From 819367178af69e3ac9df781cb322037c141bd3ea Mon Sep 17 00:00:00 2001 From: richarjiang Date: Wed, 21 Oct 2026 11:17:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=9A=E5=91=98=E6=A1=A3=E6=A1=88?= =?UTF-8?q?=E5=AF=84=E8=AF=AD=E6=94=B9=E5=90=8D=E4=B8=BA=E8=AF=BE=E5=90=8E?= =?UTF-8?q?=E6=80=BB=E7=BB=93=E5=B9=B6=E6=94=AF=E6=8C=81=E6=95=99=E7=BB=83?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=B7=B2=E8=AF=84=E4=BB=B7=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../app/src/components/CoachNoteEditor.vue | 63 ++++++++++++++----- .../app/src/components/MemberProgress.vue | 29 ++++++--- .../app/src/pages/profile/lesson-feedback.vue | 16 +++-- ...lesson-feedback-sharing.controller.spec.ts | 21 ++++++- .../user/__tests__/lesson-feedback.spec.ts | 37 ++++++++++- .../__tests__/member-progress.service.spec.ts | 6 +- .../src/user/member-progress.controller.ts | 1 + .../src/user/member-progress.service.ts | 18 +++++- 8 files changed, 160 insertions(+), 31 deletions(-) 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 @@ - AFTER CLASS · 练习手记记录这一课的进步 + AFTER CLASS · 练习手记{{ isEditing ? '编辑课后评价' : '记录这一课的进步' }} - {{ bookingId ? '已关联本次完成课程' : '教练笔记' }} · 每项均选填,自由记录 + {{ hasLinkedLesson ? '已关联本次完成课程' : '教练笔记' }} · 每项均选填,自由记录 @@ -37,25 +37,46 @@ {{ error }} - + 填写整体评价或任意一项器械即可保存 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 + ' 项器械记录' : '整体练习反馈' }} 记录于 {{ 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({