diff --git a/packages/app/src/components/CoachNoteEditor.vue b/packages/app/src/components/CoachNoteEditor.vue
index 04debc3..b3da04d 100644
--- a/packages/app/src/components/CoachNoteEditor.vue
+++ b/packages/app/src/components/CoachNoteEditor.vue
@@ -4,10 +4,10 @@
@@ -37,25 +37,46 @@
diff --git a/packages/app/src/pages/profile/lesson-feedback.vue b/packages/app/src/pages/profile/lesson-feedback.vue
index a153a99..b2cac67 100644
--- a/packages/app/src/pages/profile/lesson-feedback.vue
+++ b/packages/app/src/pages/profile/lesson-feedback.vue
@@ -19,7 +19,7 @@
{{ feedback.note.booking ? formatChinaDate(feedback.note.booking.timeSlot.date) : formatChinaDate(feedback.note.createdAt) }}{{ feedback.note.booking ? feedback.note.booking.timeSlot.startTime + ' · 本次课程' : '教练笔记' }}
- 教练评价{{ apparatusCount ? apparatusCount + ' 项器械记录' : '整体练习反馈' }}
+ 教练评价{{ apparatusCount ? apparatusCount + ' 项器械记录' : '整体练习反馈' }}
整体评价{{ feedback.note.content }}
记录于 {{ formatChinaDate(feedback.note.createdAt) }}
@@ -36,6 +36,7 @@
当前为私密记录,分享前需开启该学员查看
+
@@ -46,6 +47,7 @@ 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 CoachNoteEditor from '../../components/CoachNoteEditor.vue'
import { useUserStore } from '../../stores/user'
import { get, put, HttpRequestError } from '../../utils/request'
import { formatChinaDate } from '../../utils/format'
@@ -56,7 +58,7 @@ const navBarHeight = getSystemLayout().navBarHeight
const userStore = useUserStore()
const noteId = ref(''), userId = ref(''), feedback = ref(null)
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 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)}`)
@@ -105,17 +107,21 @@ async function setSharing(shared: boolean) {
} catch (e) { if (seq === sequence) shareError.value = e instanceof Error ? e.message : '操作失败,请重试' }
finally { sharing.value = false }
}
+function onNoteSaved() {
+ editing.value = false
+ void load()
+}
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 })
+onHide(() => { visible = false; editing.value = false; sequence++; feedback.value = null; state.value = 'loading' })
+onUnload(() => { visible = false; editing.value = 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()
})
diff --git a/packages/server/src/user/__tests__/lesson-feedback-sharing.controller.spec.ts b/packages/server/src/user/__tests__/lesson-feedback-sharing.controller.spec.ts
index 3100f2d..a60cd4e 100644
--- a/packages/server/src/user/__tests__/lesson-feedback-sharing.controller.spec.ts
+++ b/packages/server/src/user/__tests__/lesson-feedback-sharing.controller.spec.ts
@@ -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.updateMany.mockImplementation(async ({ where, data }) => {
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 }
})
})
@@ -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(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', () => {
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')
diff --git a/packages/server/src/user/__tests__/lesson-feedback.spec.ts b/packages/server/src/user/__tests__/lesson-feedback.spec.ts
index 0e6e378..c913b94 100644
--- a/packages/server/src/user/__tests__/lesson-feedback.spec.ts
+++ b/packages/server/src/user/__tests__/lesson-feedback.spec.ts
@@ -10,7 +10,7 @@ describe('Coach lesson feedback', () => {
const db = {
user: { findUnique: jest.fn() },
booking: { findFirst: jest.fn() },
- memberNote: { create: jest.fn() },
+ memberNote: { create: jest.fn(), updateMany: jest.fn(), findFirst: jest.fn() },
}
let service: MemberProgressService
beforeEach(() => {
@@ -69,4 +69,39 @@ describe('Coach lesson feedback', () => {
await expect(service.addNote('member', 'coach', { shared: true, bookingId: 'foreign', apparatusNotes: { M: '完成训练' } })).rejects.toThrow('只能批注该学员已完成的课程')
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' } }))
+ })
})
diff --git a/packages/server/src/user/__tests__/member-progress.service.spec.ts b/packages/server/src/user/__tests__/member-progress.service.spec.ts
index 0e904a8..6d1d257 100644
--- a/packages/server/src/user/__tests__/member-progress.service.spec.ts
+++ b/packages/server/src/user/__tests__/member-progress.service.spec.ts
@@ -198,10 +198,14 @@ describe('Member progress authorization', () => {
})
it('restricts record editing to admins and exposes no member write route', () => {
const handler = AdminMemberProgressController.prototype.updateMetric
+ const noteHandler = AdminMemberProgressController.prototype.updateNote
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('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('updateNote' in MemberProgressController.prototype).toBe(false)
})
})
diff --git a/packages/server/src/user/member-progress.controller.ts b/packages/server/src/user/member-progress.controller.ts
index 50b34e5..6956b16 100644
--- a/packages/server/src/user/member-progress.controller.ts
+++ b/packages/server/src/user/member-progress.controller.ts
@@ -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) }
@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') 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) }
@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) }
diff --git a/packages/server/src/user/member-progress.service.ts b/packages/server/src/user/member-progress.service.ts
index 6c974bf..dc92863 100644
--- a/packages/server/src/user/member-progress.service.ts
+++ b/packages/server/src/user/member-progress.service.ts
@@ -54,12 +54,28 @@ export class MemberProgressService {
}
async addNote(userId: string, operatorId: string, dto: MemberNoteDto) {
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('器械记录内容无效')
const content = dto.content?.trim() || ''
const apparatusNotes = Object.fromEntries(filledApparatusNotes(dto.apparatusNotes).map(note => [note.apparatus, note.content]))
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('只能批注该学员已完成的课程')
- 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) {
const record = await this.prisma.memberNote.findFirst({