Files
mp-pilates/packages/app/src/components/CoachNoteEditor.vue
2026-10-18 16:09:39 +08:00

102 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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: [id: string] }>()
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(savedId?: string) {
uni.hideKeyboard()
closing.value = true
closeTimer = setTimeout(() => {
emit('close')
if (savedId) emit('saved', savedId)
}, 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 {
const note = await post<{ id: string }>(`/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 } : {}),
})
uni.showToast({ title: '课后评价已保存', icon: 'success' })
close(note.id)
} 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>