feat: 新增个人身体画像评估与馆主经营助手
把 3 分钟身体状态评估做成独立获客链路,匿名测评后登录认领完整报告,并接入体验预约与今日待办。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
81
packages/app/src/pages/admin/portrait-assessment.vue
Normal file
81
packages/app/src/pages/admin/portrait-assessment.vue
Normal 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>
|
||||
67
packages/app/src/pages/admin/portrait-lead-detail.vue
Normal file
67
packages/app/src/pages/admin/portrait-lead-detail.vue
Normal 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>
|
||||
41
packages/app/src/pages/admin/portrait-leads.vue
Normal file
41
packages/app/src/pages/admin/portrait-leads.vue
Normal 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>
|
||||
46
packages/app/src/pages/admin/portrait-plan.vue
Normal file
46
packages/app/src/pages/admin/portrait-plan.vue
Normal 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>
|
||||
52
packages/app/src/pages/admin/portrait-today.vue
Normal file
52
packages/app/src/pages/admin/portrait-today.vue
Normal 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>
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
44
packages/app/src/pages/portrait/advice.vue
Normal file
44
packages/app/src/pages/portrait/advice.vue
Normal 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>
|
||||
156
packages/app/src/pages/portrait/assessment.vue
Normal file
156
packages/app/src/pages/portrait/assessment.vue
Normal 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>
|
||||
49
packages/app/src/pages/portrait/index.vue
Normal file
49
packages/app/src/pages/portrait/index.vue
Normal 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>
|
||||
70
packages/app/src/pages/portrait/plan.vue
Normal file
70
packages/app/src/pages/portrait/plan.vue
Normal 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>
|
||||
76
packages/app/src/pages/portrait/report.vue
Normal file
76
packages/app/src/pages/portrait/report.vue
Normal 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>
|
||||
67
packages/app/src/pages/portrait/trial.vue
Normal file
67
packages/app/src/pages/portrait/trial.vue
Normal 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>
|
||||
Reference in New Issue
Block a user