feat: 会员档案寄语改名为课后总结并支持教练编辑已评价信息

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
richarjiang
2026-10-21 11:17:14 +08:00
parent 3dc56a113b
commit 819367178a
8 changed files with 160 additions and 31 deletions

View File

@@ -4,10 +4,10 @@
<view class="sheet-header"> <view class="sheet-header">
<view class="handle" /> <view class="handle" />
<view class="title-row"> <view class="title-row">
<view><text class="eyebrow">AFTER CLASS · 练习手记</text><text class="title">记录这一课的进步</text></view> <view><text class="eyebrow">AFTER CLASS · 练习手记</text><text class="title">{{ isEditing ? '编辑课后评价' : '记录这一课的进步' }}</text></view>
<button class="close" :disabled="busy || closing" aria-label="关闭课后评价" @tap="requestClose">×</button> <button class="close" :disabled="busy || closing" aria-label="关闭课后评价" @tap="requestClose">×</button>
</view> </view>
<text class="subtitle">{{ bookingId ? '已关联本次完成课程' : '教练笔记' }} · 每项均选填,自由记录</text> <text class="subtitle">{{ hasLinkedLesson ? '已关联本次完成课程' : '教练笔记' }} · 每项均选填,自由记录</text>
</view> </view>
<scroll-view scroll-y class="sheet-body" :show-scrollbar="false"> <scroll-view scroll-y class="sheet-body" :show-scrollbar="false">
<view class="body-inner"> <view class="body-inner">
@@ -37,25 +37,46 @@
</scroll-view> </scroll-view>
<view class="sheet-footer"> <view class="sheet-footer">
<text v-if="error" class="error">{{ error }}</text> <text v-if="error" class="error">{{ error }}</text>
<button class="save" :disabled="busy || closing || !hasContent" :loading="busy" @tap="save">{{ busy ? '正在保存…' : '保存课后评价' }}</button> <button class="save" :disabled="busy || closing || !hasContent" :loading="busy" @tap="save">{{ busy ? '正在保存…' : (isEditing ? '保存修改' : '保存课后评价') }}</button>
<text class="footer-hint">填写整体评价或任意一项器械即可保存</text> <text class="footer-hint">填写整体评价或任意一项器械即可保存</text>
</view> </view>
</view> </view>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { LESSON_APPARATUS, LESSON_NOTE_MAX_LENGTH, filledApparatusNotes } from '@mp-pilates/shared' import { LESSON_APPARATUS, LESSON_NOTE_MAX_LENGTH, filledApparatusNotes } from '@mp-pilates/shared'
import type { ApparatusNotes, LessonApparatus } from '@mp-pilates/shared' import type { ApparatusNotes, LessonApparatus, MemberNoteRecord } from '@mp-pilates/shared'
import { post } from '../utils/request' import { post, put } from '../utils/request'
const props = defineProps<{ userId: string; bookingId?: string }>() const props = defineProps<{ userId: string; bookingId?: string; note?: MemberNoteRecord | null }>()
const emit = defineEmits<{ close: []; saved: [id: string] }>() const emit = defineEmits<{ close: []; saved: [id: string] }>()
const content = ref(''), notes = ref<ApparatusNotes>({}), active = ref<LessonApparatus | null>(null), shared = ref(false) const content = ref(''), notes = ref<ApparatusNotes>({}), active = ref<LessonApparatus | null>(null), shared = ref(false), initialSnapshot = ref('')
const confirmingClose = ref(false) const confirmingClose = ref(false)
const busy = ref(false), closing = ref(false), error = ref('') const busy = ref(false), closing = ref(false), error = ref('')
const isEditing = computed(() => !!props.note?.id)
const hasLinkedLesson = computed(() => Boolean(props.bookingId || props.note?.bookingId || props.note?.booking))
const filledCount = computed(() => filledApparatusNotes(notes.value).length) const filledCount = computed(() => filledApparatusNotes(notes.value).length)
const hasContent = computed(() => !!content.value.trim() || filledCount.value > 0) const hasContent = computed(() => !!content.value.trim() || filledCount.value > 0)
const hasDraft = computed(() => !!content.value || Object.values(notes.value).some(Boolean)) function serializeDraft(text: string, apparatus: ApparatusNotes, isShared: boolean) {
return JSON.stringify({
content: text,
notes: Object.fromEntries(LESSON_APPARATUS.map(key => [key, apparatus[key] ?? ''])),
shared: isShared,
})
}
function applyNote(record?: MemberNoteRecord | null) {
content.value = record?.content ?? ''
notes.value = { ...(record?.apparatusNotes ?? {}) }
shared.value = record?.shared ?? false
active.value = null
error.value = ''
initialSnapshot.value = serializeDraft(content.value, notes.value, shared.value)
}
watch(() => props.note, record => applyNote(record), { immediate: true })
const hasDraft = computed(() => {
if (isEditing.value) return serializeDraft(content.value, notes.value, shared.value) !== initialSnapshot.value
return !!content.value || Object.values(notes.value).some(Boolean)
})
let closeTimer: ReturnType<typeof setTimeout> | undefined let closeTimer: ReturnType<typeof setTimeout> | undefined
function toggle(apparatus: LessonApparatus) { active.value = active.value === apparatus ? null : apparatus } 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 changeShared(event: Event) { shared.value = (event as unknown as { detail: { value: boolean } }).detail.value }
@@ -63,15 +84,20 @@ function close(savedId?: string) {
uni.hideKeyboard() uni.hideKeyboard()
closing.value = true closing.value = true
closeTimer = setTimeout(() => { closeTimer = setTimeout(() => {
emit('close')
if (savedId) emit('saved', savedId) if (savedId) emit('saved', savedId)
emit('close')
}, 220) }, 220)
} }
async function requestClose() { async function requestClose() {
if (busy.value || closing.value || confirmingClose.value) return if (busy.value || closing.value || confirmingClose.value) return
if (hasDraft.value) { if (hasDraft.value) {
confirmingClose.value = true confirmingClose.value = true
const result = await uni.showModal({ title: '放弃这份未保存的评价?', content: '已填写的内容尚未保存,可以继续填写。', confirmText: '继续填写', cancelText: '放弃' }).finally(() => { confirmingClose.value = false }) const result = await uni.showModal({
title: isEditing.value ? '放弃未保存的修改?' : '放弃这份未保存的评价?',
content: isEditing.value ? '修改后的内容尚未保存,可以继续编辑。' : '已填写的内容尚未保存,可以继续填写。',
confirmText: isEditing.value ? '继续编辑' : '继续填写',
cancelText: '放弃',
}).finally(() => { confirmingClose.value = false })
if (!result.cancel) return if (!result.cancel) return
} }
close() close()
@@ -81,14 +107,21 @@ async function save() {
busy.value = true busy.value = true
error.value = '' error.value = ''
try { try {
const note = await post<{ id: string }>(`/admin/members/${props.userId}/progress/notes`, { const payload = {
content: content.value.trim(), content: content.value.trim(),
apparatusNotes: Object.fromEntries(filledApparatusNotes(notes.value).map(note => [note.apparatus, note.content])), apparatusNotes: Object.fromEntries(filledApparatusNotes(notes.value).map(note => [note.apparatus, note.content])),
shared: shared.value, shared: shared.value,
...(props.bookingId ? { bookingId: props.bookingId } : {}), ...(props.bookingId ? { bookingId: props.bookingId } : {}),
}) }
uni.showToast({ title: '课后评价已保存', icon: 'success' }) if (props.note?.id) {
close(note.id) const note = await put<{ id?: string }>(`/admin/members/${props.userId}/progress/notes/${encodeURIComponent(props.note.id)}`, payload)
uni.showToast({ title: '评价修改已保存', icon: 'success' })
close(note?.id || props.note.id)
} else {
const note = await post<{ id: string }>(`/admin/members/${props.userId}/progress/notes`, payload)
uni.showToast({ title: '课后评价已保存', icon: 'success' })
close(note.id)
}
} catch (e) { error.value = e instanceof Error ? e.message : '保存失败,请重试' } } catch (e) { error.value = e instanceof Error ? e.message : '保存失败,请重试' }
finally { busy.value = false } finally { busy.value = false }
} }

View File

@@ -16,7 +16,7 @@
<text class="muted intro">{{ admin ? '私密笔记仅馆主可见,开启共享后学员才能看到。' : '留住被看见的进步,也记下下一次练习的方向。' }}</text> <text class="muted intro">{{ admin ? '私密笔记仅馆主可见,开启共享后学员才能看到。' : '留住被看见的进步,也记下下一次练习的方向。' }}</text>
<view v-if="!archive.notes.length" class="empty">静待第一份练习寄语<text>教练分享的课后反馈会留在这里。</text></view> <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 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> <view class="heading"><text class="badge">{{ note.shared ? '教练寄语 · 学员可见' : '私密 · 仅馆主可见' }}</text><view v-if="admin" class="note-actions"><button class="text-btn edit-btn" @tap="editNote(note)">编辑</button><button class="text-btn" @tap="remove('notes', note.id)">删除</button></view></view>
<text v-if="note.content" class="body-copy">{{ note.content }}</text> <text v-if="note.content" class="body-copy">{{ note.content }}</text>
<ApparatusNoteList :notes="note.apparatusNotes" /> <ApparatusNoteList :notes="note.apparatusNotes" />
<text v-if="note.booking" class="lesson">课程 · {{ formatChinaDate(note.booking.timeSlot.date) }} {{ note.booking.timeSlot.startTime }}</text> <text v-if="note.booking" class="lesson">课程 · {{ formatChinaDate(note.booking.timeSlot.date) }} {{ note.booking.timeSlot.startTime }}</text>
@@ -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> <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>
</template> </template>
<CoachNoteEditor v-if="editor === 'notes' && admin && userId" :user-id="userId" :booking-id="bookingId" @close="editor = ''" @saved="onNoteSaved" /> <CoachNoteEditor v-if="editor === 'notes' && admin && userId" :user-id="userId" :booking-id="editingNote ? undefined : bookingId" :note="editingNote" @close="closeNoteEditor" @saved="onNoteSaved" />
<view v-if="editor === 'photos'" class="overlay" @tap="onBackdropTap"> <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> <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> <text class="field-label">记录日期</text><picker mode="date" :value="recordDate" :end="today" @change="recordDate = $event.detail.value"><view class="input">{{ recordDate }} ›</view></picker>
@@ -62,13 +62,13 @@ import { onShow } from '@dcloudio/uni-app'
import BodyAssessmentRecords from './BodyAssessmentRecords.vue' import BodyAssessmentRecords from './BodyAssessmentRecords.vue'
import CoachNoteEditor from './CoachNoteEditor.vue' import CoachNoteEditor from './CoachNoteEditor.vue'
import ApparatusNoteList from './ApparatusNoteList.vue' import ApparatusNoteList from './ApparatusNoteList.vue'
import type { ProgressArchive, ProgressPhotoRecord } from '@mp-pilates/shared' import type { MemberNoteRecord, ProgressArchive, ProgressPhotoRecord } from '@mp-pilates/shared'
import { get, post, del } from '../utils/request' import { get, post, del } from '../utils/request'
import { formatChinaDate } from '../utils/format' import { formatChinaDate } from '../utils/format'
import { adminLessonFeedbackUrl } from '../utils/lesson-feedback' import { adminLessonFeedbackUrl } from '../utils/lesson-feedback'
const props = defineProps<{ userId?: string; admin?: boolean; bookingId?: string; refreshKey?: number }>() 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 archive = ref<ProgressArchive | null>(null), loading = ref(false), error = ref(''), tab = ref('metrics'), editor = ref(''), editingNote = ref<MemberNoteRecord | null>(null), busy = ref(false), saveError = ref('')
const tabs = [{ key: 'metrics', name: '体测' }, { key: 'notes', name: '寄语' }, { key: 'photos', name: '照片' }, { key: 'milestones', name: '里程碑' }] 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 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), 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 photoUrls = ref<Record<string, string>>({}), selectedPhotos = ref<string[]>([]), comparison = ref<{ id: string; url: string; date: string }[]>([])
@@ -92,11 +92,26 @@ function openNote(id: string) {
const url = props.admin && props.userId ? adminLessonFeedbackUrl(props.userId, id) : `/pages/profile/lesson-feedback?id=${encodeURIComponent(id)}` const url = props.admin && props.userId ? adminLessonFeedbackUrl(props.userId, id) : `/pages/profile/lesson-feedback?id=${encodeURIComponent(id)}`
uni.navigateTo({ url }) uni.navigateTo({ url })
} }
function onNoteSaved(id: string) { void load(); openNote(id) } function closeNoteEditor() {
editor.value = ''
editingNote.value = null
}
function editNote(note: MemberNoteRecord) {
openedAt = Date.now()
editingNote.value = note
editor.value = 'notes'
}
function onNoteSaved(id: string) {
const wasEditing = !!editingNote.value
editingNote.value = null
void load()
if (!wasEditing) openNote(id)
}
function previewComparison(url: string) { uni.previewImage({ urls: comparison.value.map(p => p.url), current: url }) } 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 message(e: unknown) { return e instanceof Error ? e.message : '操作失败,请重试' }
function openEditor(kind: string) { function openEditor(kind: string) {
openedAt = Date.now() openedAt = Date.now()
editingNote.value = null
editor.value = kind editor.value = kind
saveError.value = '' saveError.value = ''
caption.value = '' caption.value = ''
@@ -178,7 +193,7 @@ onShow(() => { if (archive.value && !editor.value) void load() })
onBeforeUnmount(() => { sequence++; if (timer) clearTimeout(timer) }) onBeforeUnmount(() => { sequence++; if (timer) clearTimeout(timer) })
</script> </script>
<style lang="scss" scoped> <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;} .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;}.note-actions{display:flex;align-items:center;gap:12rpx;}.edit-btn{color:#516c5d;}
.archive{padding:28rpx 32rpx 60rpx;color:#514943;--sage:#617d70;--muted:#827b70;} .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;} 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> </style>

View File

@@ -19,7 +19,7 @@
<view class="lesson-date"><text>{{ feedback.note.booking ? formatChinaDate(feedback.note.booking.timeSlot.date) : formatChinaDate(feedback.note.createdAt) }}</text><text>{{ feedback.note.booking ? feedback.note.booking.timeSlot.startTime + ' · 本次课程' : '教练笔记' }}</text></view> <view class="lesson-date"><text>{{ feedback.note.booking ? formatChinaDate(feedback.note.booking.timeSlot.date) : formatChinaDate(feedback.note.createdAt) }}</text><text>{{ feedback.note.booking ? feedback.note.booking.timeSlot.startTime + ' · 本次课程' : '教练笔记' }}</text></view>
</view> </view>
<view class="content-card"> <view class="content-card">
<view class="content-heading"><text>教练评价</text><text class="subtle">{{ apparatusCount ? apparatusCount + ' 项器械记录' : '整体练习反馈' }}</text></view> <view class="content-heading"><text>教练评价</text><view class="heading-meta"><text class="subtle">{{ apparatusCount ? apparatusCount + ' 项器械记录' : '整体练习反馈' }}</text><button v-if="adminMode" class="edit-btn" @tap="editing = true">编辑</button></view></view>
<template v-if="feedback.note.content"><text v-if="apparatusCount" class="summary-label">整体评价</text><text class="body-copy">{{ feedback.note.content }}</text></template> <template v-if="feedback.note.content"><text v-if="apparatusCount" class="summary-label">整体评价</text><text class="body-copy">{{ feedback.note.content }}</text></template>
<ApparatusNoteList :notes="feedback.note.apparatusNotes" /> <ApparatusNoteList :notes="feedback.note.apparatusNotes" />
<text class="record-time">记录于 {{ formatChinaDate(feedback.note.createdAt) }}</text> <text class="record-time">记录于 {{ formatChinaDate(feedback.note.createdAt) }}</text>
@@ -36,6 +36,7 @@
<text class="dock-hint">当前为私密记录,分享前需开启该学员查看</text> <text class="dock-hint">当前为私密记录,分享前需开启该学员查看</text>
</template> </template>
</view> </view>
<CoachNoteEditor v-if="editing && adminMode && userId && feedback" :user-id="userId" :note="feedback.note" @close="editing = false" @saved="onNoteSaved" />
</template> </template>
</view> </view>
</template> </template>
@@ -46,6 +47,7 @@ import { filledApparatusNotes } from '@mp-pilates/shared'
import type { LessonFeedbackDetail } from '@mp-pilates/shared' import type { LessonFeedbackDetail } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue' import CustomNavBar from '../../components/CustomNavBar.vue'
import ApparatusNoteList from '../../components/ApparatusNoteList.vue' import ApparatusNoteList from '../../components/ApparatusNoteList.vue'
import CoachNoteEditor from '../../components/CoachNoteEditor.vue'
import { useUserStore } from '../../stores/user' import { useUserStore } from '../../stores/user'
import { get, put, HttpRequestError } from '../../utils/request' import { get, put, HttpRequestError } from '../../utils/request'
import { formatChinaDate } from '../../utils/format' import { formatChinaDate } from '../../utils/format'
@@ -56,7 +58,7 @@ const navBarHeight = getSystemLayout().navBarHeight
const userStore = useUserStore() const userStore = useUserStore()
const noteId = ref(''), userId = ref(''), feedback = ref<LessonFeedbackDetail | null>(null) const noteId = ref(''), userId = ref(''), feedback = ref<LessonFeedbackDetail | null>(null)
const state = ref<'loading' | 'login' | 'denied' | 'error' | 'ready'>('loading') const state = ref<'loading' | 'login' | 'denied' | 'error' | 'ready'>('loading')
const error = ref(''), shareError = ref(''), loggingIn = ref(false), sharing = ref(false) const error = ref(''), shareError = ref(''), loggingIn = ref(false), sharing = ref(false), editing = ref(false)
const adminMode = computed(() => !!userId.value) const adminMode = computed(() => !!userId.value)
const apparatusCount = computed(() => filledApparatusNotes(feedback.value?.note.apparatusNotes).length) const apparatusCount = computed(() => filledApparatusNotes(feedback.value?.note.apparatusNotes).length)
const endpoint = computed(() => adminMode.value ? `/admin/members/${encodeURIComponent(userId.value)}/progress/notes/${encodeURIComponent(noteId.value)}` : `/user/progress/notes/${encodeURIComponent(noteId.value)}`) const endpoint = computed(() => adminMode.value ? `/admin/members/${encodeURIComponent(userId.value)}/progress/notes/${encodeURIComponent(noteId.value)}` : `/user/progress/notes/${encodeURIComponent(noteId.value)}`)
@@ -105,17 +107,21 @@ async function setSharing(shared: boolean) {
} catch (e) { if (seq === sequence) shareError.value = e instanceof Error ? e.message : '操作失败,请重试' } } catch (e) { if (seq === sequence) shareError.value = e instanceof Error ? e.message : '操作失败,请重试' }
finally { sharing.value = false } finally { sharing.value = false }
} }
function onNoteSaved() {
editing.value = false
void load()
}
function goHome() { uni.switchTab({ url: '/pages/home/index' }) } function goHome() { uni.switchTab({ url: '/pages/home/index' }) }
onShareAppMessage(() => lessonFeedbackShare(adminMode.value && state.value === 'ready' && feedback.value?.note.shared ? noteId.value : undefined)) onShareAppMessage(() => lessonFeedbackShare(adminMode.value && state.value === 'ready' && feedback.value?.note.shared ? noteId.value : undefined))
onLoad(query => { noteId.value = String(query?.id || ''); userId.value = String(query?.userId || ''); uni.hideShareMenu({ hideShareItems: ['shareAppMessage', 'shareTimeline'], menus: ['shareAppMessage', 'shareTimeline'] }) }) onLoad(query => { noteId.value = String(query?.id || ''); userId.value = String(query?.userId || ''); uni.hideShareMenu({ hideShareItems: ['shareAppMessage', 'shareTimeline'], menus: ['shareAppMessage', 'shareTimeline'] }) })
onShow(() => { visible = true; void load() }) onShow(() => { visible = true; void load() })
onHide(() => { visible = false; sequence++; feedback.value = null; state.value = 'loading' }) onHide(() => { visible = false; editing.value = false; sequence++; feedback.value = null; state.value = 'loading' })
onUnload(() => { visible = false; sequence++; feedback.value = null }) onUnload(() => { visible = false; editing.value = false; sequence++; feedback.value = null })
watch(() => userStore.token, () => { watch(() => userStore.token, () => {
sequence++; feedback.value = null; state.value = userStore.loggedIn ? 'loading' : 'login'; syncShareMenu() sequence++; feedback.value = null; state.value = userStore.loggedIn ? 'loading' : 'login'; syncShareMenu()
if (visible && !loggingIn.value && userStore.loggedIn) void load() if (visible && !loggingIn.value && userStore.loggedIn) void load()
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.feedback-page{min-height:100vh;box-sizing:border-box;padding-bottom:calc(230rpx + env(safe-area-inset-bottom));background:#fbf9f5;color:#514943;}button{font-weight:400;}button::after{border:0;}.cover{margin:30rpx 32rpx 24rpx;padding:36rpx 30rpx 30rpx;background:#edf1e6;border:1rpx solid #dfe5d6;border-radius:28rpx;animation:rise .35s ease both;}.eyebrow{font-size:19rpx;letter-spacing:3rpx;color:#7d8b72;}.headline{display:block;font-family:'Songti SC','STSong',serif;font-size:37rpx;line-height:1.6;margin:22rpx 0 28rpx;}.member{display:block;font-size:27rpx;line-height:1.8;overflow-wrap:anywhere;}.lesson-date{display:flex;justify-content:space-between;gap:18rpx;border-top:1rpx solid #dbe2d1;padding-top:22rpx;margin-top:22rpx;color:#859078;font-size:22rpx;}.content-card{margin:0 32rpx;padding:30rpx;background:#fffdf9;border:1rpx solid #e8e1d7;border-radius:24rpx;animation:rise .4s .06s ease both;}.content-heading{display:flex;align-items:center;justify-content:space-between;gap:16rpx;font-size:29rpx;margin-bottom:24rpx;}.subtle{font-size:21rpx;color:#9a9082;}.summary-label{font-size:23rpx;color:#8a8275;}.body-copy{display:block;font-size:28rpx;line-height:1.95;white-space:pre-wrap;overflow-wrap:anywhere;margin:14rpx 0 26rpx;}.record-time{display:block;color:#aaa092;font-size:21rpx;margin-top:30rpx;}.privacy-note{display:block;text-align:center;color:#a29888;font-size:21rpx;line-height:1.8;margin:30rpx 32rpx;}.share-dock{position:fixed;bottom:0;left:0;right:0;padding:22rpx 32rpx calc(18rpx + env(safe-area-inset-bottom));border-top:1rpx solid #e5e0d6;background:#fbf9f5;}.primary,.secondary{width:100%;margin:0;border-radius:20rpx;line-height:88rpx;font-size:28rpx;background:#617d70;color:#fff;transition:transform .16s,opacity .2s;}.primary:active{transform:scale(.985);}.primary[disabled]{opacity:.55;}.secondary{background:#eaece3;color:#60745a;}.share-button text{margin-left:18rpx;}.dock-meta{display:flex;align-items:center;justify-content:space-between;gap:12rpx;font-size:20rpx;color:#a09584;margin-top:8rpx;}.text-button{padding:8rpx 0;margin:0;background:transparent;color:#8b816f;font-size:21rpx;line-height:1.8;}.dock-hint{display:block;text-align:center;font-size:21rpx;color:#9a9080;margin-top:18rpx;}.state{margin:36rpx 32rpx;padding:40rpx 20rpx;color:#8d8271;font-size:25rpx;text-align:center;line-height:1.9;}.state-card{margin-top:90rpx;padding:50rpx 32rpx;background:#f1f0e8;border-radius:28rpx;}.state-symbol{display:block;font-family:'Baskerville',serif;color:#9ba68c;font-size:70rpx;margin-bottom:26rpx;}.state-title{display:block;font-family:'Songti SC','STSong',serif;font-size:34rpx;color:#635b4f;}.state-copy{display:block;margin:20rpx 0 36rpx;font-size:25rpx;line-height:1.9;}.state .text-button{margin:28rpx auto 0;}.error{display:block;color:#a35645;font-size:23rpx;line-height:1.8;margin-bottom:14rpx;}.skeleton{height:220rpx;background:#ecece3;border-radius:24rpx;margin-bottom:24rpx;animation:breathe 1.2s infinite alternate;}.skeleton.tall{height:330rpx;}@keyframes rise{from{opacity:0;transform:translateY(16rpx)}to{opacity:1;transform:translateY(0)}}@keyframes breathe{to{opacity:.45}}@media(prefers-reduced-motion:reduce){.cover,.content-card,.skeleton{animation:none;}.primary{transition:none;}} .feedback-page{min-height:100vh;box-sizing:border-box;padding-bottom:calc(230rpx + env(safe-area-inset-bottom));background:#fbf9f5;color:#514943;}button{font-weight:400;}button::after{border:0;}.cover{margin:30rpx 32rpx 24rpx;padding:36rpx 30rpx 30rpx;background:#edf1e6;border:1rpx solid #dfe5d6;border-radius:28rpx;animation:rise .35s ease both;}.eyebrow{font-size:19rpx;letter-spacing:3rpx;color:#7d8b72;}.headline{display:block;font-family:'Songti SC','STSong',serif;font-size:37rpx;line-height:1.6;margin:22rpx 0 28rpx;}.member{display:block;font-size:27rpx;line-height:1.8;overflow-wrap:anywhere;}.lesson-date{display:flex;justify-content:space-between;gap:18rpx;border-top:1rpx solid #dbe2d1;padding-top:22rpx;margin-top:22rpx;color:#859078;font-size:22rpx;}.content-card{margin:0 32rpx;padding:30rpx;background:#fffdf9;border:1rpx solid #e8e1d7;border-radius:24rpx;animation:rise .4s .06s ease both;}.content-heading{display:flex;align-items:center;justify-content:space-between;gap:16rpx;font-size:29rpx;margin-bottom:24rpx;}.heading-meta{display:flex;align-items:center;gap:18rpx;}.edit-btn{margin:0;padding:8rpx 18rpx;background:#e9eee5;color:#516c5d;border-radius:12rpx;font-size:22rpx;line-height:1.5;}.subtle{font-size:21rpx;color:#9a9082;}.summary-label{font-size:23rpx;color:#8a8275;}.body-copy{display:block;font-size:28rpx;line-height:1.95;white-space:pre-wrap;overflow-wrap:anywhere;margin:14rpx 0 26rpx;}.record-time{display:block;color:#aaa092;font-size:21rpx;margin-top:30rpx;}.privacy-note{display:block;text-align:center;color:#a29888;font-size:21rpx;line-height:1.8;margin:30rpx 32rpx;}.share-dock{position:fixed;bottom:0;left:0;right:0;padding:22rpx 32rpx calc(18rpx + env(safe-area-inset-bottom));border-top:1rpx solid #e5e0d6;background:#fbf9f5;}.primary,.secondary{width:100%;margin:0;border-radius:20rpx;line-height:88rpx;font-size:28rpx;background:#617d70;color:#fff;transition:transform .16s,opacity .2s;}.primary:active{transform:scale(.985);}.primary[disabled]{opacity:.55;}.secondary{background:#eaece3;color:#60745a;}.share-button text{margin-left:18rpx;}.dock-meta{display:flex;align-items:center;justify-content:space-between;gap:12rpx;font-size:20rpx;color:#a09584;margin-top:8rpx;}.text-button{padding:8rpx 0;margin:0;background:transparent;color:#8b816f;font-size:21rpx;line-height:1.8;}.dock-hint{display:block;text-align:center;font-size:21rpx;color:#9a9080;margin-top:18rpx;}.state{margin:36rpx 32rpx;padding:40rpx 20rpx;color:#8d8271;font-size:25rpx;text-align:center;line-height:1.9;}.state-card{margin-top:90rpx;padding:50rpx 32rpx;background:#f1f0e8;border-radius:28rpx;}.state-symbol{display:block;font-family:'Baskerville',serif;color:#9ba68c;font-size:70rpx;margin-bottom:26rpx;}.state-title{display:block;font-family:'Songti SC','STSong',serif;font-size:34rpx;color:#635b4f;}.state-copy{display:block;margin:20rpx 0 36rpx;font-size:25rpx;line-height:1.9;}.state .text-button{margin:28rpx auto 0;}.error{display:block;color:#a35645;font-size:23rpx;line-height:1.8;margin-bottom:14rpx;}.skeleton{height:220rpx;background:#ecece3;border-radius:24rpx;margin-bottom:24rpx;animation:breathe 1.2s infinite alternate;}.skeleton.tall{height:330rpx;}@keyframes rise{from{opacity:0;transform:translateY(16rpx)}to{opacity:1;transform:translateY(0)}}@keyframes breathe{to{opacity:.45}}@media(prefers-reduced-motion:reduce){.cover,.content-card,.skeleton{animation:none;}.primary{transition:none;}}
</style> </style>

View File

@@ -40,7 +40,9 @@ describe('Owner-only shared lesson feedback HTTP access', () => {
db.memberNote.findFirst.mockImplementation(async ({ where }) => record && where.id === record.id && where.userId === 'owner' && (where.shared === undefined || where.shared === record.shared) ? record : null) db.memberNote.findFirst.mockImplementation(async ({ where }) => record && where.id === record.id && where.userId === 'owner' && (where.shared === undefined || where.shared === record.shared) ? record : null)
db.memberNote.updateMany.mockImplementation(async ({ where, data }) => { db.memberNote.updateMany.mockImplementation(async ({ where, data }) => {
if (!record || where.userId !== 'owner' || where.id !== record.id) return { count: 0 } if (!record || where.userId !== 'owner' || where.id !== record.id) return { count: 0 }
record.shared = data.shared if (data.content !== undefined) record.content = data.content
if (data.apparatusNotes !== undefined) record.apparatusNotes = data.apparatusNotes
if (data.shared !== undefined) record.shared = data.shared
return { count: 1 } return { count: 1 }
}) })
}) })
@@ -102,6 +104,23 @@ describe('Owner-only shared lesson feedback HTTP access', () => {
expect((await request('PUT', '/admin/members/other/progress/notes/note/sharing', 'teacher', { shared: false }, 'ADMIN')).status).toBe(404) expect((await request('PUT', '/admin/members/other/progress/notes/note/sharing', 'teacher', { shared: false }, 'ADMIN')).status).toBe(404)
expect(record!.shared).toBe(true) expect(record!.shared).toBe(true)
}) })
it('allows an admin to edit an existing note and blocks member or cross-member edits', async () => {
expect((await request('PUT', '/admin/members/owner/progress/notes/note', 'owner', { content: '篡改', shared: true })).status).toBe(403)
expect((await request('PUT', '/admin/members/other/progress/notes/note', 'teacher', { content: '跨学员修改', shared: true }, 'ADMIN')).status).toBe(404)
const editRes = await request('PUT', '/admin/members/owner/progress/notes/note', 'teacher', {
content: '更新后的课后总结',
apparatusNotes: { R: '核心控制提升' },
shared: true,
}, 'ADMIN')
expect(editRes.status).toBe(200)
const readRes = await request('GET', '/user/progress/notes/note', 'owner')
expect(readRes.status).toBe(200)
expect((await readRes.json()).data.note).toMatchObject({
content: '更新后的课后总结',
apparatusNotes: { R: '核心控制提升' },
shared: true,
})
})
it('shares a student-only URL with a generic title and static cover instead of personal content', () => { it('shares a student-only URL with a generic title and static cover instead of personal content', () => {
expect(lessonFeedbackShare('note')).toEqual({ title: '教练为你记录了一份课后评价', path: '/pages/profile/lesson-feedback?id=note', imageUrl: '/static/share/lesson-feedback.png' }) expect(lessonFeedbackShare('note')).toEqual({ title: '教练为你记录了一份课后评价', path: '/pages/profile/lesson-feedback?id=note', imageUrl: '/static/share/lesson-feedback.png' })
expect(lessonFeedbackShare('note&userId=owner').path).toBe('/pages/profile/lesson-feedback?id=note%26userId%3Downer') expect(lessonFeedbackShare('note&userId=owner').path).toBe('/pages/profile/lesson-feedback?id=note%26userId%3Downer')

View File

@@ -10,7 +10,7 @@ describe('Coach lesson feedback', () => {
const db = { const db = {
user: { findUnique: jest.fn() }, user: { findUnique: jest.fn() },
booking: { findFirst: jest.fn() }, booking: { findFirst: jest.fn() },
memberNote: { create: jest.fn() }, memberNote: { create: jest.fn(), updateMany: jest.fn(), findFirst: jest.fn() },
} }
let service: MemberProgressService let service: MemberProgressService
beforeEach(() => { beforeEach(() => {
@@ -69,4 +69,39 @@ describe('Coach lesson feedback', () => {
await expect(service.addNote('member', 'coach', { shared: true, bookingId: 'foreign', apparatusNotes: { M: '完成训练' } })).rejects.toThrow('只能批注该学员已完成的课程') await expect(service.addNote('member', 'coach', { shared: true, bookingId: 'foreign', apparatusNotes: { M: '完成训练' } })).rejects.toThrow('只能批注该学员已完成的课程')
expect(db.memberNote.create).not.toHaveBeenCalled() expect(db.memberNote.create).not.toHaveBeenCalled()
}) })
it('updates an existing note in place while preserving linked booking and removing cleared apparatus fields', async () => {
db.memberNote.updateMany.mockResolvedValue({ count: 1 })
db.memberNote.findFirst.mockResolvedValue({
id: 'note-1',
content: '更新后的整体总结',
apparatusNotes: { R: '核心更稳' },
shared: true,
bookingId: 'lesson',
})
const updated = await service.updateNote('member', 'note-1', 'coach-2', {
content: ' 更新后的整体总结 ',
shared: true,
apparatusNotes: { M: ' ', R: ' 核心更稳 ' },
})
expect(db.memberNote.updateMany).toHaveBeenCalledWith({
where: { id: 'note-1', userId: 'member' },
data: {
content: '更新后的整体总结',
apparatusNotes: { R: '核心更稳' },
shared: true,
operatorId: 'coach-2',
},
})
expect(updated).toMatchObject({ id: 'note-1', content: '更新后的整体总结', apparatusNotes: { R: '核心更稳' }, shared: true })
})
it('rejects empty or foreign note updates before modifying records', async () => {
await expect(service.updateNote('member', 'note-1', 'coach', { content: ' ', shared: false, apparatusNotes: { M: ' ' } })).rejects.toThrow('请填写整体评价或至少一项器械记录')
expect(db.memberNote.updateMany).not.toHaveBeenCalled()
db.memberNote.updateMany.mockResolvedValue({ count: 0 })
await expect(service.updateNote('other', 'note-1', 'coach', { content: '有效内容', shared: false })).rejects.toThrow('评价不存在或不属于该学员')
expect(db.memberNote.updateMany).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'note-1', userId: 'other' } }))
})
}) })

View File

@@ -198,10 +198,14 @@ describe('Member progress authorization', () => {
}) })
it('restricts record editing to admins and exposes no member write route', () => { it('restricts record editing to admins and exposes no member write route', () => {
const handler = AdminMemberProgressController.prototype.updateMetric const handler = AdminMemberProgressController.prototype.updateMetric
const noteHandler = AdminMemberProgressController.prototype.updateNote
const guard = new RolesGuard(new Reflector()) const guard = new RolesGuard(new Reflector())
const context = (role: string) => ({ getHandler: () => handler, getClass: () => AdminMemberProgressController, switchToHttp: () => ({ getRequest: () => ({ user: { role } }) }) }) as unknown as ExecutionContext const context = (role: string, target: unknown = handler) => ({ getHandler: () => target, getClass: () => AdminMemberProgressController, switchToHttp: () => ({ getRequest: () => ({ user: { role } }) }) }) as unknown as ExecutionContext
expect(guard.canActivate(context('MEMBER'))).toBe(false) expect(guard.canActivate(context('MEMBER'))).toBe(false)
expect(guard.canActivate(context('ADMIN'))).toBe(true) expect(guard.canActivate(context('ADMIN'))).toBe(true)
expect(guard.canActivate(context('MEMBER', noteHandler))).toBe(false)
expect(guard.canActivate(context('ADMIN', noteHandler))).toBe(true)
expect('updateMetric' in MemberProgressController.prototype).toBe(false) expect('updateMetric' in MemberProgressController.prototype).toBe(false)
expect('updateNote' in MemberProgressController.prototype).toBe(false)
}) })
}) })

View File

@@ -25,6 +25,7 @@ export class AdminMemberProgressController {
@Put('metrics/:id') updateMetric(@Param('userId') userId: string, @Param('id') id: string, @CurrentUser('sub') operatorId: string, @Body() dto: BodyMetricDto) { return this.service.updateMetric(userId, id, operatorId, dto) } @Put('metrics/:id') updateMetric(@Param('userId') userId: string, @Param('id') id: string, @CurrentUser('sub') operatorId: string, @Body() dto: BodyMetricDto) { return this.service.updateMetric(userId, id, operatorId, dto) }
@Post('notes') note(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: MemberNoteDto) { return this.service.addNote(userId, operatorId, dto) } @Post('notes') note(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: MemberNoteDto) { return this.service.addNote(userId, operatorId, dto) }
@Get('notes/:id') @Header('Cache-Control', 'private, no-store') noteDetail(@Param('userId') userId: string, @Param('id') id: string) { return this.service.note(userId, id, true) } @Get('notes/:id') @Header('Cache-Control', 'private, no-store') noteDetail(@Param('userId') userId: string, @Param('id') id: string) { return this.service.note(userId, id, true) }
@Put('notes/:id') updateNote(@Param('userId') userId: string, @Param('id') id: string, @CurrentUser('sub') operatorId: string, @Body() dto: MemberNoteDto) { return this.service.updateNote(userId, id, operatorId, dto) }
@Put('notes/:id/sharing') shareNote(@Param('userId') userId: string, @Param('id') id: string, @Body() dto: NoteSharingDto) { return this.service.setNoteSharing(userId, id, dto.shared) } @Put('notes/:id/sharing') shareNote(@Param('userId') userId: string, @Param('id') id: string, @Body() dto: NoteSharingDto) { return this.service.setNoteSharing(userId, id, dto.shared) }
@Delete(':kind/:id') remove(@Param('userId') userId: string, @Param('kind') kind: string, @Param('id') id: string) { return this.service.remove(userId, kind, id) } @Delete(':kind/:id') remove(@Param('userId') userId: string, @Param('kind') kind: string, @Param('id') id: string) { return this.service.remove(userId, kind, id) }
@Post('photos/upload') upload(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: ProgressPhotoDto) { return this.service.upload(userId, operatorId, dto) } @Post('photos/upload') upload(@Param('userId') userId: string, @CurrentUser('sub') operatorId: string, @Body() dto: ProgressPhotoDto) { return this.service.upload(userId, operatorId, dto) }

View File

@@ -54,12 +54,28 @@ export class MemberProgressService {
} }
async addNote(userId: string, operatorId: string, dto: MemberNoteDto) { async addNote(userId: string, operatorId: string, dto: MemberNoteDto) {
await this.member(userId) await this.member(userId)
const data = await this.noteData(userId, dto)
return this.prisma.memberNote.create({ data: { ...data, userId, operatorId, bookingId: dto.bookingId || null } })
}
async updateNote(userId: string, id: string, operatorId: string, dto: MemberNoteDto) {
const data = await this.noteData(userId, dto)
const result = await this.prisma.memberNote.updateMany({
where: { id, userId },
data: { ...data, operatorId, ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId || null } : {}) },
})
if (!result.count) throw new NotFoundException('评价不存在或不属于该学员')
return (await this.prisma.memberNote.findFirst?.({
where: { id, userId },
include: { booking: { select: { timeSlot: { select: { date: true, startTime: true } } } } },
})) ?? { id, userId, operatorId, ...data }
}
private async noteData(userId: string, dto: MemberNoteDto) {
if (dto.apparatusNotes !== undefined && !isApparatusNotes(dto.apparatusNotes)) throw new BadRequestException('器械记录内容无效') if (dto.apparatusNotes !== undefined && !isApparatusNotes(dto.apparatusNotes)) throw new BadRequestException('器械记录内容无效')
const content = dto.content?.trim() || '' const content = dto.content?.trim() || ''
const apparatusNotes = Object.fromEntries(filledApparatusNotes(dto.apparatusNotes).map(note => [note.apparatus, note.content])) const apparatusNotes = Object.fromEntries(filledApparatusNotes(dto.apparatusNotes).map(note => [note.apparatus, note.content]))
if (!content && !Object.keys(apparatusNotes).length) throw new BadRequestException('请填写整体评价或至少一项器械记录') if (!content && !Object.keys(apparatusNotes).length) throw new BadRequestException('请填写整体评价或至少一项器械记录')
if (dto.bookingId && !await this.prisma.booking.findFirst({ where: { id: dto.bookingId, userId, status: 'COMPLETED' } })) throw new BadRequestException('只能批注该学员已完成的课程') if (dto.bookingId && !await this.prisma.booking.findFirst({ where: { id: dto.bookingId, userId, status: 'COMPLETED' } })) throw new BadRequestException('只能批注该学员已完成的课程')
return this.prisma.memberNote.create({ data: { userId, operatorId, content, apparatusNotes, shared: dto.shared, bookingId: dto.bookingId || null } }) return { content, apparatusNotes, shared: dto.shared }
} }
async note(userId: string, id: string, admin = false) { async note(userId: string, id: string, admin = false) {
const record = await this.prisma.memberNote.findFirst({ const record = await this.prisma.memberNote.findFirst({