feat: 支持新版会员档案

This commit is contained in:
richarjiang
2026-10-18 14:59:48 +08:00
parent 21e3ece21c
commit b452de379d
23 changed files with 731 additions and 54 deletions

View File

@@ -0,0 +1,37 @@
<template>
<view class="assessments">
<view class="section-top"><view><text class="eyebrow">POSTURE JOURNAL</text><text class="title">体态与身体评估</text></view><button v-if="admin" class="add" @tap="open()">+ 新建记录</button></view>
<text class="intro">从每一个细节,慢慢了解身体。<text>铅垂线 · 侧视 · 正面 · 后视 · 脊柱</text></text>
<view v-if="!records.length" class="empty"><view class="empty-symbol">+</view><text class="empty-title">第一份身体观察,从这里开始</text><text>可只记录体态检查,身高、体重等均为选填。</text><button v-if="admin" class="add empty-add" @tap="open()">开始评估</button></view>
<view v-for="record in records" :key="record.id" class="record">
<view class="record-head"><view class="date"><text class="day">{{ record.recordedAt.slice(8, 10) }}</text><view><text class="month">{{ record.recordedAt.slice(0, 7).replace('-', ' / ') }}</text><text class="record-label">{{ record.assessment ? '体态分析指南检查表' : '身体刻度记录' }}</text></view></view><text class="arrow" @tap="open(record)">查看 ›</text></view>
<view v-if="record.assessment" class="scope"><text>{{ postureAnswerCount(record.assessment) }} 项检查已记录</text><text>{{ completedViews(record) }} / 5 个检查分区</text></view>
<view v-else class="scope"><text>历史体测记录</text><text>原始数据已保留</text></view>
<view v-if="measurements(record).length" class="measurements"><text v-for="item in measurements(record)" :key="item.key">{{ item.label }} <text class="value">{{ record[item.key] }}</text> {{ item.unit }}</text></view>
<text v-if="record.remark" class="remark">{{ record.remark }}</text>
<view class="record-actions"><button class="text-button" @tap="open(record)">查看完整检查表</button><view v-if="admin" class="admin-actions"><button class="text-button edit" @tap="open(record, true)">编辑</button><button class="text-button delete" @tap="$emit('remove', record.id)">删除</button></view></view>
</view>
</view>
</template>
<script setup lang="ts">
import { BODY_MEASUREMENT_FIELDS, POSTURE_SECTIONS, postureAnswerCount } from '@mp-pilates/shared'
import type { BodyMetricRecord } from '@mp-pilates/shared'
const props = defineProps<{ records: BodyMetricRecord[]; admin?: boolean; userId?: string }>()
defineEmits<{ remove: [id: string] }>()
function measurements(record: BodyMetricRecord) { return BODY_MEASUREMENT_FIELDS.filter(field => record[field.key] != null) }
function completedViews(record: BodyMetricRecord) {
const assessment = record.assessment
if (!assessment) return 0
return Number(Object.values(assessment.plumb).some(Boolean)) + Number(!!assessment.spine.flat || !!assessment.spine.imbalance)
+ POSTURE_SECTIONS.filter(section => section.groups.some(group => group.options.some(option => assessment.answers[option.id]?.length))).length
}
function open(record?: BodyMetricRecord, edit = false) {
const query = [props.admin ? `userId=${encodeURIComponent(props.userId || '')}` : '', record ? `id=${encodeURIComponent(record.id)}` : '', edit ? 'edit=1' : ''].filter(Boolean).join('&')
uni.navigateTo({ url: '/pages/profile/body-assessment?' + query })
}
</script>
<style lang="scss" scoped>
.section-top{display:flex;justify-content:space-between;align-items:center;gap:16rpx;}.eyebrow{display:block;color:#8ba5b3;font-size:18rpx;letter-spacing:4rpx;}.title{display:block;margin-top:12rpx;font-family:'Songti SC','STSong',serif;font-size:36rpx;color:#4a4035;}.add{margin:0;padding:0 22rpx;line-height:76rpx;background:#e8f0f4;color:#587b90;font-size:24rpx;border-radius:18rpx;flex-shrink:0;}.add::after,.text-button::after{border:0;}.intro{display:block;font-size:24rpx;color:#8e8274;line-height:1.9;margin:22rpx 0 30rpx;}.intro>text{display:block;font-size:21rpx;letter-spacing:1rpx;color:#a29485;}.record{padding:28rpx;margin-bottom:24rpx;background:#fffefa;border:1rpx solid #e8e1d7;border-radius:26rpx;animation:unfold .3s ease both;}.record-head{display:flex;align-items:center;justify-content:space-between;}.date{display:flex;align-items:center;gap:20rpx;}.day{font-family:'Baskerville',serif;font-size:64rpx;color:#4a4035;line-height:1;}.month{display:block;font-size:21rpx;color:#9c8e7e;letter-spacing:2rpx;}.record-label{display:block;margin-top:9rpx;font-size:25rpx;color:#5c5145;}.arrow{font-size:24rpx;color:#7ba5be;padding:20rpx 0 20rpx 24rpx;}.scope{display:flex;justify-content:space-between;gap:12rpx;margin:26rpx 0 0;padding:18rpx 20rpx;background:#f1f5f6;border-radius:14rpx;color:#7390a0;font-size:21rpx;}.measurements{display:flex;flex-wrap:wrap;gap:16rpx 22rpx;margin-top:24rpx;font-size:22rpx;color:#9b8d7d;}.value{color:#5d5246;margin-left:8rpx;font-size:26rpx;}.remark{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;white-space:pre-wrap;margin-top:20rpx;font-size:25rpx;line-height:1.8;color:#8a7c6c;}.record-actions{display:flex;justify-content:space-between;align-items:center;border-top:1rpx solid #eee8df;margin-top:24rpx;padding-top:10rpx;}.admin-actions{display:flex;gap:20rpx;}.text-button{background:transparent;padding:0;margin:0;color:#8e8274;font-size:23rpx;line-height:72rpx;}.edit{color:#587b90;}.delete{color:#b09184;}.empty{padding:48rpx 28rpx;text-align:center;border:1rpx dashed #d9d6cc;border-radius:26rpx;color:#a09080;font-size:23rpx;line-height:1.8;}.empty-symbol{font-size:54rpx;font-weight:200;color:#a9bfcc;margin-bottom:12rpx;}.empty-title{display:block;font-size:28rpx;color:#7a6a5a;margin-bottom:12rpx;}.empty-add{display:inline-block;margin-top:28rpx;min-width:200rpx;}@keyframes unfold{from{opacity:0;transform:translateY(10rpx);}to{opacity:1;transform:translateY(0);}}@media(prefers-reduced-motion:reduce){.record{animation:none;}}
</style>

View File

@@ -23,12 +23,13 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
defineProps<{
const props = defineProps<{
title: string
/** Transparent bg with white text — for pages with colored header */
transparent?: boolean
/** Show back arrow (for sub-pages navigated via navigateTo) */
showBack?: boolean
beforeBack?: () => boolean | Promise<boolean>
}>()
const statusBarHeight = ref(0)
@@ -38,7 +39,8 @@ onMounted(() => {
statusBarHeight.value = windowInfo.statusBarHeight ?? 20
})
function handleBack() {
async function handleBack() {
if (props.beforeBack && !await props.beforeBack()) return
const pages = getCurrentPages()
if (pages.length > 1) {

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,33 @@
<template>
<view class="checklist">
<view v-for="(group, index) in section.groups" :key="group.id" class="anatomy-card" :style="{ animationDelay: index * 25 + 'ms' }">
<view class="group-heading"><view class="group-title"><text class="number">{{ String(index + 1).padStart(2, '0') }}</text><text>{{ group.title }}</text></view><text class="status">{{ groupCount(group) ? groupCount(group) + ' 项已记录' : '未检查' }}</text></view>
<view v-for="option in group.options" :key="option.id" class="finding" :class="{ 'finding--selected': answers[option.id]?.length }">
<text class="finding-label">{{ option.label }}</text>
<view class="marks">
<button v-for="mark in option.bilateral ? sideMarks : wholeMarks" :key="mark.key" class="mark" :class="{ selected: answers[option.id]?.includes(mark.key) }" :disabled="readonly" :aria-label="group.title + ',' + option.label + ',' + mark.label + (answers[option.id]?.includes(mark.key) ? ',已选' : ',未选')" @tap="toggle(group, option, mark.key)"><text v-if="answers[option.id]?.includes(mark.key)" class="tick">✓</text>{{ mark.label }}</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { togglePostureMark } from '@mp-pilates/shared'
import type { PostureAssessment, PostureGroup, PostureMark, PostureOption, PostureSection } from '@mp-pilates/shared'
const props = defineProps<{ section: PostureSection; answers: PostureAssessment['answers']; readonly?: boolean }>()
const emit = defineEmits<{ change: [answers: PostureAssessment['answers']] }>()
const sideMarks: { key: PostureMark; label: string }[] = [{ key: 'right', label: '右' }, { key: 'left', label: '左' }]
const wholeMarks: { key: PostureMark; label: string }[] = [{ key: 'checked', label: '勾选' }]
function groupCount(group: PostureGroup) { return group.options.reduce((sum, option) => sum + (props.answers[option.id]?.length || 0), 0) }
function toggle(group: PostureGroup, option: PostureOption, mark: PostureMark) {
if (!props.readonly) emit('change', togglePostureMark(props.answers, group, option, mark))
}
</script>
<style lang="scss" scoped>
.anatomy-card{margin-bottom:24rpx;padding:8rpx 26rpx 18rpx;border:1rpx solid #e9e3da;border-radius:24rpx;background:#fffefa;animation:arrive .3s ease both;}
.group-heading{display:flex;align-items:center;justify-content:space-between;gap:14rpx;padding:24rpx 0;border-bottom:1rpx solid #eee9e2;margin-bottom:8rpx;}.group-title{display:flex;align-items:center;gap:16rpx;font-size:29rpx;color:#4a4035;font-weight:500;}.number{font-family:'Baskerville',serif;font-size:28rpx;color:#8ba5b3;}.status{font-size:20rpx;color:#a09080;}
.finding{display:flex;align-items:center;justify-content:space-between;gap:16rpx;min-height:94rpx;padding:6rpx 0;box-sizing:border-box;}.finding-label{flex:1;min-width:0;font-size:25rpx;line-height:1.65;color:#766b5e;}.finding--selected .finding-label{color:#4a4035;}.marks{display:flex;gap:12rpx;flex-shrink:0;}.mark{display:flex;align-items:center;justify-content:center;gap:4rpx;min-width:78rpx;min-height:72rpx;margin:0;padding:0 16rpx;border:1rpx solid #e2e5e3;border-radius:14rpx;background:#f8f8f4;font-size:24rpx;line-height:1.3;color:#9a958c;transition:background .18s,border-color .18s,color .18s,transform .18s;}.mark::after{border:0;}.mark.selected{background:#eaf2f6;border-color:#a9bfcc;color:#51778e;}.mark:active{transform:scale(.95);}.mark[disabled]{opacity:1;}.tick{font-size:20rpx;}@keyframes arrive{from{opacity:0;transform:translateY(12rpx);}to{opacity:1;transform:translateY(0);}}
@media(prefers-reduced-motion:reduce){.anatomy-card{animation:none;}.mark{transition:none;}}
</style>

View File

@@ -75,6 +75,12 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/profile/body-assessment",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/profile/progress",
"style": {

View File

@@ -0,0 +1,196 @@
<template>
<view class="assessment-page" :style="{ paddingTop: navBarHeight + 'px' }">
<CustomNavBar :title="editable ? (recordId ? '编辑体测记录' : '新建体测记录') : '体测记录详情'" show-back :before-back="beforeBack" />
<view v-if="loading" class="state"><view class="skeleton" /><view class="skeleton tall" /><text>正在翻开检查表…</text></view>
<view v-else-if="loadError" class="state"><text>{{ loadError }}</text><button class="primary retry" @tap="load">重新加载</button></view>
<template v-else-if="ready">
<view class="cover">
<view class="cover-top"><text class="eyebrow">POSTURE ASSESSMENT</text><text class="mode">{{ editable ? '填写中 · 各项选填' : '评估记录' }}</text></view>
<text class="cover-title">体态分析指南<text>检查表</text></text>
<view class="identity"><view><text class="caption">会员姓名</text><text class="member-name">{{ nickname || '会员' }}</text></view><view class="date-block"><text class="caption">评估日期</text><picker v-if="editable" mode="date" :disabled="saving" :value="recordDate" :end="today" @change="recordDate = $event.detail.value"><view class="record-date">{{ recordDate.replace(/-/g, ' / ') }} <text>⌄</text></view></picker><text v-else class="record-date">{{ recordDate.replace(/-/g, ' / ') }}</text></view></view>
<view class="cover-bottom"><text>{{ answerCount }} 项检查已记录</text><text>{{ editable ? '无需填满,可随时保存' : '未检查项目保留为空' }}</text></view>
</view>
<view class="navigation" :style="{ top: navBarHeight + 'px' }">
<scroll-view scroll-x :show-scrollbar="false" :scroll-into-view="'step-' + activeStep" :scroll-with-animation="true" class="steps-scroll"><view class="steps"><button v-for="(step, index) in steps" :id="'step-' + index" :key="step.id" class="step" :class="{ active: activeStep === index }" @tap="changeStep(index)"><text class="step-number">{{ String(index + 1).padStart(2, '0') }}</text>{{ step.label }}<view v-if="stepCount(step.id)" class="step-dot" /></button></view></scroll-view>
<view class="progress-line"><view :style="{ width: ((activeStep + 1) / steps.length * 100) + '%' }" /></view>
</view>
<view id="section-content" class="content">
<view class="section-heading"><view><text class="section-kicker">{{ steps[activeStep].english }}</text><text class="section-title">{{ steps[activeStep].title }}</text></view><text class="section-index">{{ String(activeStep + 1).padStart(2, '0') }}<text> / 06</text></text></view>
<template v-if="activeStep === 0">
<text class="guide">身高、体重等均为选填。只记录本次实际测量的数据。</text>
<view class="measurement-card"><view v-for="field in BODY_MEASUREMENT_FIELDS" :key="field.key" class="measurement-field"><text class="field-label">{{ field.label }}<text class="optional">选填</text></text><view class="measurement-input"><input v-if="editable" v-model="measurements[field.key]" :disabled="saving" :type="field.key === 'flexibility' ? 'text' : 'digit'" maxlength="8" placeholder="未测量" placeholder-class="input-placeholder" :aria-label="field.label + ',选填,单位' + field.unit" /><text v-else class="measurement-value">{{ measurements[field.key] || '—' }}</text><text class="unit">{{ field.unit }}</text></view></view></view>
<text class="small-note">坐位体前屈:未触及脚尖可填写负值。</text>
<view class="notes-card"><text class="field-label">评估备注 <text class="optional">选填</text></text><textarea v-if="editable" v-model="remark" :disabled="saving" maxlength="200" placeholder="记录本次观察、测量情况或练习关注点…" :auto-height="true" /><text v-else class="note-copy">{{ remark || '暂无备注' }}</text><text v-if="editable" class="word-count">{{ remark.length }} / 200</text></view>
</template>
<template v-else-if="activeStep === 1">
<text class="guide">依照纸质检查表,观察身体各部分的排列。未检查可留空。</text>
<view v-for="(question, index) in POSTURE_PLUMB_QUESTIONS" :key="question.key" class="question-card"><text class="question-number">0{{ index + 1 }}</text><text class="question-label">{{ question.label }}</text><view class="answer-row"><button v-for="choice in yesNo" :key="choice.value" class="answer" :class="{ selected: assessment.plumb[question.key] === choice.value }" :disabled="!editable || saving" @tap="togglePlumb(question.key, choice.value)"><text class="answer-check">{{ assessment.plumb[question.key] === choice.value ? '✓' : '○' }}</text>{{ choice.label }}</button><text class="unanswered">{{ assessment.plumb[question.key] ? '' : '未检查' }}</text></view></view>
</template>
<template v-else-if="activeSection">
<view class="view-guide"><text class="view-guide-title">两侧均需检查</text><text>{{ editable ? '按实际观察勾选;「右」「左」均指会员自身。再次轻触可取消。' : '「右」「左」均指会员自身;未勾选不代表已确认正常。' }}</text></view>
<PostureChecklist :key="activeSection.id" :section="activeSection" :answers="assessment.answers" :readonly="!editable || saving" @change="assessment.answers = $event" />
<text v-if="activeStep === 3" class="source-note">* 如有必要,可参照后视图加以确认。</text>
</template>
<template v-else-if="activeStep === 5">
<text class="guide">依次从侧面和背后观察,记录检查结果与所在位置。</text>
<view v-for="(question, index) in spineQuestions" :key="question.key" class="question-card"><text class="question-number">0{{ index + 1 }}</text><text class="spine-view">{{ question.view }}</text><text class="question-label">{{ question.label }}</text><view class="answer-row"><button v-for="choice in yesNo" :key="choice.value" class="answer" :class="{ selected: assessment.spine[question.key] === choice.value }" :disabled="!editable || saving" @tap="toggleSpine(question.key, choice.value)"><text class="answer-check">{{ assessment.spine[question.key] === choice.value ? '✓' : '○' }}</text>{{ choice.label }}</button><text class="unanswered">{{ assessment.spine[question.key] ? '' : '未检查' }}</text></view><template v-if="assessment.spine[question.key] === 'yes'"><text class="location-label">在什么位置?<text class="optional">选填</text></text><input v-if="editable" v-model="assessment.spine[question.location]" :disabled="saving" class="location-input" maxlength="200" placeholder="填写观察到的具体位置" /><text v-else class="note-copy">{{ assessment.spine[question.location] || '未记录位置' }}</text></template></view>
<view class="finish-note"><text class="finish-title">每一份观察,都是下一次练习的起点。</text><text>{{ editable ? '检查表可以分次完成。保存后,可随时回到档案继续编辑。' : '这份记录已收录于会员成长档案。' }}</text></view>
</template>
<text v-if="saveError" class="save-error">{{ saveError }}</text>
</view>
<view class="footer"><view class="footer-meta"><text>{{ editable ? (dirty ? '有未保存的修改' : '各项均可选填') : '完整保留本次检查结果' }}</text><text>{{ activeStep + 1 }} / {{ steps.length }}</text></view><view class="footer-buttons"><button class="secondary" :disabled="saving || activeStep === 0" @tap="changeStep(activeStep - 1)">上一步</button><button v-if="activeStep < steps.length - 1" class="secondary next" :disabled="saving" @tap="changeStep(activeStep + 1)">下一项 ›</button><button v-if="editable" class="primary" :loading="saving" :disabled="saving" @tap="save">{{ saving ? '保存中' : '保存记录' }}</button><button v-else-if="canEdit" class="primary" @tap="editable = true">编辑记录</button><button v-else-if="activeStep === steps.length - 1" class="primary" @tap="goBack">返回档案</button></view></view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { BODY_MEASUREMENT_FIELDS, POSTURE_PLUMB_QUESTIONS, POSTURE_SECTIONS, emptyPostureAssessment, postureAnswerCount } from '@mp-pilates/shared'
import type { BodyMetricRecord, BodyMeasurementKey, PostureAssessment, ProgressArchive } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import PostureChecklist from '../../components/PostureChecklist.vue'
import { get, post, put } from '../../utils/request'
import { getSystemLayout } from '../../utils/system'
import { useUserStore } from '../../stores/user'
const navBarHeight = getSystemLayout().navBarHeight
const userStore = useUserStore()
const userId = ref(''), recordId = ref(''), nickname = ref('')
const loading = ref(true), ready = ref(false), editable = ref(false), saving = ref(false), loadError = ref(''), saveError = ref('')
const today = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)
const recordDate = ref(today), remark = ref(''), measurements = ref<Partial<Record<BodyMeasurementKey, string>>>({}), assessment = ref<PostureAssessment>(emptyPostureAssessment())
const activeStep = ref(0), initialSnapshot = ref('')
const steps = [
{ id: 'basic', label: '基础', title: '身体基础数据', english: 'BODY MEASUREMENTS' },
{ id: 'plumb', label: '铅垂线', title: '铅垂线', english: 'PLUMB LINE' },
{ id: 'side', label: '侧视', title: '侧视图', english: 'SIDE VIEW' },
{ id: 'front', label: '正面', title: '正面视图', english: 'FRONT VIEW' },
{ id: 'back', label: '后视', title: '后视图', english: 'BACK VIEW' },
{ id: 'spine', label: '脊柱', title: '循序检查脊柱', english: 'SPINAL OBSERVATION' },
]
const yesNo = [{ value: 'yes' as const, label: '是' }, { value: 'no' as const, label: '否' }]
const spineQuestions = [
{ key: 'flat' as const, location: 'flatLocation' as const, view: '从侧面观察', label: '是否有平直部位?' },
{ key: 'imbalance' as const, location: 'imbalanceLocation' as const, view: '从背后观察并触碰检查', label: '是否有失衡部位?' },
]
const canEdit = computed(() => !!userId.value && userStore.isAdmin)
const base = computed(() => userId.value ? `/admin/members/${encodeURIComponent(userId.value)}/progress` : '/user/progress')
const activeSection = computed(() => POSTURE_SECTIONS.find(section => section.id === steps[activeStep.value].id))
const answerCount = computed(() => postureAnswerCount(assessment.value))
const snapshot = () => JSON.stringify({ recordedAt: recordDate.value, measurements: measurements.value, remark: remark.value, assessment: assessment.value })
const dirty = computed(() => ready.value && editable.value && snapshot() !== initialSnapshot.value)
let requestedEdit = false
function stepCount(id: string) {
if (id === 'basic') return Object.values(measurements.value).filter(value => value?.trim()).length + Number(!!remark.value.trim())
if (id === 'plumb') return Object.values(assessment.value.plumb).filter(Boolean).length
if (id === 'spine') return Number(!!assessment.value.spine.flat) + Number(!!assessment.value.spine.imbalance)
return Object.entries(assessment.value.answers).filter(([key, marks]) => key.startsWith(id + '.') && marks.length).length
}
function changeStep(index: number) {
if (saving.value || index < 0 || index >= steps.length) return
activeStep.value = index
saveError.value = ''
// Keep the section navigation in view when moving between long checklists.
uni.pageScrollTo({ scrollTop: 0, duration: 200 })
}
function togglePlumb(key: 'alignment' | 'stacking', value: 'yes' | 'no') {
if (!editable.value || saving.value) return
assessment.value.plumb[key] = assessment.value.plumb[key] === value ? null : value
}
function toggleSpine(key: 'flat' | 'imbalance', value: 'yes' | 'no') {
if (!editable.value || saving.value) return
assessment.value.spine[key] = assessment.value.spine[key] === value ? null : value
if (assessment.value.spine[key] !== 'yes') assessment.value.spine[key === 'flat' ? 'flatLocation' : 'imbalanceLocation'] = ''
}
function message(error: unknown) { return error instanceof Error ? error.message : '操作失败,请重试' }
async function load() {
loading.value = true; ready.value = false; loadError.value = ''
try {
if (userId.value) {
if (!userStore.hasProfile) await userStore.fetchProfile()
if (!userStore.isAdmin) throw new Error('仅馆主可编辑会员体测档案')
}
if (recordId.value) {
const result = await get<{ nickname: string; record: BodyMetricRecord }>(base.value + '/metrics/' + encodeURIComponent(recordId.value))
nickname.value = result.nickname
recordDate.value = result.record.recordedAt.slice(0, 10)
remark.value = result.record.remark || ''
measurements.value = Object.fromEntries(BODY_MEASUREMENT_FIELDS.map(field => [field.key, result.record[field.key] == null ? '' : String(result.record[field.key])]))
// Historical measurements have no posture payload; open them without inventing findings.
assessment.value = result.record.assessment || emptyPostureAssessment()
} else {
if (!canEdit.value) throw new Error('请从会员档案进入体测记录')
nickname.value = (await get<ProgressArchive>(base.value)).nickname
}
editable.value = canEdit.value && (!recordId.value || requestedEdit)
initialSnapshot.value = snapshot()
ready.value = true
} catch (error) { loadError.value = message(error) }
finally { loading.value = false }
}
async function save() {
if (saving.value || !editable.value || !canEdit.value) return
saveError.value = ''
try {
const payload: Record<string, unknown> = { recordedAt: recordDate.value, remark: remark.value, assessment: assessment.value }
let measured = false
for (const field of BODY_MEASUREMENT_FIELDS) {
const raw = measurements.value[field.key]?.trim() || ''
if (!raw) { payload[field.key] = null; continue }
const value = Number(raw)
if (!/^-?\d+(\.\d+)?$/.test(raw) || !Number.isFinite(value) || value < field.min || value > field.max) {
activeStep.value = 0
throw new Error(`${field.label}请填写 ${field.min}~${field.max} ${field.unit} 范围内的数字`)
}
payload[field.key] = value; measured = true
}
if (!measured && !answerCount.value && !remark.value.trim()) throw new Error('请至少记录一项测量、体态检查或评估备注')
saving.value = true
// Freeze the submitted snapshot so a slow request cannot overwrite subsequent input.
const submitted = JSON.parse(JSON.stringify(payload)) as Record<string, unknown>
const record = recordId.value
? await put<BodyMetricRecord>(base.value + '/metrics/' + encodeURIComponent(recordId.value), submitted)
: await post<BodyMetricRecord>(base.value + '/metrics', submitted)
recordId.value = record.id
initialSnapshot.value = snapshot()
editable.value = false
uni.showToast({ title: '体测记录已保存', icon: 'success' })
uni.pageScrollTo({ scrollTop: 0, duration: 200 })
} catch (error) {
saveError.value = message(error)
uni.showToast({ title: saveError.value, icon: 'none' })
} finally { saving.value = false }
}
async function beforeBack() {
if (saving.value) return false
if (!dirty.value) return true
const result = await uni.showModal({ title: '离开检查表?', content: '本次修改尚未保存,离开后将丢失。', confirmText: '继续填写', cancelText: '放弃修改' })
if (!result.cancel) return false
uni.disableAlertBeforeUnload({})
return true
}
async function goBack() { if (await beforeBack()) uni.navigateBack() }
watch(dirty, value => {
if (value) uni.enableAlertBeforeUnload({ message: '体测记录尚未保存,确定离开吗?' })
else uni.disableAlertBeforeUnload({})
})
onLoad(query => { userId.value = String(query?.userId || ''); recordId.value = String(query?.id || ''); requestedEdit = query?.edit === '1'; void load() })
onUnload(() => uni.disableAlertBeforeUnload({}))
</script>
<style lang="scss" scoped>
.assessment-page{min-height:100vh;box-sizing:border-box;background:#faf8f5;color:#4a4035;padding-bottom:calc(220rpx + env(safe-area-inset-bottom));}button::after{border:0;}button{font-weight:400;}button[disabled]{opacity:.5;}.cover{margin:28rpx 32rpx 0;padding:30rpx;background:#eef3f5;border:1rpx solid #dae5ea;border-radius:28rpx;}.cover-top{display:flex;align-items:center;justify-content:space-between;gap:12rpx;}.eyebrow{font-size:18rpx;letter-spacing:3rpx;color:#849eac;}.mode{font-size:20rpx;color:#7a94a1;}.cover-title{display:block;font-family:'Songti SC','STSong',serif;font-size:44rpx;line-height:1.5;margin:22rpx 0 30rpx;letter-spacing:2rpx;}.cover-title>text{font-size:30rpx;margin-left:18rpx;color:#7b8c92;}.identity{display:flex;justify-content:space-between;gap:24rpx;}.identity>view{min-width:0;}.caption{display:block;font-size:20rpx;color:#8e9a9d;margin-bottom:10rpx;}.member-name{display:block;font-size:29rpx;line-height:1.5;overflow-wrap:anywhere;}.date-block{flex-shrink:0;}.record-date{font-size:25rpx;line-height:1.7;color:#657b86;}.record-date>text{margin-left:12rpx;}.cover-bottom{display:flex;justify-content:space-between;gap:12rpx;border-top:1rpx solid #dbe5e9;padding-top:22rpx;margin-top:28rpx;font-size:20rpx;color:#85969d;}
.navigation{position:sticky;z-index:20;background:#faf8f5;padding-top:20rpx;}.steps-scroll{width:100%;}.steps-scroll :deep(.uni-scroll-view){scrollbar-width:none;}.steps-scroll :deep(::-webkit-scrollbar){display:none;width:0;height:0;}.steps{display:flex;gap:10rpx;padding:0 32rpx 20rpx;}.step{position:relative;flex-shrink:0;display:flex;align-items:center;gap:9rpx;margin:0;padding:0 20rpx;height:76rpx;line-height:76rpx;border-radius:16rpx;background:transparent;color:#a39687;font-size:25rpx;transition:background .2s,color .2s;}.step.active{background:#e6eef2;color:#547b91;}.step-number{font-family:'Baskerville',serif;font-size:24rpx;opacity:.65;}.step-dot{position:absolute;right:10rpx;top:10rpx;width:7rpx;height:7rpx;border-radius:50%;background:#8ca58f;}.progress-line{height:3rpx;background:#eee8df;}.progress-line>view{height:100%;background:#a9bfcc;transition:width .25s ease;}
.content{padding:34rpx 32rpx;}.section-heading{display:flex;align-items:center;justify-content:space-between;gap:20rpx;margin:8rpx 0 24rpx;}.section-kicker{display:block;font-size:18rpx;color:#a09080;letter-spacing:3rpx;}.section-title{display:block;margin-top:12rpx;font-family:'Songti SC','STSong',serif;font-size:37rpx;}.section-index{font-family:'Baskerville',serif;font-size:52rpx;color:#b1c2cb;}.section-index>text{font-size:24rpx;color:#c8c4bc;}.guide{display:block;font-size:24rpx;line-height:1.9;color:#958777;margin-bottom:28rpx;}.measurement-card{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:30rpx 24rpx;background:#fffefa;padding:28rpx;border:1rpx solid #e9e3da;border-radius:24rpx;}.field-label{display:block;font-size:25rpx;line-height:1.7;}.optional{font-size:20rpx;color:#b1a596;margin-left:12rpx;}.measurement-input{display:flex;align-items:center;border-bottom:1rpx solid #e9e4db;gap:10rpx;margin-top:12rpx;}.measurement-input input{flex:1;min-width:0;width:100%;height:80rpx;font-size:32rpx;color:#4a4035;}.measurement-value{flex:1;line-height:80rpx;font-size:32rpx;}.unit{font-size:23rpx;color:#b1a595;}.small-note{display:block;color:#a49584;font-size:21rpx;line-height:1.8;margin:18rpx 8rpx 30rpx;}.notes-card{background:#fffefa;border:1rpx solid #e9e3da;border-radius:24rpx;padding:26rpx;}.notes-card textarea{width:100%;min-height:180rpx;line-height:1.8;font-size:26rpx;box-sizing:border-box;margin-top:20rpx;}.word-count{display:block;text-align:right;font-size:20rpx;color:#b2a697;}.note-copy{display:block;font-size:26rpx;color:#8e8070;line-height:1.8;white-space:pre-wrap;overflow-wrap:anywhere;margin-top:16rpx;}
.view-guide{background:#f0eee7;border-left:4rpx solid #d4ccba;padding:20rpx 24rpx;border-radius:0 16rpx 16rpx 0;margin-bottom:28rpx;}.view-guide-title{display:block;font-size:24rpx;color:#80725f;margin-bottom:6rpx;}.view-guide>text:last-child{font-size:22rpx;line-height:1.8;color:#a0927e;}.source-note{display:block;color:#a08e76;font-size:23rpx;line-height:1.8;margin:16rpx 4rpx;}.question-card{background:#fffefa;border:1rpx solid #e9e3da;border-radius:24rpx;padding:28rpx;margin-bottom:24rpx;animation:appear .3s ease both;}.question-number{display:block;font-size:26rpx;font-family:'Baskerville',serif;color:#a9bfcc;letter-spacing:2rpx;margin-bottom:18rpx;}.question-label{display:block;font-size:28rpx;line-height:1.8;}.answer-row{display:flex;align-items:center;gap:16rpx;margin-top:28rpx;}.answer{display:flex;align-items:center;gap:16rpx;margin:0;min-height:80rpx;padding:0 32rpx;line-height:80rpx;border-radius:16rpx;border:1rpx solid #e2e5e3;background:#f8f8f4;color:#9b968c;font-size:26rpx;transition:background .2s;}.answer.selected{background:#eaf2f6;border-color:#a9bfcc;color:#51778e;}.answer[disabled]{opacity:1;}.answer-check{font-size:25rpx;}.unanswered{font-size:21rpx;color:#b1a596;margin-left:auto;}.spine-view{display:block;font-size:23rpx;color:#9a8b77;margin-bottom:10rpx;}.location-label{display:block;font-size:24rpx;color:#8d7e6c;margin-top:30rpx;}.location-input{background:#f4f1eb;box-sizing:border-box;height:90rpx;border-radius:14rpx;padding:0 22rpx;font-size:25rpx;margin-top:16rpx;}.finish-note{padding:28rpx 8rpx;font-size:23rpx;color:#a09280;line-height:1.9;}.finish-title{display:block;font-family:'Songti SC',serif;font-size:29rpx;color:#80725f;margin-bottom:14rpx;}
.footer{position:fixed;z-index:30;bottom:0;left:0;right:0;background:#fffefa;border-top:1rpx solid #eae4db;padding:18rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));box-shadow:0 -8rpx 30rpx rgba(74,64,53,.025);}.footer-meta{display:flex;justify-content:space-between;font-size:20rpx;color:#a49685;line-height:1.8;margin-bottom:14rpx;}.footer-buttons{display:flex;gap:14rpx;}.secondary,.primary{display:flex;align-items:center;justify-content:center;margin:0;line-height:88rpx;min-height:88rpx;border-radius:18rpx;font-size:25rpx;padding:0 24rpx;}.secondary{background:#f2eee7;color:#938571;}.next{flex:1;}.primary{flex:1;background:#7ba5be;color:#fff;}.save-error{display:block;background:#faf0ec;border-radius:14rpx;padding:20rpx;font-size:24rpx;color:#b17764;line-height:1.8;margin-top:20rpx;}.state{padding:70rpx 32rpx;text-align:center;font-size:26rpx;color:#a09080;line-height:1.8;}.retry{margin-top:30rpx;}.skeleton{height:220rpx;border-radius:24rpx;background:#eeeae3;margin-bottom:28rpx;animation:pulse 1.5s ease infinite;}.skeleton.tall{height:460rpx;}@keyframes pulse{50%{opacity:.4;}}@keyframes appear{from{opacity:0;transform:translateY(10rpx);}to{opacity:1;transform:translateY(0);}}@media(prefers-reduced-motion:reduce){.question-card,.skeleton{animation:none;}.step,.progress-line>view,.answer{transition:none;}}
</style>