fix: 修复身体画像约课阻断、页面响应式丢失及多项边界问题

- fix(booking): 修复未认领画像阻断约课问题,支持约课事务内自动认领绑定
- fix(app): 修复 Pinia 状态解构导致响应式失效,补齐 PortraitRadar 组件实例传参
- fix(admin): 修复经营助手复测待办点击 404,优化体验课后线索回访文案
- fix(server): 线下评估日期间隔按中国时区校验,复测按期次单项核销,优化单次评估分享卡与限流清理
- test: 补齐 body-portrait-offline 与 booking 来源画像单元测试

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
richarjiang
2026-09-11 22:45:44 +08:00
parent ab6602e41d
commit d91282ecbd
18 changed files with 324 additions and 37 deletions

View File

@@ -3,15 +3,16 @@
</template>
<script setup lang="ts">
import { onMounted, watch } from 'vue'
import { getCurrentInstance, onMounted, watch } from 'vue'
import { BODY_DIMENSION_LABELS, type BodyPortraitScores } from '@mp-pilates/shared'
const props = defineProps<{ scores: BodyPortraitScores }>()
const instance = getCurrentInstance()
const labels = ['肩颈', '脊柱', '核心', '髋骨盆', '下肢']
const keys = ['cervicalShoulder', 'spinalMobility', 'coreControl', 'hipPelvis', 'lowerLimb'] as const
function draw() {
const ctx = uni.createCanvasContext('portraitRadar')
const ctx = uni.createCanvasContext('portraitRadar', instance?.proxy)
const w = 280
const h = 280
const cx = w / 2

View File

@@ -4,7 +4,7 @@
<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)">
<view v-for="task in dashboard.tasks" :key="task.leadId + task.kind" class="card" @tap="handleTask(task)">
<text class="name">{{ task.title }}</text>
<text class="detail">{{ task.detail }}</text>
</view>
@@ -24,7 +24,7 @@ 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'
import type { GrowthTodayDashboard, GrowthTodayTask } from '@mp-pilates/shared'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const admin = useAdminStore()
@@ -34,9 +34,12 @@ 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 handleTask(task: GrowthTodayTask) {
if (task.kind === 'reassessment_due') {
uni.navigateTo({ url: `/pages/admin/portrait-assessment?userId=${task.userId}` })
} else if (task.leadId && task.leadId.length >= 20) {
uni.navigateTo({ url: `/pages/admin/portrait-lead-detail?id=${task.leadId}` })
}
}
function navigate(path: string) { uni.navigateTo({ url: path }) }
</script>

View File

@@ -17,6 +17,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import SafetyNotice from '../../components/SafetyNotice.vue'
@@ -25,7 +26,7 @@ import { useBodyPortraitStore } from '../../stores/body-portrait'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const portrait = useBodyPortraitStore()
const report = portrait.report
const { report } = storeToRefs(portrait)
onShow(() => { portrait.track('advice_viewed') })

View File

@@ -18,6 +18,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { onLoad } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
@@ -25,7 +26,7 @@ import { useBodyPortraitStore } from '../../stores/body-portrait'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const portrait = useBodyPortraitStore()
const stats = portrait.stats
const { stats } = storeToRefs(portrait)
onLoad((query) => {
portrait.captureAttribution((query || {}) as Record<string, string>)

View File

@@ -16,7 +16,7 @@
<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>
<text v-for="row in share.rows" :key="row.label" class="meta">{{ row.label }} {{ row.first === row.latest ? row.first : `${row.first} ${row.latest}` }}</text>
</view>
<view v-if="plan" class="cta" @tap="makeShare">生成我的成长卡片</view>
<view v-else class="body">完成到店评估后教练会为你生成 12 周改善计划</view>

View File

@@ -28,6 +28,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { onShow } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import PortraitRadar from '../../components/PortraitRadar.vue'
@@ -40,20 +41,18 @@ 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
const { teaser, report, claimed } = storeToRefs(portrait)
onShow(async () => {
if (!portrait.teaser && !portrait.session) {
if (!teaser.value && !portrait.session) {
await portrait.loadMine().catch(() => {})
}
if (portrait.teaser) await portrait.track('report_viewed')
if (teaser.value) await portrait.track('report_viewed')
})
async function continueFlow() {
try {
if (!portrait.claimed) {
if (!claimed.value) {
if (!userStore.loggedIn) await userStore.login()
await portrait.claim()
}

View File

@@ -20,6 +20,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
import { getErrorMessage, wxBindPhone } from '../../utils/auth'
@@ -29,7 +30,7 @@ import { useUserStore } from '../../stores/user'
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
const portrait = useBodyPortraitStore()
const userStore = useUserStore()
const report = portrait.report
const { report } = storeToRefs(portrait)
async function goTrial() {
try {

View File

@@ -104,10 +104,11 @@ export const useBodyPortraitStore = defineStore('body-portrait', () => {
async function track(name: string) {
try {
const scopeKey = assessmentId.value || visitToken.value || `anon_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
await post('/body-portrait/events', {
name,
accessToken: accessToken.value || undefined,
idempotencyKey: `${name}:${assessmentId.value || 'anon'}`,
idempotencyKey: `${name}:${scopeKey}`.slice(0, 80),
})
} catch {
// tracking must never block the user flow

View File

@@ -41,7 +41,8 @@ export const useBookingStore = defineStore('booking', () => {
}
async function createBooking(dto: CreateBookingDto) {
const originAssessmentId = dto.originAssessmentId || useBodyPortraitStore().assessmentId || undefined
const portraitStore = useBodyPortraitStore()
const originAssessmentId = dto.originAssessmentId || (portraitStore.claimed ? portraitStore.assessmentId : undefined)
const result = await post<BookingWithDetails>('/booking', {
...dto,
...(originAssessmentId ? { originAssessmentId } : {}),