102 lines
11 KiB
Vue
102 lines
11 KiB
Vue
<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>
|