feat: share lesson feedback with owner-only access

This commit is contained in:
richarjiang
2026-10-18 16:09:39 +08:00
parent f085852943
commit b2a785b27b
12 changed files with 310 additions and 11 deletions

View File

@@ -22,3 +22,17 @@
5. 默认私密记录仅馆主可见,开启共享后学员端按器械分段展示;未填写的器械不显示,历史整体评价仍显示。 5. 默认私密记录仅馆主可见,开启共享后学员端按器械分段展示;未填写的器械不显示,历史整体评价仍显示。
本地已验证共享包构建、前端类型检查、小程序构建、后端构建,以及完整 Jest 测试。本地浏览器组件预览用于验证展开、切换、填写计数、仅器械保存及分段展示;不替代微信真机验收。 本地已验证共享包构建、前端类型检查、小程序构建、后端构建,以及完整 Jest 测试。本地浏览器组件预览用于验证展开、切换、填写计数、仅器械保存及分段展示;不替代微信真机验收。
## 单条评价分享
保存后自动进入评价详情页;档案内每条评价底部的「查看与分享」也可进入。教练页底固定「分享给学员」主按钮,避免长评价滚动后找不到操作。私密记录首次点击会确认开启该学员查看,再次点击调起微信转发;可随时关闭学员查看。
- 分享仅携带评价 ID,跳转 `/pages/profile/lesson-feedback?id=...`,不带管理员预览参数 `userId`。
- 分享标题与封面固定,不包含学员姓名、评价正文或页面截图。
- 学员详情接口 `GET /user/progress/notes/:id` 强制登录,并在同一次数据库查询中限定 JWT 学员 ID、评价 ID 与 `shared=true`。管理员打开学生分享链接同样没有跨学员读取权限。
- 教练预览和共享设置分别使用现有 ADMIN 权限控制下的 `GET /admin/members/:userId/progress/notes/:id` 与 `PUT .../notes/:id/sharing`;二者均限定目标学员与评价归属。共享修改仅接受布尔 `shared`。
- 详情响应设置 `Cache-Control: private, no-store`,仅返回展示所需内容。无权限、已删除、未共享统一返回 404,不泄露评价是否存在。
- 未登录先展示登录引导,登录后留在同一条评价;不显示任何姓名或正文预览。退出登录或页面进入后台时清除详情,返回页面重新请求和验证权限。
- 关闭共享后旧链接不再能从接口读取;已经阅读、截图或自行保存的内容不能远程撤回。
本次分享功能不新增表、字段或环境变量,沿用已部署的器械评价字段。

View File

@@ -32,7 +32,7 @@
<text v-if="active !== apparatus && notes[apparatus]?.trim()" class="preview">{{ notes[apparatus]?.trim() }}</text> <text v-if="active !== apparatus && notes[apparatus]?.trim()" class="preview">{{ notes[apparatus]?.trim() }}</text>
</view> </view>
</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> </view>
</scroll-view> </scroll-view>
<view class="sheet-footer"> <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 type { ApparatusNotes, LessonApparatus } from '@mp-pilates/shared'
import { post } from '../utils/request' import { post } from '../utils/request'
const props = defineProps<{ userId: string; bookingId?: string }>() 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 content = ref(''), notes = ref<ApparatusNotes>({}), active = ref<LessonApparatus | null>(null), shared = ref(false)
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('')
@@ -59,10 +59,13 @@ const hasDraft = computed(() => !!content.value || Object.values(notes.value).so
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 }
function close() { function close(savedId?: string) {
uni.hideKeyboard() uni.hideKeyboard()
closing.value = true closing.value = true
closeTimer = setTimeout(() => emit('close'), 220) closeTimer = setTimeout(() => {
emit('close')
if (savedId) emit('saved', savedId)
}, 220)
} }
async function requestClose() { async function requestClose() {
if (busy.value || closing.value || confirmingClose.value) return if (busy.value || closing.value || confirmingClose.value) return
@@ -78,15 +81,14 @@ async function save() {
busy.value = true busy.value = true
error.value = '' error.value = ''
try { 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(), 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 } : {}),
}) })
emit('saved')
uni.showToast({ title: '课后评价已保存', icon: 'success' }) uni.showToast({ title: '课后评价已保存', icon: 'success' })
close() 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

@@ -20,7 +20,7 @@
<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>
<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> </view>
</template> </template>
<template v-if="tab === 'photos'"> <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> <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="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"> <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>
@@ -65,6 +65,7 @@ import ApparatusNoteList from './ApparatusNoteList.vue'
import type { ProgressArchive, ProgressPhotoRecord } from '@mp-pilates/shared' import type { 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'
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(''), 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: '里程碑' }]
@@ -87,6 +88,11 @@ async function load() {
catch (e) { if (seq === sequence) error.value = message(e) } catch (e) { if (seq === sequence) error.value = message(e) }
finally { if (seq === sequence && isInitial) loading.value = false } 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 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) {
@@ -172,6 +178,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;}
.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

@@ -81,6 +81,12 @@
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{
"path": "pages/profile/lesson-feedback",
"style": {
"navigationStyle": "custom"
}
},
{ {
"path": "pages/profile/progress", "path": "pages/profile/progress",
"style": { "style": {

View File

@@ -0,0 +1,121 @@
<template>
<view class="feedback-page" :style="{ paddingTop: navBarHeight + 'px' }">
<CustomNavBar title="课后评价" show-back />
<view v-if="state === 'loading'" class="state"><view class="skeleton" /><view class="skeleton tall" /><text>正在翻开这次练习的记录…</text></view>
<view v-else-if="state !== 'ready' || !feedback" class="state state-card">
<text class="state-symbol">{{ state === 'login' ? '○' : '—' }}</text>
<text class="state-title">{{ state === 'login' ? '登录后,查看你的课后评价' : state === 'denied' ? '这份评价暂时无法查看' : '暂时未能读取评价' }}</text>
<text class="state-copy">{{ state === 'login' ? '仅限这份评价对应的学员本人查看。' : state === 'denied' ? '请确认使用的是本人微信账号,且教练已开启学员查看。' : error }}</text>
<button v-if="state === 'login'" class="primary" :loading="loggingIn" :disabled="loggingIn" @tap="login">微信登录查看</button>
<button v-else class="secondary" @tap="load">重新检查</button>
<text v-if="state === 'login' && error" class="error">{{ error }}</text>
<button class="text-button" @tap="goHome">返回首页</button>
</view>
<template v-else>
<view class="cover">
<text class="eyebrow">AFTER CLASS · 练习手记</text>
<text class="headline">每一份进步,都被看见。</text>
<text class="member">{{ feedback.nickname }}的课后评价</text>
<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 class="content-card">
<view class="content-heading"><text>教练评价</text><text class="subtle">{{ apparatusCount ? apparatusCount + ' 项器械记录' : '整体练习反馈' }}</text></view>
<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" />
<text class="record-time">记录于 {{ formatChinaDate(feedback.note.createdAt) }}</text>
</view>
<text class="privacy-note">仅限评价对应的学员本人登录查看</text>
<view v-if="adminMode" class="share-dock">
<text v-if="shareError" class="error">{{ shareError }}</text>
<template v-if="feedback.note.shared">
<button class="primary share-button" open-type="share" :disabled="sharing">分享给学员 <text>↗</text></button>
<view class="dock-meta"><text>转发链接后,仍会验证学员身份</text><button class="text-button" :disabled="sharing" @tap="setSharing(false)">关闭学员查看</button></view>
</template>
<template v-else>
<button class="primary" :loading="sharing" :disabled="sharing" @tap="setSharing(true)">分享给学员 <text>↗</text></button>
<text class="dock-hint">当前为私密记录,分享前需开启该学员查看</text>
</template>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { onHide, onLoad, onShareAppMessage, onShow, onUnload } from '@dcloudio/uni-app'
import { filledApparatusNotes } from '@mp-pilates/shared'
import type { LessonFeedbackDetail } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import ApparatusNoteList from '../../components/ApparatusNoteList.vue'
import { useUserStore } from '../../stores/user'
import { get, put, HttpRequestError } from '../../utils/request'
import { formatChinaDate } from '../../utils/format'
import { getSystemLayout } from '../../utils/system'
import { lessonFeedbackShare } from '../../utils/lesson-feedback'
const navBarHeight = getSystemLayout().navBarHeight
const userStore = useUserStore()
const noteId = ref(''), userId = ref(''), feedback = ref<LessonFeedbackDetail | null>(null)
const state = ref<'loading' | 'login' | 'denied' | 'error' | 'ready'>('loading')
const error = ref(''), shareError = ref(''), loggingIn = ref(false), sharing = ref(false)
const adminMode = computed(() => !!userId.value)
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)}`)
let sequence = 0, visible = false
function syncShareMenu() {
// Only the authorized teacher preview offers sharing. The student link always
// uses the owner-only endpoint, even when an administrator opens that link.
if (adminMode.value && feedback.value?.note.shared && state.value === 'ready') uni.showShareMenu({ menus: ['shareAppMessage'] })
else uni.hideShareMenu({ hideShareItems: ['shareAppMessage', 'shareTimeline'], menus: ['shareAppMessage', 'shareTimeline'] })
}
async function load() {
const seq = ++sequence, token = userStore.token
feedback.value = null; error.value = ''; shareError.value = ''; state.value = 'loading'; syncShareMenu()
if (!noteId.value) { state.value = 'denied'; return }
if (!token) { state.value = 'login'; return }
try {
const result = await get<LessonFeedbackDetail>(endpoint.value)
if (seq !== sequence || token !== userStore.token || !visible) return
feedback.value = result; state.value = 'ready'
} catch (e) {
if (seq !== sequence || !visible) return
if (!userStore.loggedIn) state.value = 'login'
else if (e instanceof HttpRequestError && [403, 404].includes(e.statusCode)) state.value = 'denied'
else { state.value = 'error'; error.value = e instanceof Error ? e.message : '请检查网络后重试' }
} finally { if (seq === sequence) syncShareMenu() }
}
async function login() {
if (loggingIn.value) return
loggingIn.value = true; error.value = ''
try { await userStore.login(); if (visible) await load() }
catch (e) { error.value = e instanceof Error ? e.message : '登录失败,请重试' }
finally { loggingIn.value = false }
}
async function setSharing(shared: boolean) {
if (sharing.value || !feedback.value || !adminMode.value) return
const seq = sequence, token = userStore.token
const result = await uni.showModal({ title: shared ? '允许该学员查看这份评价?' : '关闭学员查看?', content: shared ? '仅该学员本人可通过链接和成长档案查看,其他账号无法读取。' : '关闭后,已分享的链接和学员档案将不再提供这份评价。', confirmText: shared ? '开启查看' : '关闭查看' })
if (!result.confirm || seq !== sequence || token !== userStore.token || !feedback.value) return
sharing.value = true; shareError.value = ''
try {
const saved = await put<{ shared: boolean }>(endpoint.value + '/sharing', { shared })
if (seq !== sequence || token !== userStore.token || !feedback.value) return
feedback.value.note.shared = saved.shared
syncShareMenu()
uni.showToast({ title: shared ? '已开启,请再次点击分享' : '已关闭学员查看', icon: 'none' })
} catch (e) { if (seq === sequence) shareError.value = e instanceof Error ? e.message : '操作失败,请重试' }
finally { sharing.value = false }
}
function goHome() { uni.switchTab({ url: '/pages/home/index' }) }
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'] }) })
onShow(() => { visible = true; void load() })
onHide(() => { visible = false; sequence++; feedback.value = null; state.value = 'loading' })
onUnload(() => { visible = false; sequence++; feedback.value = null })
watch(() => userStore.token, () => {
sequence++; feedback.value = null; state.value = userStore.loggedIn ? 'loading' : 'login'; syncShareMenu()
if (visible && !loggingIn.value && userStore.loggedIn) void load()
})
</script>
<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;}}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -0,0 +1,12 @@
// Use a generic cover instead of WeChat's automatic page screenshot, which may
// contain a member's training notes. Shared URLs never include an admin userId.
export function lessonFeedbackShare(id?: string) {
return {
title: '教练为你记录了一份课后评价',
path: id ? `/pages/profile/lesson-feedback?id=${encodeURIComponent(id)}` : '/pages/home/index',
imageUrl: '/static/share/lesson-feedback.png',
}
}
export function adminLessonFeedbackUrl(userId: string, id: string) {
return `/pages/profile/lesson-feedback?id=${encodeURIComponent(id)}&userId=${encodeURIComponent(userId)}`
}

View File

@@ -0,0 +1,110 @@
import { ExecutionContext, INestApplication, UnauthorizedException, ValidationPipe } from '@nestjs/common'
import { Test } from '@nestjs/testing'
import type { AddressInfo } from 'node:net'
import { JwtAuthGuard } from '../../auth/jwt-auth.guard'
import { RolesGuard } from '../../auth/roles.guard'
import { PrismaService } from '../../prisma/prisma.service'
import { ApiResponseInterceptor } from '../../common/interceptors/api-response.interceptor'
import { AdminMemberProgressController, MemberProgressController } from '../member-progress.controller'
import { MemberProgressService } from '../member-progress.service'
import { ProgressPhotoStorageService } from '../progress-photo-storage.service'
import { lessonFeedbackShare } from '../../../../app/src/utils/lesson-feedback'
describe('Owner-only shared lesson feedback HTTP access', () => {
let app: INestApplication
const original = { id: 'note', content: '整体评价', apparatusNotes: { M: '控制稳定', Chair: '注意节奏' }, shared: true, createdAt: new Date('2026-10-18T00:00:00Z'), bookingId: 'lesson', booking: { timeSlot: { date: new Date('2026-10-18'), startTime: '10:00' } }, user: { nickname: '学员' } }
let record: typeof original | null
const db = { memberNote: { findFirst: jest.fn(), updateMany: jest.fn() } }
async function request(method: string, path: string, identity?: string, body?: unknown, role = 'MEMBER') {
const address = app.getHttpServer().address() as AddressInfo
return fetch(`http://127.0.0.1:${address.port}${path}`, { method, headers: { 'Content-Type': 'application/json', ...(identity ? { 'x-user': identity, 'x-role': role } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
}
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [MemberProgressController, AdminMemberProgressController],
providers: [MemberProgressService, RolesGuard, { provide: PrismaService, useValue: db }, { provide: ProgressPhotoStorageService, useValue: {} }],
}).overrideGuard(JwtAuthGuard).useValue({ canActivate(context: ExecutionContext) {
const req = context.switchToHttp().getRequest()
if (!req.headers['x-user']) throw new UnauthorizedException()
req.user = { sub: req.headers['x-user'], role: req.headers['x-role'] }
return true
} }).compile()
app = module.createNestApplication()
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
app.useGlobalInterceptors(new ApiResponseInterceptor())
await app.listen(0, '127.0.0.1')
})
beforeEach(() => {
jest.clearAllMocks()
record = structuredClone(original)
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 }) => {
if (!record || where.userId !== 'owner' || where.id !== record.id) return { count: 0 }
record.shared = data.shared
return { count: 1 }
})
})
afterAll(async () => { await app.close() })
it('returns this one shared evaluation to its authenticated owner with no internal IDs', async () => {
const response = await request('GET', '/user/progress/notes/note', 'owner')
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toBe('private, no-store')
const body = await response.json()
expect(body.data).toMatchObject({ nickname: '学员', note: { content: '整体评价', apparatusNotes: { M: '控制稳定', Chair: '注意节奏' } } })
expect(body.data.note.operatorId).toBeUndefined()
expect(body.data.note.userId).toBeUndefined()
expect(db.memberNote.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'note', userId: 'owner', shared: true }, select: expect.not.objectContaining({ operatorId: true, userId: true }) }))
})
it.each(['MEMBER', 'ADMIN'])('rejects a forwarded student link for a different %s account', async role => {
const response = await request('GET', '/user/progress/notes/note?userId=owner', 'other', undefined, role)
expect(response.status).toBe(404)
expect(JSON.stringify(await response.json())).not.toContain('控制稳定')
expect(db.memberNote.findFirst.mock.calls[0][0].where).toEqual({ id: 'note', userId: 'other', shared: true })
})
it.each(['private', 'deleted', 'unknown'])('does not reveal a %s note even to the owner', async kind => {
if (kind === 'private') record!.shared = false
if (kind === 'deleted') record = null
const response = await request('GET', `/user/progress/notes/${kind === 'unknown' ? 'missing' : 'note'}`, 'owner')
expect(response.status).toBe(404)
expect((await response.json()).message).toBe('评价不存在或你无权查看')
})
it.each(['/user/progress/notes/note', '/admin/members/owner/progress/notes/note'])('requires authentication: %s', async path => {
expect((await request('GET', path)).status).toBe(401)
expect(db.memberNote.findFirst).not.toHaveBeenCalled()
})
it('allows an authorized teacher preview of a private note, scoped to the target member', async () => {
record!.shared = false
expect((await request('GET', '/admin/members/owner/progress/notes/note', 'teacher', undefined, 'ADMIN')).status).toBe(200)
expect(db.memberNote.findFirst.mock.calls[0][0].where).toEqual({ id: 'note', userId: 'owner' })
expect((await request('GET', '/admin/members/other/progress/notes/note', 'teacher', undefined, 'ADMIN')).status).toBe(404)
})
it('does not let members obtain preview access or change sharing by forging an admin URL', async () => {
expect((await request('GET', '/admin/members/owner/progress/notes/note', 'other')).status).toBe(403)
expect((await request('PUT', '/admin/members/owner/progress/notes/note/sharing', 'owner', { shared: true })).status).toBe(403)
expect(db.memberNote.findFirst).not.toHaveBeenCalled()
expect(db.memberNote.updateMany).not.toHaveBeenCalled()
})
it('revokes existing links immediately and lets a teacher re-enable the same note', async () => {
const endpoint = '/admin/members/owner/progress/notes/note/sharing'
expect((await request('PUT', endpoint, 'teacher', { shared: false }, 'ADMIN')).status).toBe(200)
expect((await request('GET', '/user/progress/notes/note', 'owner')).status).toBe(404)
expect((await request('PUT', endpoint, 'teacher', { shared: true }, 'ADMIN')).status).toBe(200)
expect((await request('GET', '/user/progress/notes/note', 'owner')).status).toBe(200)
expect(record!.apparatusNotes).toEqual(original.apparatusNotes)
expect(db.memberNote.updateMany).toHaveBeenCalledWith({ where: { id: 'note', userId: 'owner' }, data: { shared: false } })
})
it.each([{}, { shared: 'true' }, { shared: 1 }, { shared: null }, { shared: true, userId: 'other' }])('rejects malformed sharing updates %j', async body => {
expect((await request('PUT', '/admin/members/owner/progress/notes/note/sharing', 'teacher', body, 'ADMIN')).status).toBe(400)
expect(db.memberNote.updateMany).not.toHaveBeenCalled()
})
it('does not change a note through a mismatched member path', async () => {
expect((await request('PUT', '/admin/members/other/progress/notes/note/sharing', 'teacher', { shared: false }, 'ADMIN')).status).toBe(404)
expect(record!.shared).toBe(true)
})
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&userId=owner').path).toBe('/pages/profile/lesson-feedback?id=note%26userId%3Downer')
expect(lessonFeedbackShare().path).toBe('/pages/home/index')
})
})

View File

@@ -19,6 +19,7 @@ export class MemberNoteDto {
@IsBoolean() shared!: boolean @IsBoolean() shared!: boolean
@IsOptional() @IsString() @MinLength(1) bookingId?: string @IsOptional() @IsString() @MinLength(1) bookingId?: string
} }
export class NoteSharingDto { @IsBoolean() shared!: boolean }
export class ProgressPhotoDto { export class ProgressPhotoDto {
@IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/) recordedAt!: string @IsDateString() @Matches(/^\d{4}-\d{2}-\d{2}$/) recordedAt!: string
@IsString() @MaxLength(200) caption!: string @IsString() @MaxLength(200) caption!: string

View File

@@ -1,15 +1,16 @@
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common' import { Body, Controller, Delete, Get, Header, Param, Post, Put, UseGuards } from '@nestjs/common'
import { UserRole } from '@mp-pilates/shared' import { UserRole } from '@mp-pilates/shared'
import { JwtAuthGuard } from '../auth/jwt-auth.guard' import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { RolesGuard } from '../auth/roles.guard' import { RolesGuard } from '../auth/roles.guard'
import { Roles } from '../auth/roles.decorator' import { Roles } from '../auth/roles.decorator'
import { CurrentUser } from '../common/decorators/current-user.decorator' import { CurrentUser } from '../common/decorators/current-user.decorator'
import { MemberProgressService } from './member-progress.service' import { MemberProgressService } from './member-progress.service'
import { BodyMetricDto, MemberNoteDto, PhotoConsentDto, ProgressPhotoDto } from './dto/member-progress.dto' import { BodyMetricDto, MemberNoteDto, NoteSharingDto, PhotoConsentDto, ProgressPhotoDto } from './dto/member-progress.dto'
@Controller('user/progress') @UseGuards(JwtAuthGuard) @Controller('user/progress') @UseGuards(JwtAuthGuard)
export class MemberProgressController { export class MemberProgressController {
constructor(private readonly service: MemberProgressService) {} constructor(private readonly service: MemberProgressService) {}
@Get() archive(@CurrentUser('sub') userId: string) { return this.service.archive(userId) } @Get() archive(@CurrentUser('sub') userId: string) { return this.service.archive(userId) }
@Get('notes/:id') @Header('Cache-Control', 'private, no-store') note(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.note(userId, id) }
@Get('metrics/:id') metric(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.metric(userId, id) } @Get('metrics/:id') metric(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.metric(userId, id) }
@Post('photos/:id/consent') consent(@CurrentUser('sub') userId: string, @Param('id') id: string, @Body() dto: PhotoConsentDto) { return this.service.consent(userId, id, dto.consent) } @Post('photos/:id/consent') consent(@CurrentUser('sub') userId: string, @Param('id') id: string, @Body() dto: PhotoConsentDto) { return this.service.consent(userId, id, dto.consent) }
@Get('photos/:id/url') url(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.photoUrl(userId, id) } @Get('photos/:id/url') url(@CurrentUser('sub') userId: string, @Param('id') id: string) { return this.service.photoUrl(userId, id) }
@@ -23,6 +24,8 @@ export class AdminMemberProgressController {
@Get('metrics/:id') metricDetail(@Param('userId') userId: string, @Param('id') id: string) { return this.service.metric(userId, id) } @Get('metrics/:id') metricDetail(@Param('userId') userId: string, @Param('id') id: string) { return this.service.metric(userId, id) }
@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) }
@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) }
@Post('photos/:id/complete') complete(@Param('userId') userId: string, @Param('id') id: string) { return this.service.finishUpload(userId, id) } @Post('photos/:id/complete') complete(@Param('userId') userId: string, @Param('id') id: string) { return this.service.finishUpload(userId, id) }

View File

@@ -61,6 +61,25 @@ export class MemberProgressService {
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 this.prisma.memberNote.create({ data: { userId, operatorId, content, apparatusNotes, shared: dto.shared, bookingId: dto.bookingId || null } })
} }
async note(userId: string, id: string, admin = false) {
const record = await this.prisma.memberNote.findFirst({
where: { id, userId, ...(admin ? {} : { shared: true }) },
select: {
id: true, content: true, apparatusNotes: true, shared: true, createdAt: true, bookingId: true,
booking: { select: { timeSlot: { select: { date: true, startTime: true } } } },
user: { select: { nickname: true } },
},
})
// A forwarded link must not reveal whether someone else's note exists.
if (!record) throw new NotFoundException('评价不存在或你无权查看')
const { user, ...note } = record
return { nickname: user.nickname, note }
}
async setNoteSharing(userId: string, id: string, shared: boolean) {
const result = await this.prisma.memberNote.updateMany({ where: { id, userId }, data: { shared } })
if (!result.count) throw new NotFoundException('评价不存在或不属于该学员')
return { shared }
}
async remove(userId: string, kind: string, id: string) { async remove(userId: string, kind: string, id: string) {
if (kind === 'metrics') { if (kind === 'metrics') {
const result = await this.prisma.bodyMetric.deleteMany({ where: { id, userId } }) const result = await this.prisma.bodyMetric.deleteMany({ where: { id, userId } })

View File

@@ -17,6 +17,10 @@ export interface MemberNoteRecord {
apparatusNotes?: import('../lesson-feedback').ApparatusNotes | null apparatusNotes?: import('../lesson-feedback').ApparatusNotes | null
booking: { timeSlot: { date: string; startTime: string } } | null booking: { timeSlot: { date: string; startTime: string } } | null
} }
export interface LessonFeedbackDetail {
nickname: string
note: MemberNoteRecord
}
export interface ProgressPhotoRecord { export interface ProgressPhotoRecord {
id: string; caption: string; recordedAt: string; consentedAt: string | null; revokedAt: string | null; angle?: string | null; sessionId?: string | null id: string; caption: string; recordedAt: string; consentedAt: string | null; revokedAt: string | null; angle?: string | null; sessionId?: string | null
} }