feat: add monthly card periods and apparatus lesson feedback

This commit is contained in:
richarjiang
2026-10-18 15:47:29 +08:00
parent b452de379d
commit a3d58fa8f2
40 changed files with 968 additions and 170 deletions

View File

@@ -0,0 +1,16 @@
<template>
<view v-if="entries.length" class="apparatus-notes">
<view class="notes-heading"><text>器械练习</text><text class="count">{{ entries.length }} 项记录</text></view>
<view v-for="entry in entries" :key="entry.apparatus" class="apparatus-note"><text class="apparatus-label">{{ entry.apparatus }}</text><text class="apparatus-copy">{{ entry.content }}</text></view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { filledApparatusNotes } from '@mp-pilates/shared'
import type { ApparatusNotes } from '@mp-pilates/shared'
const props = defineProps<{ notes?: ApparatusNotes | null }>()
const entries = computed(() => filledApparatusNotes(props.notes))
</script>
<style scoped>
.apparatus-notes{margin:24rpx 0 20rpx;padding-top:22rpx;border-top:1rpx solid #e8e3d9;}.notes-heading{display:flex;align-items:center;justify-content:space-between;color:#8a8275;font-size:22rpx;margin-bottom:8rpx;}.count{font-size:20rpx;color:#a0978a;}.apparatus-note{padding:20rpx 0;border-bottom:1rpx solid #eee9e1;}.apparatus-note:last-child{border:0;padding-bottom:0;}.apparatus-label{display:inline-block;min-width:52rpx;border-radius:8rpx;padding:4rpx 12rpx;background:#eaf0e5;color:#60735b;font-family:'Baskerville','Songti SC',serif;font-size:26rpx;text-align:center;}.apparatus-copy{display:block;margin-top:12rpx;font-size:27rpx;line-height:1.9;white-space:pre-wrap;overflow-wrap:anywhere;color:#5e574d;}
</style>

View File

@@ -44,6 +44,7 @@
<view class="card-item selected">
<view class="card-info">
<text class="card-name">{{ memberships[0].cardType.name }}</text>
<text v-if="memberships[0].cardType.type === 'DURATION'" class="card-remain">周期 {{ formatChinaDate(memberships[0].startDate) }} 至 {{ formatChinaDate(memberships[0].expireDate) }}</text>
<text class="card-remain" v-if="memberships[0].remainingTimes !== null">
剩余 {{ memberships[0].remainingTimes }} 次
</text>
@@ -68,6 +69,7 @@
>
<view class="card-info">
<text class="card-name">{{ m.cardType.name }}</text>
<text v-if="m.cardType.type === 'DURATION'" class="card-remain">周期 {{ formatChinaDate(m.startDate) }} 至 {{ formatChinaDate(m.expireDate) }}</text>
<text class="card-remain" v-if="m.remainingTimes !== null">
剩余 {{ m.remainingTimes }} 次
</text>
@@ -92,7 +94,7 @@
<text class="deduction-text">
{{ selectedMembership.remainingTimes === null
? `「${selectedMembership.cardType.name}」有效期内不限次`
: `确认后将从「${selectedMembership.cardType.name}」扣除 1 次课时` }}
: `教练确认后将从「${selectedMembership.cardType.name}」对应周期扣除 1 次课时` }}
</text>
</view>
@@ -124,6 +126,7 @@
import { ref, computed, watch } from 'vue'
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
import { requestBookingCreatedSubscriptionMessage } from '../utils/wechat-subscription'
import { formatChinaDate } from '../utils/format'
const props = defineProps<{
visible: boolean

View File

@@ -0,0 +1,99 @@
<template>
<view class="note-overlay" :class="{ leaving: closing }" @tap="requestClose" @touchmove.stop.prevent>
<view class="note-sheet" @tap.stop @touchmove.stop>
<view class="sheet-header">
<view class="handle" />
<view class="title-row">
<view><text class="eyebrow">AFTER CLASS · 练习手记</text><text class="title">记录这一课的进步</text></view>
<button class="close" :disabled="busy || closing" aria-label="关闭课后评价" @tap="requestClose">×</button>
</view>
<text class="subtitle">{{ bookingId ? '已关联本次完成课程' : '教练笔记' }} · 每项均选填,自由记录</text>
</view>
<scroll-view scroll-y class="sheet-body" :show-scrollbar="false">
<view class="body-inner">
<view class="section-heading"><text>整体评价</text><text class="optional">选填</text></view>
<view class="summary-input">
<textarea v-model="content" aria-label="整体评价(选填)" :maxlength="LESSON_NOTE_MAX_LENGTH" :disabled="busy" :cursor-spacing="100" placeholder="本次练习的整体表现、进步或下次建议…" placeholder-class="note-placeholder" />
<text class="counter">{{ content.length }} / {{ LESSON_NOTE_MAX_LENGTH }}</text>
</view>
<view class="apparatus-heading"><view><text class="section-heading">器械练习记录</text><text class="section-caption">点击器械展开,记录本次上课情况</text></view><text class="progress">已填写 {{ filledCount }} / 7</text></view>
<view class="apparatus-list">
<view v-for="(apparatus, index) in LESSON_APPARATUS" :key="apparatus" class="apparatus-card" :class="{ expanded: active === apparatus, filled: !!notes[apparatus]?.trim() }">
<button class="apparatus-toggle" :disabled="busy" :aria-expanded="active === apparatus" @tap="toggle(apparatus)">
<text class="apparatus-index">0{{ index + 1 }}</text><text class="apparatus-name">{{ apparatus }}</text>
<text class="apparatus-state">{{ notes[apparatus]?.trim() ? '已填写' : '选填' }}</text><text class="chevron" :class="{ rotated: active === apparatus }">⌄</text>
</button>
<view class="apparatus-body" :class="{ open: active === apparatus }" :aria-hidden="active !== apparatus">
<view class="apparatus-input">
<textarea v-if="active === apparatus" v-model="notes[apparatus]" :aria-label="apparatus + ' 器械记录(选填)'" :maxlength="LESSON_NOTE_MAX_LENGTH" :disabled="busy" :cursor-spacing="100" :placeholder="'记录 ' + apparatus + ' 的练习内容与表现…'" placeholder-class="note-placeholder" />
<view class="input-foot"><text>由教练自由填写</text><text>{{ notes[apparatus]?.length || 0 }} / {{ LESSON_NOTE_MAX_LENGTH }}</text></view>
</view>
</view>
<text v-if="active !== apparatus && notes[apparatus]?.trim()" class="preview">{{ notes[apparatus]?.trim() }}</text>
</view>
</view>
<view class="visibility"><view><text class="visibility-title">分享给学员</text><text class="section-caption">{{ shared ? '保存后,学员可在成长档案中查看' : '当前仅馆主可见' }}</text></view><switch :checked="shared" :disabled="busy" color="#617d70" @change="changeShared" /></view>
</view>
</scroll-view>
<view class="sheet-footer">
<text v-if="error" class="error">{{ error }}</text>
<button class="save" :disabled="busy || closing || !hasContent" :loading="busy" @tap="save">{{ busy ? '正在保存…' : '保存课后评价' }}</button>
<text class="footer-hint">填写整体评价或任意一项器械即可保存</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import { LESSON_APPARATUS, LESSON_NOTE_MAX_LENGTH, filledApparatusNotes } from '@mp-pilates/shared'
import type { ApparatusNotes, LessonApparatus } from '@mp-pilates/shared'
import { post } from '../utils/request'
const props = defineProps<{ userId: string; bookingId?: string }>()
const emit = defineEmits<{ close: []; saved: [] }>()
const content = ref(''), notes = ref<ApparatusNotes>({}), active = ref<LessonApparatus | null>(null), shared = ref(false)
const confirmingClose = ref(false)
const busy = ref(false), closing = ref(false), error = ref('')
const filledCount = computed(() => filledApparatusNotes(notes.value).length)
const hasContent = computed(() => !!content.value.trim() || filledCount.value > 0)
const hasDraft = computed(() => !!content.value || Object.values(notes.value).some(Boolean))
let closeTimer: ReturnType<typeof setTimeout> | undefined
function toggle(apparatus: LessonApparatus) { active.value = active.value === apparatus ? null : apparatus }
function changeShared(event: Event) { shared.value = (event as unknown as { detail: { value: boolean } }).detail.value }
function close() {
uni.hideKeyboard()
closing.value = true
closeTimer = setTimeout(() => emit('close'), 220)
}
async function requestClose() {
if (busy.value || closing.value || confirmingClose.value) return
if (hasDraft.value) {
confirmingClose.value = true
const result = await uni.showModal({ title: '放弃这份未保存的评价?', content: '已填写的内容尚未保存,可以继续填写。', confirmText: '继续填写', cancelText: '放弃' }).finally(() => { confirmingClose.value = false })
if (!result.cancel) return
}
close()
}
async function save() {
if (busy.value || closing.value || !hasContent.value) return
busy.value = true
error.value = ''
try {
await post(`/admin/members/${props.userId}/progress/notes`, {
content: content.value.trim(),
apparatusNotes: Object.fromEntries(filledApparatusNotes(notes.value).map(note => [note.apparatus, note.content])),
shared: shared.value,
...(props.bookingId ? { bookingId: props.bookingId } : {}),
})
emit('saved')
uni.showToast({ title: '课后评价已保存', icon: 'success' })
close()
} catch (e) { error.value = e instanceof Error ? e.message : '保存失败,请重试' }
finally { busy.value = false }
}
onBeforeUnmount(() => { if (closeTimer) clearTimeout(closeTimer) })
</script>
<style lang="scss" scoped>
.note-overlay{position:fixed;inset:0;z-index:220;display:flex;align-items:flex-end;background:rgba(42,48,40,.42);animation:veil-in .22s ease both;color:#514943;}
.note-sheet{display:flex;flex-direction:column;width:100%;height:88vh;box-sizing:border-box;background:#fbf9f5;border-radius:36rpx 36rpx 0 0;overflow:hidden;animation:sheet-in .3s cubic-bezier(.2,.8,.2,1) both;}
button{font-weight:400;}button::after{border:0;}.sheet-header{padding:16rpx 32rpx 24rpx;border-bottom:1rpx solid #e8e3d9;}.handle{width:64rpx;height:6rpx;border-radius:8rpx;background:#d6d8cd;margin:0 auto 26rpx;}.title-row{display:flex;align-items:center;justify-content:space-between;gap:16rpx;}.eyebrow{display:block;color:#75816f;letter-spacing:3rpx;font-size:18rpx;}.title{display:block;font-family:'Songti SC','STSong',serif;font-size:38rpx;line-height:1.5;margin-top:10rpx;}.close{margin:0;flex-shrink:0;width:72rpx;height:72rpx;line-height:66rpx;padding:0;border-radius:50%;background:#efeee7;color:#827b70;font-size:42rpx;}.subtitle{display:block;font-size:23rpx;color:#827b70;margin-top:14rpx;}.sheet-body{flex:1;height:0;min-height:0;}.body-inner{padding:30rpx 32rpx 10rpx;}.section-heading{display:flex;align-items:center;gap:16rpx;font-size:28rpx;font-weight:500;}.optional{font-size:21rpx;color:#948a7b;font-weight:400;}.summary-input{margin-top:18rpx;background:#f0ede6;border:1rpx solid #e6e1d7;border-radius:20rpx;padding:20rpx;}.summary-input textarea{height:148rpx;}.counter{display:block;text-align:right;font-size:20rpx;color:#938b7f;margin-top:10rpx;}textarea{width:100%;box-sizing:border-box;font-size:27rpx;line-height:1.8;color:#514943;}.note-placeholder{color:#9a9488;font-size:25rpx;}.apparatus-heading{display:flex;justify-content:space-between;align-items:center;gap:12rpx;margin:34rpx 0 20rpx;}.section-caption{display:block;font-size:22rpx;color:#8b8276;line-height:1.7;margin-top:8rpx;}.progress{flex-shrink:0;font-size:21rpx;padding:10rpx 14rpx;background:#eaf0e6;border-radius:30rpx;color:#61775c;}.apparatus-list{display:flex;flex-direction:column;gap:14rpx;}.apparatus-card{border:1rpx solid #e6e1d8;border-radius:20rpx;background:#fffdf9;overflow:hidden;transition:border-color .22s,background-color .22s;}.apparatus-card.expanded{border-color:#91a28a;background:#f6f8f1;}.apparatus-toggle{display:flex;align-items:center;width:100%;margin:0;padding:0 22rpx;height:96rpx;line-height:1.5;background:transparent;text-align:left;border-radius:0;color:#514943;}.apparatus-index{font-family:'Baskerville',serif;font-size:24rpx;color:#aaa18f;width:48rpx;}.apparatus-name{flex:1;font-family:'Baskerville','Songti SC',serif;font-size:34rpx;letter-spacing:1rpx;}.apparatus-state{font-size:21rpx;color:#a09789;}.filled .apparatus-state{color:#617d70;}.chevron{display:block;font-size:30rpx;margin-left:20rpx;color:#8b9381;transition:transform .22s;}.chevron.rotated{transform:rotate(180deg);}.apparatus-body{height:0;opacity:0;overflow:hidden;transition:height .24s ease,opacity .2s ease;}.apparatus-body.open{height:292rpx;opacity:1;}.apparatus-input{padding:0 22rpx 20rpx;}.apparatus-input textarea{height:220rpx;padding:16rpx;background:#fffdf9;border-radius:12rpx;border:1rpx solid #e7e9df;}.input-foot{display:flex;justify-content:space-between;color:#959180;font-size:20rpx;line-height:32rpx;margin-top:12rpx;}.preview{display:block;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:23rpx;line-height:1.7;color:#8a8276;margin:-6rpx 22rpx 20rpx 70rpx;}.visibility{display:flex;align-items:center;justify-content:space-between;gap:16rpx;margin:28rpx 0 20rpx;padding:24rpx 0;border-top:1rpx solid #e6e1d8;}.visibility-title{font-size:27rpx;}.sheet-footer{padding:20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));border-top:1rpx solid #e8e3d9;background:#fbf9f5;}.save{display:block;width:100%;margin:0;border-radius:20rpx;background:#617d70;color:#fff;line-height:88rpx;font-size:28rpx;transition:background .2s,transform .15s;}.save:active{transform:scale(.985);}.save[disabled]{background:#dce1d7;color:#8e9788;}.footer-hint{display:block;text-align:center;color:#9a9285;font-size:20rpx;margin-top:14rpx;}.error{display:block;font-size:24rpx;line-height:1.6;color:#a35645;margin-bottom:16rpx;}.leaving{animation:veil-out .22s ease both;}.leaving .note-sheet{animation:sheet-out .22s ease both;}@keyframes veil-in{from{background:rgba(42,48,40,0)}to{background:rgba(42,48,40,.42)}}@keyframes veil-out{to{background:rgba(42,48,40,0)}}@keyframes sheet-in{from{transform:translateY(100%)}to{transform:translateY(0)}}@keyframes sheet-out{to{transform:translateY(100%)}}@media(prefers-reduced-motion:reduce){.note-overlay,.note-sheet,.leaving,.leaving .note-sheet{animation:none;}.apparatus-body,.chevron,.save{transition:none;}}
</style>

View File

@@ -12,12 +12,13 @@
<view class="tabs"><button v-for="item in tabs" :key="item.key" :class="{ active: tab === item.key }" @tap="tab = item.key">{{ item.name }}</button></view>
<BodyAssessmentRecords v-if="tab === 'metrics'" :records="archive.metrics" :admin="admin" :user-id="userId" @remove="remove('metrics', $event)" />
<template v-if="tab === 'notes'">
<view class="heading"><text>{{ admin ? '教练笔记' : '教练寄语' }}</text><button v-if="admin" class="small" @tap="openEditor('notes')">+ 写笔记</button></view>
<view class="heading"><text>{{ admin ? '教练笔记' : '教练寄语' }}</text><button v-if="admin" class="small" @tap="openEditor('notes')">+ 写评价</button></view>
<text class="muted intro">{{ admin ? '私密笔记仅馆主可见,开启共享后学员才能看到。' : '留住被看见的进步,也记下下一次练习的方向。' }}</text>
<view v-if="!archive.notes.length" class="empty">静待第一份练习寄语<text>教练分享的课后反馈会留在这里。</text></view>
<view v-for="note in archive.notes" :key="note.id" class="panel note" :class="{ private: !note.shared }">
<view class="heading"><text class="badge">{{ note.shared ? '教练寄语 · 学员可见' : '私密 · 仅馆主可见' }}</text><button v-if="admin" class="text-btn" @tap="remove('notes', note.id)">删除</button></view>
<text class="body-copy">{{ note.content }}</text>
<text v-if="note.content" class="body-copy">{{ note.content }}</text>
<ApparatusNoteList :notes="note.apparatusNotes" />
<text v-if="note.booking" class="lesson">课程 · {{ formatChinaDate(note.booking.timeSlot.date) }} {{ note.booking.timeSlot.startTime }}</text>
<text class="muted">{{ formatChinaDate(note.createdAt) }}</text>
</view>
@@ -44,12 +45,12 @@
<view v-for="n in [10, 30, 50]" :key="n" class="milestone" :class="{ locked: !archive.milestones.includes(n) }"><text class="kicker">A MOMENT TO REMEMBER</text><text class="milestone-number">{{ n }}<text>节</text></text><text class="milestone-title">{{ milestoneTitles[n] }}</text><text class="muted">{{ archive.milestones.includes(n) ? '你已走过这段旅程' : '还差 ' + (n - archive.completedCount) + ' 节,一步步来' }}</text><button v-if="archive.milestones.includes(n) && !admin" class="small" :disabled="busy" @tap="saveMilestone(n)">保存纪念卡</button></view>
</template>
</template>
<view v-if="editor" class="overlay" @tap="onBackdropTap">
<scroll-view scroll-y class="sheet" @tap.stop><view class="heading"><text>{{ editor === 'notes' ? '写一份教练笔记' : '添加成长照片' }}</text><button class="text-btn" :disabled="busy" @tap="closeEditor">关闭</button></view>
<template v-if="editor !== 'notes'"><text class="field-label">记录日期</text><picker mode="date" :value="recordDate" :end="today" @change="recordDate = $event.detail.value"><view class="input">{{ recordDate }} ›</view></picker></template>
<template v-if="editor === 'notes'"><text v-if="bookingId" class="lesson">已关联本次完成课程</text><textarea v-model="noteContent" maxlength="1000" placeholder="今天观察到了什么?下一次练习需要注意什么?" /><text class="muted">{{ noteContent.length }} / 1000</text><view class="switch-row"><view><text>分享给学员</text><text class="muted">{{ shared ? '这份评语将出现在学员档案' : '仅馆主自己可见' }}</text></view><switch :checked="shared" color="#617d70" @change="changeShared" /></view></template>
<template v-if="editor === 'photos'"><textarea v-model="caption" maxlength="200" placeholder="例如:正面站姿 · 第一个月" /><button class="btn pale" :disabled="busy" @tap="choosePhoto">{{ photoPath ? '重新选择照片' : '从相册选择照片' }}</button><image v-if="photoPath" class="upload-preview" :src="photoPath" mode="aspectFit" /><text class="muted">仅支持图片,最大 10 MB。学员授权前,馆主不能从档案重新读取照片。</text></template>
<text v-if="saveError" class="error">{{ saveError }}</text><button class="btn" :loading="busy" :disabled="busy" @tap="save">{{ editor === 'photos' ? '上传并请求学员授权' : '保存记录' }}</button>
<CoachNoteEditor v-if="editor === 'notes' && admin && userId" :user-id="userId" :booking-id="bookingId" @close="editor = ''" @saved="load" />
<view v-if="editor === 'photos'" class="overlay" @tap="onBackdropTap">
<scroll-view scroll-y class="sheet" @tap.stop><view class="heading"><text>添加成长照片</text><button class="text-btn" :disabled="busy" @tap="closeEditor">关闭</button></view>
<text class="field-label">记录日期</text><picker mode="date" :value="recordDate" :end="today" @change="recordDate = $event.detail.value"><view class="input">{{ recordDate }} ›</view></picker>
<textarea v-model="caption" maxlength="200" placeholder="例如:正面站姿 · 第一个月" /><button class="btn pale" :disabled="busy" @tap="choosePhoto">{{ photoPath ? '重新选择照片' : '从相册选择照片' }}</button><image v-if="photoPath" class="upload-preview" :src="photoPath" mode="aspectFit" /><text class="muted">仅支持图片,最大 10 MB。学员授权前,馆主不能从档案重新读取照片。</text>
<text v-if="saveError" class="error">{{ saveError }}</text><button class="btn" :loading="busy" :disabled="busy" @tap="save">上传并请求学员授权</button>
</scroll-view>
</view>
<canvas canvas-id="milestoneCard" class="export-canvas" style="width:600px;height:800px" />
@@ -59,6 +60,8 @@
import { computed, getCurrentInstance, onBeforeUnmount, ref, watch } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import BodyAssessmentRecords from './BodyAssessmentRecords.vue'
import CoachNoteEditor from './CoachNoteEditor.vue'
import ApparatusNoteList from './ApparatusNoteList.vue'
import type { ProgressArchive, ProgressPhotoRecord } from '@mp-pilates/shared'
import { get, post, del } from '../utils/request'
import { formatChinaDate } from '../utils/format'
@@ -66,7 +69,7 @@ const props = defineProps<{ userId?: string; admin?: boolean; bookingId?: string
const archive = ref<ProgressArchive | null>(null), loading = ref(false), error = ref(''), tab = ref('metrics'), editor = ref(''), busy = ref(false), saveError = ref('')
const tabs = [{ key: 'metrics', name: '体测' }, { key: 'notes', name: '寄语' }, { key: 'photos', name: '照片' }, { key: 'milestones', name: '里程碑' }]
const base = computed(() => props.admin ? `/admin/members/${props.userId}/progress` : '/user/progress')
const today = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10), recordDate = ref(today), noteContent = ref(''), shared = ref(false), caption = ref(''), photoPath = ref('')
const today = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10), recordDate = ref(today), caption = ref(''), photoPath = ref('')
const photoUrls = ref<Record<string, string>>({}), selectedPhotos = ref<string[]>([]), comparison = ref<{ id: string; url: string; date: string }[]>([])
const milestoneTitles: Record<number, string> = { 10: '开始,与身体相识', 30: '坚持,找到自己的节奏', 50: '积累,看见自己的力量' }
const instance = getCurrentInstance()
@@ -85,14 +88,11 @@ async function load() {
finally { if (seq === sequence && isInitial) loading.value = false }
}
function previewComparison(url: string) { uni.previewImage({ urls: comparison.value.map(p => p.url), current: url }) }
function changeShared(event: Event) { shared.value = (event as unknown as { detail: { value: boolean } }).detail.value }
function message(e: unknown) { return e instanceof Error ? e.message : '操作失败,请重试' }
function openEditor(kind: string) {
openedAt = Date.now()
editor.value = kind
saveError.value = ''
noteContent.value = ''
shared.value = false
caption.value = ''
photoPath.value = ''
recordDate.value = today
@@ -103,15 +103,10 @@ async function save() {
if (busy.value) return
busy.value = true; saveError.value = ''
try {
if (editor.value === 'notes') {
if (!noteContent.value.trim()) throw new Error('请先填写笔记')
await post(base.value + '/notes', { content: noteContent.value, shared: shared.value, ...(props.bookingId ? { bookingId: props.bookingId } : {}) })
} else {
if (!photoPath.value) throw new Error('请先选择照片')
const credentials = await post<{ id: string; uploadUrl: string; formData: Record<string, string> }>(base.value + '/photos/upload', { recordedAt: recordDate.value, caption: caption.value, fileName: photoPath.value.split('/').pop() || 'photo.jpg' })
await new Promise<void>((resolve, reject) => uni.uploadFile({ url: credentials.uploadUrl, filePath: photoPath.value, name: 'file', formData: credentials.formData, success: res => res.statusCode >= 200 && res.statusCode < 300 ? resolve() : reject(new Error('照片上传失败')), fail: () => reject(new Error('照片上传失败,请检查网络')) }))
await post(base.value + `/photos/${credentials.id}/complete`)
}
if (!photoPath.value) throw new Error('请先选择照片')
const credentials = await post<{ id: string; uploadUrl: string; formData: Record<string, string> }>(base.value + '/photos/upload', { recordedAt: recordDate.value, caption: caption.value, fileName: photoPath.value.split('/').pop() || 'photo.jpg' })
await new Promise<void>((resolve, reject) => uni.uploadFile({ url: credentials.uploadUrl, filePath: photoPath.value, name: 'file', formData: credentials.formData, success: res => res.statusCode >= 200 && res.statusCode < 300 ? resolve() : reject(new Error('照片上传失败')), fail: () => reject(new Error('照片上传失败,请检查网络')) }))
await post(base.value + `/photos/${credentials.id}/complete`)
editor.value = ''; await load(); uni.showToast({ title: '记录已保存', icon: 'success' })
} catch (e) { saveError.value = message(e) } finally { busy.value = false }
}