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>

View File

@@ -176,6 +176,77 @@
"style": {
"navigationStyle": "custom"
}
},
{
"path": "portrait-today",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "portrait-leads",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "portrait-lead-detail",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "portrait-assessment",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "portrait-plan",
"style": {
"navigationStyle": "custom"
}
}
]
},
{
"root": "pages/portrait",
"pages": [
{
"path": "index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "assessment",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "report",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "advice",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "trial",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "plan",
"style": {
"navigationStyle": "custom"
}
}
]
}

View File

@@ -2,6 +2,21 @@
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="管理中心" show-back />
<!-- Section: 课务运营 -->
<view class="section-header">
<text class="section-title">今日经营</text>
</view>
<view class="list">
<view class="list-item" @tap="navigate('/pages/admin/portrait-today')">
<text class="item-title">今日经营助手</text>
<text class="arrow-text"></text>
</view>
<view class="list-item" @tap="navigate('/pages/admin/portrait-leads')">
<text class="item-title">身体画像线索</text>
<text class="arrow-text"></text>
</view>
</view>
<!-- Section: 课务运营 -->
<view class="section-header">
<text class="section-title">课务运营</text>

View File

@@ -0,0 +1,81 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="专业评估" show-back />
<view class="card" v-for="field in fields" :key="field.key">
<text class="name">{{ field.label }} {{ form.observations[field.key] }}/5</text>
<slider :min="1" :max="5" :value="form.observations[field.key]" @change="onObserve(field.key, $event)" />
</view>
<view class="card">
<text class="name">主观紧张 0-10</text>
<slider :min="0" :max="10" :value="form.subjectiveTension || 0" @change="onTension" />
</view>
<view class="card">
<textarea v-model="form.coachSummary" placeholder="教练观察" class="area" />
<textarea v-model="form.trainingFocus" placeholder="训练重点" class="area" />
<textarea v-model="form.phaseGoal" placeholder="第一阶段目标" class="area" />
</view>
<view class="cta" @tap="save">保存评估报告</view>
</view>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from './stores/admin'
import { getErrorMessage } from '../../utils/auth'
import { ProfessionalAssessmentKind } from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const admin = useAdminStore()
const userId = ref('')
const form = reactive({
kind: ProfessionalAssessmentKind.INITIAL,
recordedAt: new Date().toISOString().slice(0, 10),
observations: { headPosition: 3, shoulderPosition: 3, thoracicExtension: 3, shoulderFlexion: 3, breathing: 3, pelvis: 3, coreControl: 3, hipMobility: 3, singleLeg: 3 },
subjectiveTension: 5,
coachSummary: '',
trainingFocus: '',
phaseGoal: '',
})
const fields = [
{ key: 'headPosition', label: '头部位置' },
{ key: 'shoulderPosition', label: '肩部位置' },
{ key: 'thoracicExtension', label: '胸椎活动' },
{ key: 'shoulderFlexion', label: '肩屈活动' },
{ key: 'breathing', label: '呼吸模式' },
{ key: 'pelvis', label: '骨盆位置' },
{ key: 'coreControl', label: '核心控制' },
{ key: 'hipMobility', label: '髋部活动' },
{ key: 'singleLeg', label: '单腿稳定' },
] as const
onLoad((query) => { userId.value = String(query?.userId || '') })
function onObserve(key: keyof typeof form.observations, event: { detail: { value: number } }) {
form.observations[key] = Number(event.detail.value)
}
function onTension(event: { detail: { value: number } }) {
form.subjectiveTension = Number(event.detail.value)
}
async function save() {
try {
await admin.createProfessionalAssessment(userId.value, form)
uni.showToast({ title: '已保存', icon: 'success' })
setTimeout(() => uni.navigateBack(), 400)
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '保存失败'), icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 32rpx 80rpx; }
.card { background: #fff; border-radius: 20rpx; padding: 24rpx; margin-top: 16rpx; }
.name { display: block; font-size: 26rpx; color: #4a4035; margin-bottom: 8rpx; }
.area { width: 100%; min-height: 120rpx; font-size: 26rpx; margin-top: 12rpx; }
.cta { margin-top: 32rpx; height: 88rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
</style>

View File

@@ -0,0 +1,67 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="线索详情" show-back />
<view v-if="lead" class="card">
<text class="name">{{ lead.nickname }} · {{ lead.phone || '未留手机' }}</text>
<text class="detail">来源 {{ lead.source || 'organic' }} · {{ lead.campaignId || '无活动' }}</text>
<text class="detail">画像 {{ lead.report?.headline || '尚未生成' }}</text>
</view>
<view v-if="lead?.report" class="card">
<text class="name">五维关注度</text>
<text class="detail">肩颈 {{ lead.report.scores.cervicalShoulder }} · 脊柱 {{ lead.report.scores.spinalMobility }} · 核心 {{ lead.report.scores.coreControl }} · 髋骨盆 {{ lead.report.scores.hipPelvis }} · 下肢 {{ lead.report.scores.lowerLimb }}</text>
</view>
<view v-if="lead?.safetyFlagged" class="card"><text class="name">安全分流</text><text class="detail">不要强推体验课先确认运动条件</text></view>
<view class="card">
<text class="name">首次评估建议</text>
<text v-for="hint in lead?.firstAssessmentHints || []" :key="hint" class="detail"> {{ hint }}</text>
</view>
<view class="card">
<text class="name">跟进文案</text>
<text class="draft">{{ lead?.followUpDraft }}</text>
<view class="cta" @tap="copy">复制 微信发送</view>
</view>
<view class="cta ghost" @tap="goAssess">记录线下评估</view>
<view class="cta ghost" @tap="goPlan">生成 12 周计划</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from './stores/admin'
import type { GrowthLeadDetail } from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const admin = useAdminStore()
const lead = ref<GrowthLeadDetail | null>(null)
const leadId = ref('')
onLoad(async (query) => {
leadId.value = String(query?.id || '')
lead.value = await admin.fetchGrowthLead(leadId.value)
})
function copy() {
if (!lead.value) return
uni.setClipboardData({ data: lead.value.followUpDraft })
}
function goAssess() {
if (!lead.value) return
uni.navigateTo({ url: `/pages/admin/portrait-assessment?userId=${lead.value.userId}` })
}
function goPlan() {
if (!lead.value) return
uni.navigateTo({ url: `/pages/admin/portrait-plan?userId=${lead.value.userId}` })
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 32rpx 80rpx; }
.card { background: #fff; border-radius: 20rpx; padding: 28rpx; margin-top: 16rpx; }
.name { display: block; font-size: 30rpx; color: #4a4035; }
.detail, .draft { display: block; margin-top: 10rpx; color: #7a6a5a; font-size: 24rpx; line-height: 1.7; }
.cta { margin-top: 20rpx; height: 84rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
.ghost { background: #efe8e1; color: #5d5148; }
</style>

View File

@@ -0,0 +1,41 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="身体画像线索" show-back />
<view v-for="item in items" :key="item.id" class="card" @tap="open(item.id)">
<text class="name">{{ item.nickname }} · {{ stageLabel(item.stage) }}</text>
<text class="detail">{{ item.phone || '未留手机号' }} · {{ item.source || 'organic' }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from './stores/admin'
import { GROWTH_LEAD_STAGE_LABELS, type GrowthLeadStage, type GrowthLeadSummary } from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const admin = useAdminStore()
const items = ref<GrowthLeadSummary[]>([])
onShow(async () => {
const result = await admin.fetchGrowthLeads()
items.value = [...result.items]
})
function stageLabel(stage: GrowthLeadStage) {
return GROWTH_LEAD_STAGE_LABELS[stage] || stage
}
function open(id: string) {
uni.navigateTo({ url: `/pages/admin/portrait-lead-detail?id=${id}` })
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 32rpx 40rpx; }
.card { background: #fff; border-radius: 20rpx; padding: 28rpx; margin-top: 16rpx; }
.name { display: block; font-size: 30rpx; color: #4a4035; }
.detail { display: block; margin-top: 8rpx; color: #7a6a5a; font-size: 24rpx; }
</style>

View File

@@ -0,0 +1,46 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="12 周改善计划" show-back />
<text class="lead">生成后学员可在小程序看到三阶段计划而不是 12 节课</text>
<view class="cta" @tap="create">生成计划</view>
<view v-if="plan" class="card">
<text class="name">{{ plan.title }}</text>
<text v-for="phase in plan.phases" :key="phase.id" class="detail">{{ phase.name }} · {{ phase.lessonStart }}-{{ phase.lessonEnd }} </text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from './stores/admin'
import { getErrorMessage } from '../../utils/auth'
import type { TrainingPlanRecord } from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const admin = useAdminStore()
const userId = ref('')
const plan = ref<TrainingPlanRecord | null>(null)
onLoad((query) => { userId.value = String(query?.userId || '') })
async function create() {
try {
plan.value = await admin.createTrainingPlan(userId.value, { title: '你的 12 周身体改善计划', weeks: 12 })
uni.showToast({ title: '已生成', icon: 'success' })
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '生成失败'), icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 32rpx 60rpx; }
.lead { display: block; padding: 24rpx 8rpx; color: #7a6a5a; font-size: 26rpx; line-height: 1.6; }
.cta { height: 88rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
.card { margin-top: 24rpx; background: #fff; border-radius: 20rpx; padding: 28rpx; }
.name { display: block; font-size: 30rpx; color: #4a4035; }
.detail { display: block; margin-top: 10rpx; color: #7a6a5a; font-size: 24rpx; }
</style>

View File

@@ -0,0 +1,52 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="今日经营助手" show-back />
<view class="hero">
<text class="title">今天有 {{ dashboard.tasks.length }} 件值得关注的事</text>
</view>
<view v-for="task in dashboard.tasks" :key="task.leadId + task.kind" class="card" @tap="openLead(task.leadId)">
<text class="name">{{ task.title }}</text>
<text class="detail">{{ task.detail }}</text>
</view>
<view class="card">
<text class="name">渠道转化</text>
<text v-for="row in dashboard.funnel" :key="row.source" class="detail">
{{ row.source }} · 测评 {{ row.completed }} · 预约 {{ row.booked }} · 到店 {{ row.attended }} · 成交 {{ row.purchased }}
</text>
</view>
<view class="link" @tap="navigate('/pages/admin/portrait-leads')">全部线索 </view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useAdminStore } from './stores/admin'
import type { GrowthTodayDashboard } from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const admin = useAdminStore()
const dashboard = ref<GrowthTodayDashboard>({ tasks: [], funnel: [] })
onShow(async () => {
dashboard.value = await admin.fetchGrowthToday()
})
function openLead(id: string) {
if (id.length < 20) return
uni.navigateTo({ url: `/pages/admin/portrait-lead-detail?id=${id}` })
}
function navigate(path: string) { uni.navigateTo({ url: path }) }
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 32rpx 60rpx; }
.hero { padding: 24rpx 8rpx; }
.title { font-size: 36rpx; color: #4a4035; }
.card { background: #fff; border-radius: 20rpx; padding: 28rpx; margin-bottom: 16rpx; }
.name { display: block; font-size: 30rpx; color: #4a4035; }
.detail { display: block; margin-top: 10rpx; color: #7a6a5a; font-size: 24rpx; line-height: 1.6; }
.link { padding: 24rpx 8rpx; color: #6b8276; }
</style>

View File

@@ -25,6 +25,13 @@ import type {
AdminArrangeBookingDto,
MembershipWithCardType,
BookingWithDetails,
GrowthTodayDashboard,
GrowthLeadSummary,
GrowthLeadDetail,
ProfessionalAssessmentSessionRecord,
CreateProfessionalAssessmentDto,
TrainingPlanRecord,
CreateTrainingPlanDto,
} from '@mp-pilates/shared'
interface LegacyPaginatedData<T> {
@@ -263,6 +270,32 @@ export const useAdminStore = defineStore('admin', () => {
return get<TeachingAnalytics>('/admin/teaching-analytics', { month })
}
async function fetchGrowthToday() {
return get<GrowthTodayDashboard>('/admin/growth/today')
}
async function fetchGrowthLeads(params: { page?: number; search?: string; stage?: string } = {}) {
return get<PaginatedData<GrowthLeadSummary>>('/admin/growth/leads', params)
}
async function fetchGrowthLead(id: string) {
return get<GrowthLeadDetail>(`/admin/growth/leads/${id}`)
}
async function createProfessionalAssessment(userId: string, dto: CreateProfessionalAssessmentDto) {
return post<ProfessionalAssessmentSessionRecord>(
`/admin/members/${userId}/professional-assessments`,
dto as unknown as Record<string, unknown>,
)
}
async function createTrainingPlan(userId: string, dto: CreateTrainingPlanDto) {
return post<TrainingPlanRecord>(
`/admin/members/${userId}/training-plans`,
dto as unknown as Record<string, unknown>,
)
}
return {
fetchReviews, fetchReviewTrend,
fetchTeachingAnalytics,
@@ -304,5 +337,10 @@ export const useAdminStore = defineStore('admin', () => {
fetchSchedulePreview,
previewScheduleByDate,
publishDaySlots,
fetchGrowthToday,
fetchGrowthLeads,
fetchGrowthLead,
createProfessionalAssessment,
createTrainingPlan,
}
})

View File

@@ -0,0 +1,44 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="日常建议" show-back />
<SafetyNotice v-if="report?.safety.flagged" :message="report.safety.message || ''" />
<view v-else-if="report?.advice">
<text class="title">给你的三个日常建议</text>
<view v-for="item in report.advice.items" :key="item.title" class="card">
<text class="name">{{ item.title }}</text>
<text class="body">{{ item.detail }}</text>
<text class="stop">如出现{{ item.stopIf }}</text>
</view>
</view>
<text class="disclaimer">这些建议用于日常活动参考不能替代现场评估</text>
<view class="cta" @tap="next">了解线上画像的局限</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import SafetyNotice from '../../components/SafetyNotice.vue'
import { getSystemLayout } from '../../utils/system'
import { useBodyPortraitStore } from '../../stores/body-portrait'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const portrait = useBodyPortraitStore()
const report = portrait.report
onShow(() => { portrait.track('advice_viewed') })
function next() {
uni.navigateTo({ url: '/pages/portrait/trial' })
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 120rpx; }
.title { display: block; font-size: 40rpx; color: #4a4035; margin: 12rpx 0 24rpx; }
.card { background: #fff; border-radius: 24rpx; padding: 28rpx; margin-bottom: 20rpx; }
.name { display: block; font-size: 30rpx; color: #4a4035; }
.body, .stop, .disclaimer { display: block; margin-top: 12rpx; color: #7a6a5a; font-size: 26rpx; line-height: 1.7; }
.cta { margin-top: 32rpx; height: 92rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
</style>

View File

@@ -0,0 +1,156 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="身体状态评估" show-back />
<PortraitProgress :step="step + 1" :total="steps.length" />
<text class="q">{{ current.title }}</text>
<text class="d">{{ current.hint }}</text>
<BodySilhouette v-if="current.body" :selected="bodyValue" @change="onBody" />
<view v-else class="options">
<view
v-for="option in current.options"
:key="option.value"
class="opt"
:class="{ on: isOn(option.value) }"
@tap="pick(option.value)"
>
{{ option.label }}
</view>
</view>
<text class="disclaimer">非医疗诊断仅用于运动训练参考</text>
<view class="nav">
<view v-if="step > 0" class="ghost" @tap="step -= 1">上一步</view>
<view class="next" @tap="next">{{ step === steps.length - 1 ? '生成画像' : '继续' }}</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import BodySilhouette from '../../components/BodySilhouette.vue'
import PortraitProgress from '../../components/PortraitProgress.vue'
import { getSystemLayout } from '../../utils/system'
import { getErrorMessage } from '../../utils/auth'
import { useBodyPortraitStore } from '../../stores/body-portrait'
import {
AFTER_SITTING_LABELS,
AfterSitting,
BodyRegion,
EXERCISE_FREQ_LABELS,
ExerciseFreq,
PORTRAIT_GOAL_LABELS,
PortraitGoal,
SAFETY_FLAG_LABELS,
SafetyFlag,
SITTING_HOURS_LABELS,
SittingHours,
STANDING_NOTICE_LABELS,
StandingNotice,
WORK_POSTURE_LABELS,
WorkPosture,
} from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const portrait = useBodyPortraitStore()
const step = ref(0)
const steps = [
{ key: 'concerns', title: '最近身体哪里最困扰你?', hint: '可以多选,我们会据此调整后面的问题。', body: true },
{ key: 'goal', title: '如果训练有效果,你最希望看到什么变化?', hint: '后面的报告会用你自己的目标来写。', options: enumOptions(PORTRAIT_GOAL_LABELS) },
{ key: 'sittingHours', title: '每天坐多久?', hint: '这能帮助理解当前身体状态是怎么形成的。', options: enumOptions(SITTING_HOURS_LABELS) },
{ key: 'exerciseFreq', title: '平时运动频率?', hint: '没有对错,如实选择即可。', options: enumOptions(EXERCISE_FREQ_LABELS) },
{ key: 'workPosture', title: '工作时最常见状态?', hint: '选最常出现的一种。', options: enumOptions(WORK_POSTURE_LABELS) },
{ key: 'endOfDayFatigue', title: '一天结束后,身体哪里最累?', hint: '可以和困扰区域不同。', body: true },
{ key: 'afterSitting', title: '长时间坐着后,你会出现?', hint: '可多选。', options: enumOptions(AFTER_SITTING_LABELS), multi: true },
{ key: 'standingNotice', title: '自然站立时,你有没有注意过?', hint: '用日常语言描述,不需要专业判断。', options: enumOptions(STANDING_NOTICE_LABELS), multi: true },
{ key: 'safety', title: '最近是否存在以下情况?', hint: '这一题不计入关注度,只用于安全分流。', options: enumOptions(SAFETY_FLAG_LABELS), multi: true },
]
const current = computed(() => steps[step.value])
const bodyValue = computed(() => (current.value.key === 'concerns' ? portrait.answers.concerns : portrait.answers.endOfDayFatigue) as string[])
function enumOptions(labels: Record<string, string>) {
return Object.entries(labels).map(([value, label]) => ({ value, label }))
}
function isOn(value: string) {
const answers = portrait.answers as unknown as Record<string, unknown>
const currentValue = answers[current.value.key]
return Array.isArray(currentValue) ? currentValue.includes(value) : currentValue === value
}
async function persist(patch: Record<string, unknown>) {
await portrait.saveAnswers(patch as never)
}
async function onBody(value: BodyRegion[]) {
await persist({ [current.value.key]: value })
}
async function pick(value: string) {
if (current.value.multi) {
const answers = portrait.answers as unknown as Record<string, unknown>
const list = [...((answers[current.value.key] as string[]) || [])]
const exclusiveNone = current.value.key === 'safety' || current.value.key === 'afterSitting' || current.value.key === 'standingNotice'
if (value === 'none' || value === 'unnoticed') {
await persist({ [current.value.key]: [value] })
return
}
const next = list.includes(value) ? list.filter((item) => item !== value) : [...list.filter((item) => item !== 'none' && item !== 'unnoticed'), value]
if (exclusiveNone && !next.length) return
await persist({ [current.value.key]: next })
return
}
await persist({ [current.value.key]: value })
}
async function next() {
try {
if (step.value < steps.length - 1) {
step.value += 1
return
}
uni.showLoading({ title: '生成画像...' })
await portrait.complete()
uni.hideLoading()
uni.redirectTo({ url: '/pages/portrait/report' })
} catch (err) {
uni.hideLoading()
uni.showToast({ title: getErrorMessage(err, '请先完成必答题'), icon: 'none' })
}
}
onLoad(async () => {
try {
await portrait.ensureSession()
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '测评暂时无法开始'), icon: 'none' })
}
})
void AfterSitting
void ExerciseFreq
void PortraitGoal
void SafetyFlag
void SittingHours
void StandingNotice
void WorkPosture
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 160rpx; }
.q { display: block; font-size: 40rpx; line-height: 1.4; color: #4a4035; }
.d { display: block; margin: 16rpx 0 28rpx; color: #8a7b6e; font-size: 26rpx; line-height: 1.6; }
.options { display: flex; flex-direction: column; gap: 16rpx; }
.opt { padding: 28rpx; border-radius: 20rpx; background: #fff; color: #5d5148; font-size: 28rpx; }
.opt.on { background: #6b8276; color: #fff; }
.disclaimer { display: block; margin-top: 36rpx; color: #a09080; font-size: 22rpx; }
.nav { position: fixed; left: 0; right: 0; bottom: 0; padding: 24rpx 40rpx 48rpx; display: flex; gap: 16rpx; background: #fbf9f6; }
.ghost, .next { flex: 1; height: 88rpx; border-radius: 999rpx; display: flex; align-items: center; justify-content: center; }
.ghost { background: #efe8e1; color: #6b5c50; }
.next { background: #6b8276; color: #fff; }
</style>

View File

@@ -0,0 +1,49 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="身体状态评估" :show-back="false" />
<view class="hero">
<text class="kicker">3 分钟身体状态评估</text>
<text class="title">最近身体哪里最让你困扰</text>
<text class="lead">用一份对话式问卷生成你的身体画像不是医疗诊断只用于运动训练参考</text>
<text v-if="stats?.completedCount" class="count">已有 {{ stats.completedCount }} 人完成评估</text>
<view class="cta" @tap="start">开始测试</view>
</view>
<view class="trust">
<text>STOTT 认证教练</text>
<text>一对一专业评估</text>
<text>非医疗诊断仅用于运动训练参考</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { useBodyPortraitStore } from '../../stores/body-portrait'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const portrait = useBodyPortraitStore()
const stats = portrait.stats
onLoad((query) => {
portrait.captureAttribution((query || {}) as Record<string, string>)
portrait.fetchStats().catch(() => {})
})
function start() {
uni.navigateTo({ url: '/pages/portrait/assessment' })
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 80rpx; }
.hero { padding-top: 48rpx; }
.kicker { display: block; color: #8a7b6e; font-size: 22rpx; letter-spacing: 3rpx; }
.title { display: block; margin-top: 20rpx; font-size: 48rpx; line-height: 1.35; color: #4a4035; }
.lead { display: block; margin-top: 20rpx; font-size: 28rpx; line-height: 1.7; color: #7a6a5a; }
.count { display: block; margin-top: 24rpx; color: #6b8276; font-size: 24rpx; }
.cta { margin-top: 48rpx; height: 96rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 30rpx; }
.trust { margin-top: 56rpx; padding: 28rpx; border-radius: 24rpx; background: #fff; display: flex; flex-direction: column; gap: 12rpx; color: #7a6a5a; font-size: 24rpx; }
</style>

View File

@@ -0,0 +1,70 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="改善计划" show-back />
<view v-if="plan" class="card">
<text class="title">{{ plan.title }}</text>
<view v-for="phase in plan.phases" :key="phase.id" class="phase">
<text class="name">{{ phase.name }}</text>
<text class="meta"> {{ phase.lessonStart }}{{ phase.lessonEnd }} · {{ phase.focus.join(' / ') }}</text>
<text class="body">{{ phase.summary }}</text>
</view>
</view>
<view v-if="todos.length" class="card">
<text class="name">阶段复测</text>
<text v-for="todo in todos" :key="todo.id" class="body"> {{ todo.lessonCheckpoint }} {{ todo.completedAt ? '已完成' : '待安排' }}</text>
</view>
<view v-if="share" class="card">
<text class="title">{{ share.title }}</text>
<text class="body">{{ share.caption }}</text>
<text v-for="row in share.rows" :key="row.label" class="meta">{{ row.label }} {{ row.first }} {{ row.latest }}</text>
</view>
<view v-if="plan" class="cta" @tap="makeShare">生成我的成长卡片</view>
<view v-else class="body">完成到店评估后教练会为你生成 12 周改善计划</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { get, post } from '../../utils/request'
import { getErrorMessage } from '../../utils/auth'
import type { GrowthShareCardRecord, ReassessmentTodoRecord, TrainingPlanRecord } from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const plan = ref<TrainingPlanRecord | null>(null)
const todos = ref<ReassessmentTodoRecord[]>([])
const share = ref<GrowthShareCardRecord | null>(null)
onShow(async () => {
try {
const plans = await get<TrainingPlanRecord[]>('/body-portrait/plans')
plan.value = plans[0] || null
todos.value = await get<ReassessmentTodoRecord[]>('/body-portrait/todos')
} catch {}
})
async function makeShare() {
try {
share.value = await post<GrowthShareCardRecord>('/body-portrait/share-cards', { includePhotos: false })
uni.showToast({ title: '已生成,可分享给朋友', icon: 'none' })
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '暂时无法生成'), icon: 'none' })
}
}
onShareAppMessage(() => ({
title: share.value?.title || '我的普拉提变化',
path: share.value ? `/pages/portrait/index?source=member_share&shareCode=${share.value.shareCode}` : '/pages/portrait/index?source=member_share',
}))
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 80rpx; }
.card { background: #fff; border-radius: 24rpx; padding: 28rpx; margin-bottom: 20rpx; }
.title { display: block; font-size: 36rpx; color: #4a4035; }
.name { display: block; margin-top: 16rpx; font-size: 30rpx; color: #4a4035; }
.meta, .body { display: block; margin-top: 10rpx; color: #7a6a5a; font-size: 26rpx; line-height: 1.6; }
.cta { height: 92rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
</style>

View File

@@ -0,0 +1,76 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="你的身体画像" show-back />
<view v-if="teaser" class="card">
<text class="kicker">你的身体画像</text>
<text class="title">{{ teaser.headline }}</text>
<text class="summary">{{ claimed && report ? report.summary : '登录后查看完整个性化报告。问卷画像只能反映生活习惯和主观感受。' }}</text>
<text class="match">问卷依据{{ teaser.matchQuality === 'full' ? '充分' : '一般' }}这表示回答是否足够形成解释不是诊断准确率</text>
</view>
<SafetyNotice v-if="report?.safety.flagged" :message="report.safety.message || ''" />
<PortraitRadar v-if="claimed && report" :scores="report.scores" />
<view v-if="claimed && report" class="block">
<view v-for="(item, index) in report.evidence" :key="item.title" class="evidence">
<text class="etitle">{{ index + 1 }} {{ item.title }}</text>
<text class="eans">根据你的答案{{ item.answers.join('、') }}</text>
<text class="ebody">{{ item.explanation }}</text>
</view>
</view>
<view v-if="claimed && report" class="block">
<text class="etitle">这些问题是有关联的</text>
<text v-for="stepItem in report.chain.steps" :key="stepItem" class="chain">{{ stepItem }}</text>
<text class="ebody">{{ report.chain.takeaway }}</text>
</view>
<text class="disclaimer">非医疗诊断仅用于运动训练参考</text>
<view class="cta" @tap="continueFlow">{{ claimed ? '查看建议' : '登录查看完整报告' }}</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import PortraitRadar from '../../components/PortraitRadar.vue'
import SafetyNotice from '../../components/SafetyNotice.vue'
import { getSystemLayout } from '../../utils/system'
import { getErrorMessage } from '../../utils/auth'
import { useBodyPortraitStore } from '../../stores/body-portrait'
import { useUserStore } from '../../stores/user'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const portrait = useBodyPortraitStore()
const userStore = useUserStore()
const teaser = portrait.teaser
const report = portrait.report
const claimed = portrait.claimed
onShow(async () => {
if (!portrait.teaser && !portrait.session) {
await portrait.loadMine().catch(() => {})
}
if (portrait.teaser) await portrait.track('report_viewed')
})
async function continueFlow() {
try {
if (!portrait.claimed) {
if (!userStore.loggedIn) await userStore.login()
await portrait.claim()
}
uni.navigateTo({ url: '/pages/portrait/advice' })
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '请先登录后查看完整报告'), icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 120rpx; }
.card, .block, .evidence { background: #fff; border-radius: 24rpx; padding: 32rpx; margin-bottom: 24rpx; }
.kicker { color: #8a7b6e; font-size: 22rpx; }
.title { display: block; margin-top: 12rpx; font-size: 40rpx; color: #4a4035; line-height: 1.4; }
.summary, .match, .ebody, .eans, .chain { display: block; margin-top: 16rpx; color: #7a6a5a; font-size: 26rpx; line-height: 1.7; }
.etitle { display: block; font-size: 30rpx; color: #4a4035; }
.disclaimer { display: block; color: #a09080; font-size: 22rpx; margin: 12rpx 0 24rpx; }
.cta { height: 92rpx; border-radius: 999rpx; background: #6b8276; color: #fff; display: flex; align-items: center; justify-content: center; }
</style>

View File

@@ -0,0 +1,67 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="到店评估" show-back />
<text class="title">线上画像只能看到一部分</text>
<text class="lead">问卷能够帮助我们了解你的生活习惯和主观感受但身体真正如何运动需要通过现场观察进一步判断</text>
<view class="card">
<text class="name">到店专业评估会进一步看</text>
<text class="row">静态体态脊柱骨盆</text>
<text class="row">呼吸活动度稳定性与基础动作</text>
<text class="row">明确个人训练重点</text>
</view>
<view v-if="report?.safety.flagged" class="card">
<text class="name">当前不适合直接预约体验训练</text>
<text class="lead">建议先确认运动条件你也可以联系工作室我们会帮你判断下一步</text>
</view>
<button v-else-if="!userStore.user?.phone" class="cta" open-type="getPhoneNumber" @getphonenumber="book">预约身体评估体验</button>
<view v-else class="cta" @tap="goTrial">预约身体评估体验</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { getErrorMessage, wxBindPhone } from '../../utils/auth'
import { useBodyPortraitStore } from '../../stores/body-portrait'
import { useUserStore } from '../../stores/user'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const portrait = useBodyPortraitStore()
const userStore = useUserStore()
const report = portrait.report
async function goTrial() {
try {
if (!userStore.loggedIn) await userStore.login()
await portrait.track('trial_clicked')
uni.navigateTo({ url: '/pages/card/detail?trial=1&fromPortrait=1' })
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '请先登录'), icon: 'none' })
}
}
async function book(e: { detail: { encryptedData: string; iv: string; errMsg: string } }) {
try {
if (!userStore.loggedIn) await userStore.login()
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
uni.showToast({ title: '预约体验需要授权手机号', icon: 'none' })
return
}
await wxBindPhone(e as Parameters<typeof wxBindPhone>[0])
await userStore.fetchProfile()
await goTrial()
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '请先授权手机号'), icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: #fbf9f6; padding: 0 40rpx 80rpx; }
.title { display: block; font-size: 40rpx; color: #4a4035; margin-top: 12rpx; }
.lead, .row { display: block; margin-top: 16rpx; color: #7a6a5a; font-size: 26rpx; line-height: 1.7; }
.card { margin-top: 28rpx; background: #fff; border-radius: 24rpx; padding: 28rpx; }
.name { display: block; font-size: 30rpx; color: #4a4035; }
.cta { margin-top: 48rpx; height: 92rpx; border-radius: 999rpx; background: #6b8276; color: #fff; font-size: 30rpx; display: flex; align-items: center; justify-content: center; }
</style>

View File

@@ -0,0 +1,142 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type {
BodyPortraitAnswers,
BodyPortraitSessionResponse,
BodyPortraitSource,
BodyPortraitStats,
BodyPortraitTeaser,
BodyPortraitReport,
} from '@mp-pilates/shared'
import { EMPTY_ANSWERS } from './portrait-empty'
import { get, post, put } from '../utils/request'
const VISIT_KEY = 'portrait_visit_token'
const ACCESS_KEY = 'portrait_access_token'
const SOURCE_KEY = 'portrait_source'
const CAMPAIGN_KEY = 'portrait_campaign'
const REFERRAL_KEY = 'portrait_referral'
export const useBodyPortraitStore = defineStore('body-portrait', () => {
const visitToken = ref(String(uni.getStorageSync(VISIT_KEY) || ''))
const accessToken = ref(String(uni.getStorageSync(ACCESS_KEY) || ''))
const session = ref<BodyPortraitSessionResponse | null>(null)
const stats = ref<BodyPortraitStats | null>(null)
const loading = ref(false)
const answers = computed(() => session.value?.answers || EMPTY_ANSWERS)
const teaser = computed<BodyPortraitTeaser | null>(() => session.value?.teaser || null)
const report = computed<BodyPortraitReport | null>(() => session.value?.report || null)
const claimed = computed(() => !!session.value?.claimed && !!session.value?.report)
const assessmentId = computed(() => session.value?.assessmentId || '')
function persist() {
if (visitToken.value) uni.setStorageSync(VISIT_KEY, visitToken.value)
if (accessToken.value) uni.setStorageSync(ACCESS_KEY, accessToken.value)
}
function captureAttribution(query: Record<string, string | undefined> = {}) {
const source = query.source || query.utm_source
const campaign = query.campaign || query.campaign_id
const referral = query.inviteCode || query.ref || query.shareCode
if (source) uni.setStorageSync(SOURCE_KEY, source)
if (campaign) uni.setStorageSync(CAMPAIGN_KEY, campaign)
if (referral) uni.setStorageSync(REFERRAL_KEY, referral)
}
async function fetchStats() {
stats.value = await get<BodyPortraitStats>('/body-portrait/stats')
return stats.value
}
async function ensureSession() {
if (session.value?.accessToken) return session.value
loading.value = true
try {
if (!visitToken.value) {
const visit = await post<{ visitId: string; visitToken: string; source: BodyPortraitSource }>('/body-portrait/visits', {
source: String(uni.getStorageSync(SOURCE_KEY) || 'organic'),
campaignId: String(uni.getStorageSync(CAMPAIGN_KEY) || '') || undefined,
referralCode: String(uni.getStorageSync(REFERRAL_KEY) || '') || undefined,
landingPath: '/pages/portrait/index',
})
visitToken.value = visit.visitToken
}
const started = await post<BodyPortraitSessionResponse>('/body-portrait/assessments', { visitToken: visitToken.value })
accessToken.value = started.accessToken
session.value = started
persist()
return started
} finally {
loading.value = false
}
}
async function saveAnswers(next: Partial<BodyPortraitAnswers>) {
if (!accessToken.value) await ensureSession()
const updated = await put<BodyPortraitSessionResponse>('/body-portrait/assessments', {
accessToken: accessToken.value,
answers: { ...answers.value, ...next },
})
accessToken.value = updated.accessToken || accessToken.value
session.value = { ...updated, accessToken: accessToken.value }
persist()
return updated
}
async function complete() {
const updated = await post<BodyPortraitSessionResponse>('/body-portrait/assessments/complete', { accessToken: accessToken.value })
session.value = { ...updated, accessToken: accessToken.value }
return updated
}
async function claim() {
const updated = await post<BodyPortraitSessionResponse>('/body-portrait/assessments/claim', { accessToken: accessToken.value })
session.value = { ...updated, accessToken: accessToken.value, claimed: true }
return updated
}
async function loadMine() {
const latest = await get<BodyPortraitSessionResponse | null>('/body-portrait/me')
if (latest) session.value = { ...latest, accessToken: accessToken.value }
return latest
}
async function track(name: string) {
try {
await post('/body-portrait/events', {
name,
accessToken: accessToken.value || undefined,
idempotencyKey: `${name}:${assessmentId.value || 'anon'}`,
})
} catch {
// tracking must never block the user flow
}
}
function keepAnonymousOnLogout() {
session.value = session.value?.claimed ? null : session.value
}
return {
visitToken,
accessToken,
session,
stats,
loading,
answers,
teaser,
report,
claimed,
assessmentId,
captureAttribution,
fetchStats,
ensureSession,
saveAnswers,
complete,
claim,
loadMine,
track,
keepAnonymousOnLogout,
}
})

View File

@@ -9,6 +9,7 @@ import type {
TeachingScheduleSlot,
} from '@mp-pilates/shared'
import { get, post, put } from '../utils/request'
import { useBodyPortraitStore } from './body-portrait'
/** Server paginated responses use `data` field, not `items` from the shared type */
interface ServerPaginatedResult<T> {
@@ -40,7 +41,11 @@ export const useBookingStore = defineStore('booking', () => {
}
async function createBooking(dto: CreateBookingDto) {
const result = await post<BookingWithDetails>('/booking', dto as unknown as Record<string, unknown>)
const originAssessmentId = dto.originAssessmentId || useBodyPortraitStore().assessmentId || undefined
const result = await post<BookingWithDetails>('/booking', {
...dto,
...(originAssessmentId ? { originAssessmentId } : {}),
} as unknown as Record<string, unknown>)
return result
}

View File

@@ -0,0 +1,13 @@
import type { BodyPortraitAnswers } from '@mp-pilates/shared'
export const EMPTY_ANSWERS: BodyPortraitAnswers = {
concerns: [],
goal: null,
sittingHours: null,
exerciseFreq: null,
workPosture: null,
endOfDayFatigue: [],
afterSitting: [],
standingNotice: [],
safety: [],
}

View File

@@ -1,4 +1,5 @@
import { useInviteStore } from './invite'
import { useBodyPortraitStore } from './body-portrait'
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type {
@@ -161,6 +162,7 @@ export const useUserStore = defineStore('user', () => {
stats.value = null
memberships.value = []
resetSubscriptionMessageTemplateCache()
useBodyPortraitStore().keepAnonymousOnLogout()
}
function logout() {