feat: share lesson feedback with owner-only access
This commit is contained in:
@@ -32,7 +32,7 @@
|
||||
<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 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">
|
||||
@@ -49,7 +49,7 @@ import { LESSON_APPARATUS, LESSON_NOTE_MAX_LENGTH, filledApparatusNotes } from '
|
||||
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 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('')
|
||||
@@ -59,10 +59,13 @@ const hasDraft = computed(() => !!content.value || Object.values(notes.value).so
|
||||
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() {
|
||||
function close(savedId?: string) {
|
||||
uni.hideKeyboard()
|
||||
closing.value = true
|
||||
closeTimer = setTimeout(() => emit('close'), 220)
|
||||
closeTimer = setTimeout(() => {
|
||||
emit('close')
|
||||
if (savedId) emit('saved', savedId)
|
||||
}, 220)
|
||||
}
|
||||
async function requestClose() {
|
||||
if (busy.value || closing.value || confirmingClose.value) return
|
||||
@@ -78,15 +81,14 @@ async function save() {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await post(`/admin/members/${props.userId}/progress/notes`, {
|
||||
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 } : {}),
|
||||
})
|
||||
emit('saved')
|
||||
uni.showToast({ title: '课后评价已保存', icon: 'success' })
|
||||
close()
|
||||
close(note.id)
|
||||
} catch (e) { error.value = e instanceof Error ? e.message : '保存失败,请重试' }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<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 class="note-footer"><text class="muted">{{ formatChinaDate(note.createdAt) }}</text><button v-if="admin" class="note-open" @tap="openNote(note.id)">查看与分享 <text>↗</text></button><button v-else class="note-open" @tap="openNote(note.id)">查看评价 ›</button></view>
|
||||
</view>
|
||||
</template>
|
||||
<template v-if="tab === 'photos'">
|
||||
@@ -45,7 +45,7 @@
|
||||
<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>
|
||||
<CoachNoteEditor v-if="editor === 'notes' && admin && userId" :user-id="userId" :booking-id="bookingId" @close="editor = ''" @saved="load" />
|
||||
<CoachNoteEditor v-if="editor === 'notes' && admin && userId" :user-id="userId" :booking-id="bookingId" @close="editor = ''" @saved="onNoteSaved" />
|
||||
<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>
|
||||
@@ -65,6 +65,7 @@ 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'
|
||||
import { adminLessonFeedbackUrl } from '../utils/lesson-feedback'
|
||||
const props = defineProps<{ userId?: string; admin?: boolean; bookingId?: string; refreshKey?: number }>()
|
||||
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: '里程碑' }]
|
||||
@@ -87,6 +88,11 @@ async function load() {
|
||||
catch (e) { if (seq === sequence) error.value = message(e) }
|
||||
finally { if (seq === sequence && isInitial) loading.value = false }
|
||||
}
|
||||
function openNote(id: string) {
|
||||
const url = props.admin && props.userId ? adminLessonFeedbackUrl(props.userId, id) : `/pages/profile/lesson-feedback?id=${encodeURIComponent(id)}`
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
function onNoteSaved(id: string) { void load(); openNote(id) }
|
||||
function previewComparison(url: string) { uni.previewImage({ urls: comparison.value.map(p => p.url), current: url }) }
|
||||
function message(e: unknown) { return e instanceof Error ? e.message : '操作失败,请重试' }
|
||||
function openEditor(kind: string) {
|
||||
@@ -172,6 +178,7 @@ onShow(() => { if (archive.value && !editor.value) void load() })
|
||||
onBeforeUnmount(() => { sequence++; if (timer) clearTimeout(timer) })
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.note-footer{display:flex;align-items:center;justify-content:space-between;gap:16rpx;margin-top:20rpx;padding-top:18rpx;border-top:1rpx solid #e8e1d7;}.note-open{margin:0;padding:14rpx 20rpx;background:#e9eee5;color:#516c5d;border-radius:14rpx;font-size:23rpx;line-height:1.6;}.note-open text{margin-left:10rpx;}
|
||||
.archive{padding:28rpx 32rpx 60rpx;color:#514943;--sage:#617d70;--muted:#827b70;}
|
||||
button::after{border:0;}button{font-weight:400;}.hero{padding:36rpx 30rpx 32rpx;background:#eceee3;border:1rpx solid #dce1d3;border-radius:28rpx;}.kicker{display:block;font-size:20rpx;letter-spacing:4rpx;color:#75806a;}.headline{display:block;font-family:'Songti SC','STSong',serif;font-size:38rpx;line-height:1.5;margin-top:20rpx;}.total{display:flex;align-items:baseline;gap:18rpx;margin:25rpx 0 20rpx;font-size:88rpx;font-family:'Baskerville','Songti SC',serif;line-height:1;}.total .unit{font-size:24rpx;}.muted{display:block;color:var(--muted);font-size:23rpx;line-height:1.8;}.tabs{display:flex;margin:32rpx 0;gap:8rpx;border-bottom:1rpx solid #e5dfd5;}.tabs button{flex:1;margin:0;background:transparent;border-radius:0;font-size:27rpx;line-height:88rpx;padding:0;color:#8b8276;border-bottom:4rpx solid transparent;}.tabs button.active{color:#506754;border-color:#617d70;}.heading{display:flex;align-items:center;justify-content:space-between;gap:16rpx;font-size:29rpx;min-height:56rpx;}.intro{margin:12rpx 0 24rpx;}.small{margin:0;padding:14rpx 20rpx;border-radius:16rpx;background:#e9eee5;font-size:23rpx;line-height:1.6;color:#516c5d;min-height:64rpx;box-sizing:border-box;}.panel{padding:28rpx;background:#fffdf9;border:1rpx solid #e8e1d7;border-radius:24rpx;margin-bottom:22rpx;}.empty{padding:58rpx 32rpx;border:1rpx dashed #d8d1c5;border-radius:24rpx;font-size:29rpx;text-align:center;line-height:1.7;color:#766b5c;}.empty text{display:block;font-size:24rpx;color:#92877a;margin-top:12rpx;}.body-copy{display:block;font-size:28rpx;line-height:1.9;white-space:pre-wrap;overflow-wrap:anywhere;margin:18rpx 0;}.text-btn{margin:0;background:transparent;color:#8a7161;font-size:23rpx;line-height:64rpx;padding:0 10rpx;}.badge{font-size:22rpx;color:#6b7c65;}.private{background:#f3eee7;}.private .badge{color:#927966;}.lesson{display:block;font-size:23rpx;color:#627660;line-height:1.8;margin:12rpx 0;}.photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18rpx;}.photo-card{min-width:0;padding-bottom:24rpx;}.photo{display:block;width:100%;height:330rpx;border-radius:18rpx;background:#e9e6df;}.placeholder{display:flex;align-items:center;justify-content:center;padding:25rpx;font-size:24rpx;color:#897c6c;line-height:1.7;}.photo-date{display:block;font-size:24rpx;margin:14rpx 0 8rpx;}.caption{display:block;font-size:25rpx;line-height:1.7;overflow-wrap:anywhere;}.photo-card .small{margin-top:14rpx;width:100%;}.photo-card .muted{font-size:21rpx;}.milestone{position:relative;padding:40rpx 30rpx;text-align:center;margin-bottom:24rpx;background:#eee8d9;border:1rpx solid #d9ccb3;border-radius:22rpx;}.milestone-number{display:block;font-family:'Baskerville',serif;font-size:110rpx;color:#7d7554;margin:20rpx 0 6rpx;}.milestone-number text{font-size:26rpx;margin-left:12rpx;}.milestone-title{display:block;font-family:'Songti SC',serif;font-size:32rpx;margin:10rpx 0 20rpx;}.milestone .small{margin:24rpx auto 0;width:240rpx;background:#64775e;color:white;}.locked{background:#f1eee7;border-color:#e2ddd3;opacity:.7;}.btn{margin:26rpx 0 0;border-radius:20rpx;min-height:88rpx;line-height:88rpx;background:#617d70;color:#fff;font-size:27rpx;padding:0 20rpx;}.btn.pale{background:#ece8df;color:#6b5f50;}.btn[disabled]{background:#d4dacf;color:#7d8476;}.state{padding:80rpx 20rpx;text-align:center;font-size:27rpx;line-height:1.8;}.overlay{position:fixed;inset:0;z-index:200;background:rgba(44,42,35,.38);display:flex;align-items:flex-end;}.sheet{width:100%;max-height:85vh;box-sizing:border-box;background:#fbf9f5;border-radius:32rpx 32rpx 0 0;padding:30rpx 32rpx calc(40rpx + env(safe-area-inset-bottom));}.field-label{display:block;font-size:25rpx;margin:24rpx 0 12rpx;}.input{min-height:84rpx;line-height:84rpx;background:#eeeae2;padding:0 22rpx;border-radius:16rpx;font-size:27rpx;}.field{margin-bottom:16rpx;}textarea{box-sizing:border-box;width:100%;height:230rpx;background:#eeeae2;border-radius:18rpx;padding:24rpx;font-size:27rpx;line-height:1.8;margin:24rpx 0 12rpx;}.switch-row{display:flex;align-items:center;justify-content:space-between;margin-top:24rpx;gap:20rpx;font-size:27rpx;}.error{display:block;margin-top:20rpx;color:#a35645;font-size:25rpx;}.upload-preview{width:100%;height:320rpx;margin:20rpx 0;}.export-canvas{position:fixed;left:-2000px;top:0;pointer-events:none;}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user