Эх сурвалжийг харах

feat(teaching): polish lesson reports and support audited feedback edits

彭峰 1 долоо хоног өмнө
parent
commit
1faed1a1fb

+ 16 - 0
docs/operations/lesson-report-edit-2026-10-13.md

@@ -0,0 +1,16 @@
+# 学习报表与完课报告编辑(2026-10-13)
+
+学习报表和已结课预约详情沿用绿色视觉系统,统一课程概览、反馈、学习记录、评价和复习进度卡片。日期按北京时间显示到分钟;预约详情用已保存的报告快照展示本次词数与课程分钟数,避免把缺失的旧累计字段显示为零。
+
+老师可在预约详情底部或反馈卡片点击“修改报告”,学习报表也提供相同入口。编辑时回填全部反馈字段,取消不写入,失败保留输入,成功刷新详情并提示。复习、生词本、训练状态仍为可选文本。学生端不显示修改按钮。
+
+`app_lesson_feedback_update` 调用独立的 `xs_lesson_feedback_update` 事务,只更新反馈文字、对应上课记录的点评及用于刷新的时间戳。校验帐套、有效老师、预约归属、已结课状态、取消状态、精确报告版本;请求编号保障重试幂等。修改前后的反馈保存在 `LessonWorkflowCommand`。不调用结课函数,不重复扣课时,不改工资快照、原始起止时间、单词成果、复习计划或学生评价。
+
+验收:
+
+- 数据库流程测试覆盖文字修改、重试、越权、旧版本和取消拒绝;比较修改前后余额、学习成果、评价及报告系统字段。
+- Angular 定向测试覆盖编辑回填、取消、失败保留输入、保存、学生权限和请求幂等;移动端构建通过。
+- Chromium 隔离接口页面验收覆盖 375px 和 812px,无横向溢出、无页面异常,实际点击编辑与保存通过。截图在 `/tmp/xiaoshu-report-previews/`;该页面测试数据为模拟数据。
+- 云端发布版本 `2026-10-13-edit-feedback-v6`,仅发布移动网关及新增反馈修改 SQL 函数。
+- 线上隔离账号完成真实教学流程、报告修改与重试、过期版本拒绝、学生端回读;余额对账、原有学生评价和学习成果保持。隔离数据与临时函数均已清理。
+- 实际服务器发布前备份、迁移与验收日志在本机权限受限目录 `/Users/mac/.local/state/xiaoshu/report-edit/2026-10-13/`。前端本地开发页面可刷新查看;本次未发布公网静态站点。

+ 1 - 0
projects/xiaoshu-mobile/src/app/core/api.service.spec.ts

@@ -359,6 +359,7 @@ describe('teaching request reliability',()=>{
  afterEach(()=>http.verify());
  for(const action of ['e_order_complete_feedback','vote_add','unit_record_update','e_get_21list_tj'])it(action+' carries the current session',()=>{api.post(action,{uid:1761}).subscribe();const req=http.expectOne(API_CONFIG.cloudFunctionUrl);expect(req.request.body.token).toBe('r:teacher');req.flush({retcode:0,result:{}});});
  it('does not automatically retry a write and retains its request key for manual retry',fakeAsync(()=>{const body={uid:1761,wordsId:'7',save:1};api.post('e_add_words',body).subscribe({error:()=>{}});const first=http.expectOne(API_CONFIG.cloudFunctionUrl),key=first.request.body.params.requestKey;expect(key).toBeTruthy();first.flush('',{status:503,statusText:'Unavailable'});tick(2000);http.expectNone(API_CONFIG.cloudFunctionUrl);api.post('e_add_words',body).subscribe();const retry=http.expectOne(API_CONFIG.cloudFunctionUrl);expect(retry.request.body.params.requestKey).toBe(key);retry.flush({retcode:0,result:{}});}));
+ it('reuses the report amendment request key after a failed save',fakeAsync(()=>{const body={uid:1761,wordsId:'7',save:1};api.post('app_lesson_feedback_update',body).subscribe({error:()=>{}});const first=http.expectOne(API_CONFIG.cloudFunctionUrl),key=first.request.body.params.requestKey;expect(key).toBeTruthy();first.flush('',{status:503,statusText:'Unavailable'});tick(2000);http.expectNone(API_CONFIG.cloudFunctionUrl);api.post('app_lesson_feedback_update',body).subscribe();const retry=http.expectOne(API_CONFIG.cloudFunctionUrl);expect(retry.request.body.params.requestKey).toBe(key);retry.flush({retcode:0,result:{}});}));
  it('fetches a fresh learning summary after a successful teaching write',()=>{const results:Array<{records:Array<{id:number}>}>=[];api.post<{records:Array<{id:number}>}>('app_learning_overview',{uid:1761}).subscribe(({result})=>results.push(result));http.expectOne(API_CONFIG.cloudFunctionUrl).flush({retcode:0,result:{records:[]}});api.post('e_order_complete_feedback',{orderId:7}).subscribe();http.expectOne(API_CONFIG.cloudFunctionUrl).flush({retcode:0,result:{}});api.post<{records:Array<{id:number}>}>('app_learning_overview',{uid:1761}).subscribe(({result})=>results.push(result));http.expectOne(API_CONFIG.cloudFunctionUrl).flush({retcode:0,result:{records:[{id:1}]}});expect(results).toEqual([{records:[]},{records:[{id:1}]}]);});
  it('times out completion without retrying and keeps the idempotency key for a manual retry',fakeAsync(()=>{
   const body={orderId:901,expectedUpdatedAt:'2026-10-11T00:00:00Z',feedback:{contentSummary:'Unit 1'}};let message='';

+ 2 - 2
projects/xiaoshu-mobile/src/app/core/api.service.ts

@@ -36,7 +36,7 @@ export class ApiService {
   }
 
   callCloud<T>(action: string, params: Record<string, unknown> = {}, sessionToken = this.parseSessionToken()): Observable<ApiEnvelope<T>> {
-    const teachingWrite=['app_lesson_complete','app_lesson_review','e_add_words','content_add_zt','content_update','content_add','stu_record_update_v2','unit_record_update','vote_add','reading/create'].includes(action);
+    const teachingWrite=['app_lesson_feedback_update','app_lesson_complete','app_lesson_review','e_add_words','content_add_zt','content_update','content_add','stu_record_update_v2','unit_record_update','vote_add','reading/create'].includes(action);
     const requestIdentity=this.parseSessionToken()+'|'+action+'|'+this.stableJson(params);
     if(teachingWrite && !params['requestKey']) {
       const key=this.pendingWrites.get(requestIdentity)||crypto.randomUUID();
@@ -46,7 +46,7 @@ export class ApiService {
       ...(sessionToken ? { token: sessionToken } : {}),
       params: { action, ...params },
     };
-    const bounded = ['app_lesson_courses','user_get','e_order_detail','e_order_update_v2','app_companion_overview','app_live_versions','app_lesson_complete','app_lesson_review'].includes(action);
+    const bounded = ['app_lesson_courses','user_get','e_order_detail','e_order_update_v2','app_companion_overview','app_live_versions','app_lesson_feedback_update','app_lesson_complete','app_lesson_review'].includes(action);
     const request = this.http.post<unknown>(API_CONFIG.cloudFunctionUrl, body, {
       headers: {
         'Content-Type': 'application/json',

+ 32 - 1
projects/xiaoshu-mobile/src/app/core/lesson-workflow.integration.spec.ts

@@ -14,11 +14,12 @@ import { appointmentDate, beijingDate } from './appointment-time';
 @Component({standalone:true,template:'课堂内容'}) class ClassroomStub {}
 
 describe('appointment workflow screens',()=>{
+ let failFeedback:boolean;
  let order:Record<string,unknown>,failDetail:boolean,failCourses:boolean,courses:Record<string,unknown>[]|null,delayedUnits:Subject<unknown>|null,calls:Array<{action:string;params:Record<string,unknown>}>;
  const user=signal({userId:30,groupId:3}),student=signal<unknown>(null),groupId=signal(3);
  const response=(result:unknown)=>of({retcode:0,result});
  beforeEach(()=>{
-  localStorage.clear();sessionStorage.clear();calls=[];failDetail=false;failCourses=false;courses=null;delayedUnits=null;user.set({userId:30,groupId:3});groupId.set(3);
+  localStorage.clear();sessionStorage.clear();calls=[];failFeedback=false;failDetail=false;failCourses=false;courses=null;delayedUnits=null;user.set({userId:30,groupId:3});groupId.set(3);
   order={GeneralID:901,szyh:20,pl:30,HoneyName:'测试学员',kcid:801,bindingId:'binding-1',courseCategoryKey:'word',yykcmc:'词表',dszt:0,bxrq:'2099-10-11',sdsd:'19:00',allowedActions:['start'],updatedAt:'2026-10-11T00:00:00Z'};
   const invoke=(action:string,params:Record<string,unknown>={})=>{
    calls.push({action,params});
@@ -30,6 +31,7 @@ describe('appointment workflow screens',()=>{
    if(action==='node_list')return delayedUnits||response([{NodeID:params['pid']==='802'?822:811,NodeName:'Unit 1'}]);
    if(action==='e_order_update_v2'){order={...order,dszt:10,allowedActions:['continue','complete']};return response({status:10});}
    if(action==='app_lesson_complete'){order={...order,dszt:30,allowedActions:[],reviewStatus:'pending'};return response({status:30});}
+   if(action==='app_lesson_feedback_update'){if(failFeedback)return throwError(()=>new ApiError('报告已被更新,请刷新详情后重试'));order={...order,feedback:{...JSON.parse(String(params['feedback'])),revision:'2026-10-13T00:00:00Z'}};return response({saved:true});}
    if(action==='app_lesson_review'){order={...order,allowedActions:[],reviewStatus:'submitted',studentReview:{score:params['score'],comment:params['comment']}};return response({status:30});}
    return response([]);
   };
@@ -209,4 +211,33 @@ describe('appointment workflow screens',()=>{
   order={...order,allowedActions:[],blockedReason:'课程仅在预约当天可开始'};const {harness}=await screen();
   expect(harness.routeNativeElement?.textContent).toContain('预约当天');expect(harness.routeNativeElement?.querySelector('.appointment-actions .primary-button')).toBeNull();
  });
+ it('edits a completed report with its saved version without completing the lesson again',async()=>{
+  order={...order,dszt:30,allowedActions:['editFeedback'],feedback:{contentSummary:'原学习内容',reviewStatus:'原复习',reviewSummary:'复习说明',trainingStatus:'原状态',trainingSummary:'原进度',coachComment:'原点评',noHomework:true,homework:'无作业',notes:'原备注',revision:'2026-10-13 10:20:00.123456+08'}};
+  const {harness,component}=await screen();component.openFeedbackEditor();harness.fixture.detectChanges();
+  expect(component.contentSummary).toBe('原学习内容');expect(component.notes).toBe('原备注');
+  expect(harness.routeNativeElement?.querySelector('dialog')?.textContent).toContain('保存修改');
+  component.contentSummary='新的学习内容';component.finishCoachLesson();harness.fixture.detectChanges();
+  const write=calls.find(c=>c.action==='app_lesson_feedback_update');
+  expect(write?.params['expectedUpdatedAt']).toBe('2026-10-13 10:20:00.123456+08');
+  expect(calls.some(c=>c.action==='app_lesson_complete')).toBeFalse();
+  expect(component.feedback()?.['contentSummary']).toBe('新的学习内容');expect(component.feedbackSaved()).toBeTrue();expect(component.status()).toBe(30);
+ });
+ it('cancels editing without writing and restores saved fields on reopen',async()=>{
+  order={...order,dszt:30,allowedActions:['editFeedback'],feedback:{contentSummary:'已保存内容',trainingSummary:'原进度'}};
+  const {component}=await screen();component.openFeedbackEditor();component.contentSummary='未保存内容';component.closeCompletionFeedback();component.openFeedbackEditor();
+  expect(component.contentSummary).toBe('已保存内容');expect(calls.some(c=>c.action==='app_lesson_feedback_update')).toBeFalse();
+ });
+ it('does not show a teacher edit action for the student or an unverified detail',async()=>{
+  user.set({userId:20,groupId:1});groupId.set(1);order={...order,dszt:30,allowedActions:[],feedback:{contentSummary:'内容'}};
+  const {harness,component}=await screen('/pages/shop/stu_get_order?id=901');component.openFeedbackEditor();
+  expect(harness.routeNativeElement?.textContent).not.toContain('修改报告');expect(component.completionDialog()).toBeUndefined();
+ });
+
+ it('keeps a failed save open with the teacher draft ',async()=>{
+  order={...order,dszt:30,allowedActions:['editFeedback'],feedback:{contentSummary:'原内容',trainingSummary:'进度',noHomework:true,revision:'2026-10-13T00:00:00Z'}};
+  const {harness,component}=await screen();component.openFeedbackEditor();component.contentSummary='未丢失的修改';failFeedback=true;component.finishCoachLesson();harness.fixture.detectChanges();
+  expect(component.completionDialog()?.nativeElement.open).toBeTrue();expect(component.contentSummary).toBe('未丢失的修改');
+  expect(component.feedbackError()).toContain('已被更新');expect(component.submitting()).toBeFalse();expect(component.feedbackSaved()).toBeFalse();
+ });
+
 });

+ 19 - 10
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.html

@@ -20,7 +20,7 @@
       <div><dt>课程类型</dt><dd>{{ field('type') || '-' }}</dd></div>
       <div><dt>预约时间</dt><dd>{{ field('date') }} {{ field('time') }}@if (field('end')) { ~ {{ field('end') }} }</dd></div>
       <div><dt>交付方式</dt><dd>{{ field('delivery') || '-' }}</dd></div>
-      @if (status() > 20) { <div><dt>陪练老师</dt><dd>{{ field('coach') || '-' }}</dd></div><div><dt>学习成果</dt><dd>{{ order()['dsxueshi'] || 0 }} 学时,累计识词 {{ order()['learned'] || 0 }}</dd></div> }
+      @if (status() > 20) { <div><dt>陪练老师</dt><dd>{{ field('coach') || '-' }}</dd></div>@if (feedback(); as f) { <div><dt>本次学习</dt><dd>{{ f['durationMinutes'] || '—' }} 分钟 · 学习 {{ f['vocabularyCount'] ?? '—' }} 个单词</dd></div> } }
     </dl>
     </section>
 
@@ -62,14 +62,14 @@
       </section>
     }
 
-    @if (!isStudentView && can('complete')) {
+    @if (!isStudentView && (can('complete') || can('editFeedback'))) {
       <dialog #completionDialog class="completion-dialog" aria-labelledby="completion-title" aria-describedby="completion-description" (cancel)="closeCompletionFeedback($event)">
         <section class="completion-feedback">
           <header>
-            <div><h2 id="completion-title">完课反馈</h2><p id="completion-description">填写反馈并提交后,结束本次课程。</p></div>
+            <div><h2 id="completion-title">{{ editingFeedback() ? '修改学习报告' : '完课反馈' }}</h2><p id="completion-description">{{ editingFeedback() ? '修改本次课堂反馈,保存后学员即可查看。' : '填写反馈并提交后,结束本次课程。' }}</p></div>
             <button type="button" class="feedback-close" aria-label="关闭完课反馈" [disabled]="submitting()" (click)="closeCompletionFeedback()"><lucide-icon [img]="icons.X" [size]="20" /></button>
           </header>
-          <p class="feedback-hint">日期、时间、课时扣减、生词和抗遗忘数据由系统自动记录</p>
+          <p class="feedback-hint">{{ editingFeedback() ? '本次修改仅更新反馈内容,课时和学习记录保持不变。' : '日期、时间、课时扣减、生词和抗遗忘数据由系统自动记录' }}</p>
           <fieldset class="feedback-fields" [disabled]="submitting()">
             <label><span>本次学习的课本或强化内容 <b>*</b></span><textarea autofocus [(ngModel)]="contentSummary" maxlength="500" aria-required="true" placeholder="例:人教版三年级 Unit 3,重点词汇与句型"></textarea></label>
             <div class="feedback-grid">
@@ -89,8 +89,8 @@
           </fieldset>
           @if (feedbackError()) { <p class="feedback-error" role="alert">{{ feedbackError() }}</p> }
           <footer class="feedback-actions">
-            <button type="button" class="secondary-button" [disabled]="submitting()" (click)="closeCompletionFeedback()">返回课程</button>
-            <button type="button" class="primary-button" [disabled]="submitting()" (click)="finishCoachLesson()">{{ submitting() ? '正在提交…' : '提交并结束课程' }}</button>
+            <button type="button" class="secondary-button" [disabled]="submitting()" (click)="closeCompletionFeedback()">{{ editingFeedback() ? '取消修改' : '返回课程' }}</button>
+            <button type="button" class="primary-button" [disabled]="submitting()" (click)="finishCoachLesson()">{{ submitting() ? (editingFeedback() ? '正在保存…' : '正在提交…') : (editingFeedback() ? '保存修改' : '提交并结束课程') }}</button>
           </footer>
         </section>
       </dialog>
@@ -108,17 +108,26 @@
         <h2><lucide-icon [img]="icons.Star" [size]="20" />课程评价</h2>
         <div><span>学员评分</span><strong>{{ $any(order()['studentReview'])?.score || practiceRecord()['pf'] || '-' }}@if ($any(order()['studentReview'])?.score || practiceRecord()['pf']) { 分 }</strong></div>
         <p>{{ $any(order()['studentReview'])?.comment || practiceRecord()['pjnr'] || '学员暂无文字评价' }}</p>
-        <div><span>陪练点评</span><strong>{{ practiceRecord()['plpjsj'] || '' }}</strong></div>
-        <p>{{ practiceRecord()['pldp'] || '陪练暂无文字点评' }}</p>
+        <div><span>陪练点评</span><strong>{{ dateLabel(practiceRecord()['plpjsj']) }}</strong></div>
+        <p>{{ feedback()?.['coachComment'] || practiceRecord()['pldp'] || '陪练暂无文字点评' }}</p>
       </section>
     }
 
+    @if (feedback(); as f) {
+      <section class="lesson-feedback">
+        <header class="feedback-heading"><div><h2>完课反馈</h2><small>课堂内容与下次学习安排</small></div>
+          @if (!isStudentView && can('editFeedback')) { <button type="button" class="edit-report" (click)="openFeedbackEditor()" [disabled]="submitting()">修改报告</button> }
+        </header>
+        @if (feedbackSaved()) { <p class="saved-notice" role="status">报告修改已保存</p> }
+        <app-lesson-feedback [feedback]="f" />
+      </section>
+    }
     <div class="appointment-actions">
       @if (can('start')) { <button type="button" class="primary-button" [disabled]="submitting() || coursesLoading() || unitsLoading()" (click)="startOrContinue()">{{submitting()?'正在开始…':'开始课程'}}</button> }
       @if (can('continue')) { <button type="button" [class.primary-button]="!can('complete')" [class.secondary-button]="can('complete')" [disabled]="submitting() || coursesLoading() || unitsLoading()" (click)="startOrContinue()">继续课程</button> }
       @if (can('complete')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="openCompletionFeedback()">{{status()===10?'结束课程':'补齐反馈并结课'}}</button> }
       @if (can('review')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="finishStudentLesson()">{{submitting()?'正在提交…':'提交评价'}}</button> }
-      @if (status()>=30 && !order()['cancelled']) { <button type="button" class="secondary-button" (click)="openReport()">查看学习报告</button><span>{{order()['reviewStatus']==='submitted'?'学生已评价':'已结课 · 待学生评价'}}</span> }
+      @if (status()>=30 && !order()['cancelled']) { @if (!isStudentView && can('editFeedback')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="openFeedbackEditor()">修改报告</button> } <button type="button" class="secondary-button" (click)="openReport()">查看学习报告</button><span>{{order()['reviewStatus']==='submitted'?'学生已评价':'已结课 · 待学生评价'}}</span> }
     </div>
   }
-@if(feedback();as f){<section class="lesson-feedback card"><h3>完课反馈</h3><p>学习内容:{{f['contentSummary']}}</p><p>训练进度:{{f['trainingSummary']}}</p><p>作业:{{f['homework']||'无作业'}}</p></section>}</section>
+</section>

+ 24 - 0
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.scss

@@ -74,3 +74,27 @@
 .empty-state.error > div { display: grid; justify-items: center; gap: 9px; }
 @media (min-width: 760px) { .appointment-page { max-width: 760px; margin: 0 auto; border-inline: 1px solid var(--line); } .appointment-actions { right: calc((100vw - 760px) / 2); left: calc((100vw - 760px) / 2); } }
 @media (max-width: 560px) { .completion-feedback { padding: 16px; } .feedback-grid, .unit-grid { grid-template-columns: 1fr; } .unit-heading { align-items: flex-start; flex-direction: column; gap: 5px; } .unit-heading span { text-align: left; } }
+
+/* Completed-lesson reading and editing share the report card rhythm. */
+.course-section, .comment-panel, .report-panel, .lesson-feedback {
+  margin: var(--panel-gap, 12px) var(--page-gutter, 16px); padding: 20px;
+  border: 1px solid var(--line); border-radius: 16px; background: white;
+}
+.lesson-feedback { margin-bottom: 24px; }
+.feedback-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 18px; }
+.feedback-heading h2 { margin: 0 0 4px; font-size: 17px; }
+.feedback-heading small { color: var(--ink-soft); font-size: 12px; line-height: 1.5; }
+.edit-report { flex-shrink: 0; min-height: 44px; padding: 8px 14px; color: var(--brand-strong); background: var(--surface-muted); border: 1px solid var(--line); border-radius: 10px; font-weight: 600; }
+.saved-notice { color: var(--brand-strong); padding: 10px 12px; background: var(--surface-muted); border-radius: 10px; font-size: 13px; }
+.report-panel div { flex-wrap: wrap; gap: 6px 12px; line-height: 1.6; }
+.report-panel div strong { font-size: 12px; font-weight: 500; overflow-wrap: anywhere; }
+.report-panel p { white-space: pre-wrap; overflow-wrap: anywhere; }
+.appointment-info dd { font-size: 14px; }
+.feedback-actions { position: sticky; bottom: -22px; padding-bottom: 8px; background: white; }
+button:focus-visible { outline: 2px solid var(--brand-strong); outline-offset: 3px; }
+button:active:not(:disabled) { opacity: .8; }
+@media(max-width:560px) {
+  .course-section,.comment-panel,.report-panel,.lesson-feedback { padding: 16px; }
+  .feedback-actions { bottom: -16px; }
+  .feedback-fields input,.feedback-fields textarea { font-size:16px; }
+}

+ 37 - 7
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts

@@ -1,3 +1,5 @@
+import { LessonFeedbackComponent } from './lesson-feedback.component';
+import { lessonDateLabel } from './lesson-display';
 import { DropdownDirective } from '../../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, OnInit, OnDestroy, signal, viewChild } from '@angular/core';
@@ -17,7 +19,7 @@ type SourceItem = Record<string, unknown>;
 @Component({
   selector: 'app-appointment-page',
   standalone: true,
-  imports: [DropdownDirective, CommonModule, FormsModule, LucideAngularModule],
+  imports: [LessonFeedbackComponent, DropdownDirective, CommonModule, FormsModule, LucideAngularModule],
   templateUrl: './appointment-page.component.html',
   styleUrl: './appointment-page.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
@@ -42,6 +44,11 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
   readonly error = signal('');
   readonly detailWarning = signal('');
   readonly feedbackError = signal('');
+  readonly editingFeedback = signal(false);
+  readonly feedbackSaved = signal(false);
+  private feedbackRevision = '';
+  private autoEditOpened = false;
+  readonly dateLabel = lessonDateLabel;
   readonly actionError = signal('');
   readonly detailVerified = signal(false);
   private loadRequest?: Subscription;
@@ -75,13 +82,14 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
   constructor() {
     effect(() => {
       const dialog = this.completionDialog()?.nativeElement;
+      if (dialog && !this.autoEditOpened && this.can('editFeedback') && this.feedback() && this.route.snapshot.queryParamMap.get('editReport') === '1') { this.autoEditOpened = true; this.openFeedbackEditor(); }
       if (dialog && !this.isStudentView && !this.order()['cancelled'] && this.can('complete') && this.route.snapshot.queryParamMap.get('feedback') === '1') dialog.showModal();
     });
   }
 
   ngOnInit(): void {
     this.load();
-    this.stopLive = this.liveData.watch({scopes:['appointments'],studentId:()=>undefined,appointmentPolling:true,onRefresh:()=>{if(!this.submitting())this.load(true);}});
+    this.stopLive = this.liveData.watch({scopes:['appointments'],studentId:()=>undefined,appointmentPolling:true,onRefresh:()=>{if(!this.submitting() && !this.completionDialog()?.nativeElement.open)this.load(true);}});
   }
 
   ngOnDestroy(): void { this.stopLive?.(); this.loadRequest?.unsubscribe(); this.courseRequest?.unsubscribe(); this.unitRequest?.unsubscribe(); }
@@ -158,13 +166,14 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
       delivery: ['jffs'],
       coach: ['plxm', 'plName'],
     };
+    if (label === 'type') return String(this.order()['courseCategoryName'] || ({1:'30分钟课',2:'60分钟课',3:'体验课',4:'课型4'} as Record<string,string>)[String(this.order()['dslx'])] || this.order()['dslx_t'] || '课程');
     for (const key of aliases[label] ?? []) if (this.order()[key] !== undefined) return String(this.order()[key]);
     return '';
   }
 
   statusText(): string {
     if(this.order()['cancelled'])return '已取消';
-    return String(this.order()['status_txt'] ?? ({ 0: '待开始', 10: '学习中', 11: '等待学员确认', 20: '学员已确认', 30: '已完成' } as Record<number, string>)[this.status()] ?? `状态 ${this.status()}`);
+    return String(this.order()['status_txt'] ?? ({ 0: '待开始', 10: '学习中', 11: '等待学员确认', 20: '学员已确认', 30: '已完成', 40: '已完成' } as Record<number, string>)[this.status()] ?? `状态 ${this.status()}`);
   }
 
   private applyOrder(order: SourceItem): void {
@@ -268,25 +277,46 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
   openCompletionFeedback(): void {
     if(this.order()['cancelled']===true)return;
     if (this.isStudentView || !this.can('complete') || this.submitting()) return;
+    this.editingFeedback.set(false);
     this.feedbackError.set('');
     this.completionDialog()?.nativeElement.showModal();
   }
 
+  openFeedbackEditor(): void {
+    if (this.isStudentView || !this.can('editFeedback') || !this.feedback() || this.submitting()) return;
+    const feedback = this.feedback()!;
+    this.contentSummary = String(feedback['contentSummary'] || '');
+    this.reviewStatus = String(feedback['reviewStatus'] || '');
+    this.reviewSummary = String(feedback['reviewSummary'] || '');
+    this.trainingStatus = String(feedback['trainingStatus'] || '');
+    this.trainingSummary = String(feedback['trainingSummary'] || '');
+    this.homework = String(feedback['homework'] || '');
+    this.noHomework = feedback['noHomework'] === true;
+    this.coachComment = String(feedback['coachComment'] ?? this.practiceRecord()['pldp'] ?? '');
+    this.notes = String(feedback['notes'] || '');
+    this.feedbackRevision = String(feedback['revision'] || feedback['updatedAt'] || '');
+    this.editingFeedback.set(true);
+    this.feedbackError.set('');
+    this.feedbackSaved.set(false);
+    this.completionDialog()?.nativeElement.showModal();
+  }
+
   closeCompletionFeedback(event?: Event): void {
     event?.preventDefault();
     if (!this.submitting()) this.completionDialog()?.nativeElement.close();
   }
 
   finishCoachLesson(): void {
-    if (this.submitting() || !this.can('complete') || !this.completionDialog()?.nativeElement.open) return;
+    if (this.submitting() || !this.can(this.editingFeedback() ? 'editFeedback' : 'complete') || !this.completionDialog()?.nativeElement.open) return;
     this.feedbackError.set('');
     if (!this.contentSummary.trim()) { this.feedbackError.set('请填写本次学习内容'); return; }
     if (!this.trainingSummary.trim()) { this.feedbackError.set('请填写进度说明'); return; }
     if (!this.noHomework && !this.homework.trim()) { this.feedbackError.set('请填写作业安排,或选择“无作业”'); return; }
     this.submitting.set(true);
-    this.api.post('app_lesson_complete', {
+    const editing = this.editingFeedback();
+    this.api.post(editing ? 'app_lesson_feedback_update' : 'app_lesson_complete', {
       orderId: this.orderId,
-      expectedUpdatedAt: this.order()['updatedAt'],
+      expectedUpdatedAt: editing ? this.feedbackRevision : this.order()['updatedAt'],
       feedback: JSON.stringify({
         contentSummary: this.contentSummary.trim(),
         reviewStatus: this.reviewStatus.trim(),
@@ -299,7 +329,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
         coachComment: this.coachComment.trim(),
       }),
     }).pipe(finalize(() => this.submitting.set(false))).subscribe({
-      next: () => { this.completionDialog()?.nativeElement.close(); this.platform.toast('课程已结课,学习报告已生成'); this.load(); },
+      next: () => { this.completionDialog()?.nativeElement.close(); this.feedbackSaved.set(editing); this.platform.toast(editing ? '报告修改已保存' : '课程已结课,学习报告已生成'); this.load(); },
       error: (error: ApiError) => this.feedbackError.set(error.message),
     });
   }

+ 10 - 0
projects/xiaoshu-mobile/src/app/features/appointment/lesson-display.spec.ts

@@ -0,0 +1,10 @@
+import { lessonDateLabel } from './lesson-display';
+describe('lesson report date labels', () => {
+  it('formats legacy dates and precise database timestamps in Beijing time', () => {
+    expect(lessonDateLabel('20261013')).toBe('2026-10-13');
+    expect(lessonDateLabel('2026-10-13 10:26:09.101256+08')).toBe('2026-10-13 10:26');
+    expect(lessonDateLabel('2026-10-13T02:26:09.101Z')).toBe('2026-10-13 10:26');
+    expect(lessonDateLabel('2026-10-13 14:00')).toBe('2026-10-13 14:00');
+    expect(lessonDateLabel(null)).toBe('');
+  });
+});

+ 11 - 0
projects/xiaoshu-mobile/src/app/features/appointment/lesson-display.ts

@@ -0,0 +1,11 @@
+/** Format legacy compact dates and PostgreSQL timestamps without leaking raw precision. */
+export function lessonDateLabel(value: unknown): string {
+  const raw = String(value || '').trim();
+  if (!raw) return '';
+  if (/^\d{8}$/.test(raw)) return `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`;
+  if (!/([zZ]|[+-]\d{2}(:?\d{2})?)$/.test(raw.slice(10))) return raw.slice(0, 16);
+  const normalized = raw.replace(' ', 'T').replace(/(\.\d{3})\d+/, '$1').replace(/([+-]\d{2})$/, '$1:00');
+  const date = new Date(normalized);
+  if (!Number.isFinite(date.getTime())) return raw.slice(0, 16);
+  return new Intl.DateTimeFormat('sv-SE', {timeZone:'Asia/Shanghai', year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', hour12:false}).format(date);
+}

+ 24 - 0
projects/xiaoshu-mobile/src/app/features/appointment/lesson-feedback.component.ts

@@ -0,0 +1,24 @@
+import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+
+@Component({
+  selector: 'app-lesson-feedback', standalone: true,
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  template: `
+    <dl class="feedback-content">
+      <div class="learning-content"><dt>学习内容</dt><dd>{{ feedback()['contentSummary'] || '未补充' }}</dd></div>
+      <div><dt>复习 / 生词本</dt><dd>{{ feedback()['reviewStatus'] || '未补充' }}</dd>@if(feedback()['reviewSummary']) { <dd>{{ feedback()['reviewSummary'] }}</dd> }</div>
+      <div><dt>训练进度</dt>@if(feedback()['trainingStatus']) { <dd>{{ feedback()['trainingStatus'] }}</dd> }<dd>{{ feedback()['trainingSummary'] || '未补充' }}</dd></div>
+      <div><dt>老师点评</dt><dd>{{ feedback()['coachComment'] || '未补充' }}</dd></div>
+      <div class="homework-content"><dt>课后作业</dt><dd>{{ feedback()['noHomework'] ? '本次无作业' : feedback()['homework'] || '未补充' }}</dd></div>
+    </dl>
+  `,
+  styles: [`
+    :host{display:block}.feedback-content{margin:0;display:grid;gap:16px}
+    .feedback-content>div{min-width:0}.feedback-content dt{margin-bottom:5px;color:var(--ink-soft);font-size:12px;line-height:1.5}
+    .feedback-content dd{margin:0;color:var(--ink);font-size:15px;line-height:1.7;white-space:pre-wrap;overflow-wrap:anywhere}
+    .feedback-content dd+dd{margin-top:4px}.learning-content,.homework-content{padding:14px;border-radius:12px;background:var(--surface-muted)}
+    .learning-content dd{font-weight:600}.homework-content{border-left:3px solid var(--brand-strong)}
+    @media(min-width:600px){.feedback-content{grid-template-columns:1fr 1fr;gap:20px}.learning-content,.homework-content{grid-column:1/-1}}
+  `],
+})
+export class LessonFeedbackComponent { readonly feedback = input.required<Record<string, unknown>>(); }

+ 44 - 0
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.html

@@ -0,0 +1,44 @@
+<section class="lesson-report" [attr.aria-busy]="loading()">
+  @if (error()) { <div class="load-error" role="alert"><p>{{ error() }}</p><button type="button" (click)="load()" [disabled]="loading()">重新加载</button></div> }
+  @if (report(); as r) {
+    <header class="report-hero">
+      <div class="hero-top"><span>本次课堂报告</span><button type="button" aria-label="刷新学习报告" (click)="load()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="18" /></button></div>
+      <h1>{{ r.order['kcmc'] || r.order['yykcmc'] || '课程学习报告' }}</h1>
+      <p>{{ r.order['HoneyName'] || '学员' }}<span aria-hidden="true"> · </span>{{ dateLabel(r.order['yysj']) }}</p>
+      @if (r.order['cancelled']) { <span class="status-badge">此预约已取消</span> }
+      @else { <span class="status-badge">{{ r.feedback ? '已结课' : '反馈待提交' }}</span> }
+    </header>
+    <div class="learning-stats" aria-label="本次学习统计">
+      <div><strong>{{ count('learned') }}</strong><span>学习单词</span></div>
+      <div><strong>{{ count('ygg') }}</strong><span>已掌握</span></div>
+      <div><strong>{{ r.antiForget.completed }}<small> / {{ r.antiForget.total }}</small></strong><span>抗遗忘完成</span></div>
+    </div>
+    <section class="report-card">
+      <header><h2><lucide-icon [img]="icons.ClipboardList" [size]="19" aria-hidden="true" />完课反馈</h2>
+        @if ($any(r.order['allowedActions'])?.includes('editFeedback')) { <a class="edit-link" routerLink="/pages/shop/get_order" [queryParams]="{id:orderId,editReport:1}">修改报告</a> }
+      </header>
+      @if (r.feedback; as f) { <app-lesson-feedback [feedback]="f" /> }
+      @else { <p class="empty-copy">老师尚未提交反馈,提交后将在这里展示。</p> }
+    </section>
+    <section class="report-card">
+      <header><h2><lucide-icon [img]="icons.BookOpen" [size]="19" aria-hidden="true" />学习记录</h2><span>{{ r.records.length }} 条记录</span></header>
+      <div class="study-records">
+        @for (item of r.records; track $index) {
+          <article><time>{{ dateLabel(item['dqrq']) }}</time><p>{{ item['con'] || '单词学习' }}</p><div><span>已学 <b>{{ item['learned'] || 0 }}</b> 词</span><span>已掌握 <b>{{ item['ygg'] || 0 }}</b> 词</span><span>待加强 <b>{{ item['djq'] || 0 }}</b> 词</span></div></article>
+        } @empty { <p class="empty-copy">暂无本次课程的单词学习记录</p> }
+      </div>
+    </section>
+    <section class="report-card">
+      <header><h2><lucide-icon [img]="icons.Star" [size]="19" aria-hidden="true" />学生评价</h2></header>
+      @if (r.order['studentReview']; as review) { <p class="rating">{{ $any(review).score }}<small> / 5 分</small></p><p class="review-comment">{{ $any(review).comment || '学员未填写文字评价' }}</p> }
+      @else { <p class="empty-copy">等待学员评价本次课程</p> }
+    </section>
+    <section class="report-card">
+      <header><h2><lucide-icon [img]="icons.BrainCircuit" [size]="19" aria-hidden="true" />抗遗忘计划</h2><span>{{ reviewProgress() }}%</span></header>
+      <div class="review-track" role="progressbar" aria-label="抗遗忘计划完成进度" [attr.aria-valuenow]="reviewProgress()" aria-valuemin="0" aria-valuemax="100"><span [style.width.%]="reviewProgress()"></span></div>
+      <p class="empty-copy">{{ r.antiForget.total ? '已完成 ' + r.antiForget.completed + ' 项,共 ' + r.antiForget.total + ' 项复习任务' : '本次课程暂无抗遗忘任务' }}</p>
+    </section>
+    <p class="report-updated">数据更新于 {{ dateLabel(r.refreshedAt) }}</p>
+  } @else if (loading()) { <div class="loading-state" role="status">正在整理本次学习报告…</div> }
+  <footer><a routerLink="/pages/his/his">查看学习记录</a></footer>
+</section>

+ 23 - 0
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.scss

@@ -0,0 +1,23 @@
+:host { display:block; }
+.lesson-report { max-width:760px; margin:auto; padding:16px var(--page-gutter,16px) max(24px,env(safe-area-inset-bottom)); color:var(--ink); }
+.report-hero { padding:20px; border-radius:18px; background:var(--brand-strong); color:white; }
+.hero-top { display:flex; justify-content:space-between; align-items:center; gap:12px; font-size:12px; }
+.hero-top button { display:grid; place-items:center; width:44px; height:44px; margin:-8px -8px -4px 0; border:1px solid rgb(255 255 255 / 40%); border-radius:12px; color:white; background:transparent; }
+h1 { margin:14px 0 10px; font-size:22px; line-height:1.45; overflow-wrap:anywhere; }
+.report-hero p { margin:0 0 16px; font-size:13px; line-height:1.7; }
+.status-badge { display:inline-block; padding:5px 10px; background:rgb(255 255 255 / 16%); border-radius:6px; font-size:12px; }
+.learning-stats { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); margin:12px 0 20px; padding:18px 0; border:1px solid var(--line); border-radius:16px; background:white; }
+.learning-stats>div { display:grid; text-align:center; gap:6px; border-right:1px solid var(--line); }
+.learning-stats>div:last-child { border:0; }.learning-stats strong { color:var(--brand-strong); font-size:24px; }.learning-stats small { font-size:12px; font-weight:400; }.learning-stats span { font-size:12px; color:var(--ink-soft); }
+.report-card { padding:20px; margin-top:14px; border:1px solid var(--line); border-radius:16px; background:white; }
+.report-card header { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px; margin-bottom:18px; }
+h2 { display:flex; align-items:center; gap:8px; margin:0; font-size:17px; }.report-card header>span { color:var(--ink-soft); font-size:12px; }
+h2 lucide-icon { color:var(--brand-strong); }.edit-link { display:inline-flex; min-height:44px; align-items:center; padding:6px 12px; background:var(--surface-muted); border-radius:10px; color:var(--brand-strong); font-size:13px; text-decoration:none; font-weight:600; }
+.empty-copy { color:var(--ink-soft); font-size:13px; line-height:1.7; margin:10px 0 0; }
+.study-records article+article { border-top:1px solid var(--line); margin-top:16px; padding-top:16px; }.study-records time { color:var(--ink-soft); font-size:12px; }.study-records p { font-size:14px; line-height:1.6; margin:6px 0 10px; overflow-wrap:anywhere; }.study-records article>div { display:flex; flex-wrap:wrap; gap:8px 16px; color:var(--ink-soft); font-size:12px; }.study-records b { color:var(--brand-strong); }
+.rating { color:var(--brand-strong); font-size:26px; font-weight:700; margin:0 0 8px; }.rating small { color:var(--ink-soft); font-size:13px; font-weight:400; }.review-comment { margin:0; font-size:15px; line-height:1.7; white-space:pre-wrap; overflow-wrap:anywhere; }
+.review-track { height:6px; border-radius:4px; background:var(--line); overflow:hidden; }.review-track span { display:block; height:100%; background:var(--brand-strong); }
+.report-updated { margin:20px 0; text-align:center; color:var(--ink-soft); font-size:12px; }
+footer a { display:flex; align-items:center; justify-content:center; min-height:48px; margin-top:16px; border:1px solid var(--line); border-radius:12px; background:white; color:var(--brand-strong); text-decoration:none; font-weight:600; font-size:14px; }
+.load-error,.loading-state { padding:24px; line-height:1.7; }.load-error { color:#a63428; }.load-error button { min-height:44px; }button:disabled { opacity:.6; }a:focus-visible,button:focus-visible { outline:2px solid var(--brand-strong); outline-offset:3px; }button:active,a:active { opacity:.8; }
+@media(max-width:420px) { .report-card,.report-hero { padding:16px; } h1 { font-size:20px; } }

+ 42 - 6
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.ts

@@ -1,11 +1,47 @@
 import { CommonModule } from '@angular/common';
-import { Component, inject, OnInit, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
 import { ActivatedRoute, RouterLink } from '@angular/router';
+import { BookOpen, BrainCircuit, ClipboardList, LucideAngularModule, RefreshCw, Star } from 'lucide-angular';
 import { finalize } from 'rxjs';
 import { ApiService } from '../../core/api.service';
-interface Report {order:Record<string,unknown>;feedback:Record<string,unknown>|null;records:Array<Record<string,unknown>>;antiForget:{total:number;completed:number};refreshedAt:string}
-@Component({standalone:true,imports:[CommonModule,RouterLink],template:`<section class="lesson-report"><header><h2>学习报表</h2><p>课程学习、完课反馈与抗遗忘进度</p></header>@if(error()){<p class="error" role="alert">{{error()}}</p><button (click)="load()">重新加载</button>}@if(report();as r){<article><h3>{{r.order['kcmc']}}</h3><p>{{r.order['HoneyName']}} · {{r.order['yysj']}}</p>@if(r.order['cancelled']){<p>此预约已取消</p>}</article><article><h3>完课反馈</h3>@if(r.feedback;as f){<p><b>学习内容:</b>{{f['contentSummary']}}</p><p><b>复习情况:</b>{{f['reviewSummary']||'未补充'}}</p><p><b>训练进度:</b>{{f['trainingSummary']}}</p><p><b>老师点评:</b>{{f['coachComment']||'未补充'}}</p><p><b>作业:</b>{{f['homework']||'无作业'}}</p>}@else{<p>尚未提交完课反馈</p>}</article><article><h3>学习记录</h3>@for(item of r.records;track item['generalId']){<p>{{item['dqrq']}} · 已学 {{item['learned']}} 个单词 · 已掌握 {{item['ygg']}}</p>}@empty{<p>暂无本次课程的单词学习记录</p>}</article><article><h3>学生评价</h3>@if(r.order['studentReview'];as review){<p>{{$any(review).score}} 分 · {{$any(review).comment||'无文字评价'}}</p>}@else{<p>尚未评价</p>}</article><article><h3>抗遗忘计划</h3><p>已完成 {{r.antiForget.completed}} / {{r.antiForget.total}} 项</p></article><small>更新时间 {{r.refreshedAt|date:'yyyy-MM-dd HH:mm':'+0800'}}</small>}@else if(loading()){<p>正在加载…</p>}<footer><a routerLink="/pages/his/his">查看学习记录</a></footer></section>`,styles:[`.lesson-report{max-width:1000px;margin:24px auto;padding:0 20px;line-height:1.8}.lesson-report article{padding:24px;margin:16px 0;border:1px solid var(--line);border-radius:18px;background:white}.error{color:#af3328}`]})
-export class LessonReportComponent implements OnInit{
- readonly report=signal<Report|null>(null);readonly loading=signal(false);readonly error=signal('');private readonly api=inject(ApiService);private readonly route=inject(ActivatedRoute);
- ngOnInit():void{this.load();}load():void{const id=this.route.snapshot.queryParamMap.get('sid')||this.route.snapshot.queryParamMap.get('id');if(!id){this.error.set('缺少预约编号,请从课程详情进入');return;}this.loading.set(true);this.error.set('');this.api.get<Report>('app_lesson_report',{id}).pipe(finalize(()=>this.loading.set(false))).subscribe({next:r=>this.report.set(r.result),error:e=>this.error.set(e.message)});}
+import { LessonFeedbackComponent } from './lesson-feedback.component';
+import { lessonDateLabel } from './lesson-display';
+
+interface Report {
+  order: Record<string, unknown>;
+  feedback: Record<string, unknown> | null;
+  records: Array<Record<string, unknown>>;
+  antiForget: {total: number; completed: number};
+  refreshedAt: string;
+}
+@Component({
+  standalone: true, imports: [CommonModule, RouterLink, LucideAngularModule, LessonFeedbackComponent],
+  templateUrl: './lesson-report.component.html', styleUrl: './lesson-report.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class LessonReportComponent implements OnInit {
+  readonly report = signal<Report | null>(null);
+  readonly loading = signal(false);
+  readonly error = signal('');
+  readonly icons = {BookOpen, BrainCircuit, ClipboardList, RefreshCw, Star};
+  readonly dateLabel = lessonDateLabel;
+  private readonly api = inject(ApiService);
+  private readonly route = inject(ActivatedRoute);
+  get orderId(): string { return this.route.snapshot.queryParamMap.get('sid') || this.route.snapshot.queryParamMap.get('id') || ''; }
+  ngOnInit(): void { this.load(); }
+  load(): void {
+    if (this.loading()) return;
+    if (!this.orderId) { this.error.set('缺少预约编号,请从课程详情进入'); return; }
+    this.loading.set(true); this.error.set('');
+    this.api.get<Report>('app_lesson_report', {id:this.orderId}).pipe(finalize(() => this.loading.set(false))).subscribe({
+      next: r => this.report.set(r.result), error: e => this.error.set(e.message),
+    });
+  }
+  count(field: 'learned' | 'ygg'): number {
+    return (this.report()?.records || []).reduce((sum, row) => sum + (Number(row[field]) || 0), 0);
+  }
+  reviewProgress(): number {
+    const review = this.report()?.antiForget;
+    return review?.total ? Math.min(100, Math.round(review.completed / review.total * 100)) : 0;
+  }
 }

+ 11 - 0
scripts/cloud/lesson-workflow.js

@@ -33,6 +33,16 @@ async function appLessonComplete(input, current) {
     fail(409, message);
   }
 }
+async function appLessonFeedbackUpdate(input, current) {
+  assertLessonTenant(current);
+  const id = String(input.orderId || '').trim(), key = String(input.requestKey || '');
+  if (!id || key.length < 10 || key.length > 160 || !input.expectedUpdatedAt || Number.isNaN(Date.parse(input.expectedUpdatedAt))) fail(400, '缺少报告版本,请刷新详情后重试');
+  const feedback = lessonFeedbackInput(input);
+  try {
+    return (await Psql.one('SELECT xs_lesson_feedback_update($1,$2,$3,$4,$5::timestamptz,$6,$7::jsonb) AS result',
+      [DEFAULT_COMPANY_ID,id,current.id,ownLegacyId(current),input.expectedUpdatedAt,key,JSON.stringify(feedback)])).result;
+  } catch (error) { fail(409, String(error.message || error)); }
+}
 async function appLessonReview(input, current) {
   assertLessonTenant(current);
   const score = Number(input.score), comment = String(input.comment || '').trim(), id = String(input.orderId || '');
@@ -63,6 +73,7 @@ async function lessonOrderCapabilities(order, current) {
     else if (status === 0) actions.push('start');
     if (status === 10) actions.push('continue','complete');
     if ([11,20].includes(status)) actions.push('complete');
+    if ([30,40].includes(status) && await Psql.oneOrNone('SELECT \"objectId\" FROM \"LessonCompletionReport\" WHERE company=$1 AND \"appointmentGeneralId\"::text=$2 LIMIT 1',[DEFAULT_COMPANY_ID,id])) actions.push('editFeedback');
   }
   const reviewed = Boolean(review || number(lesson && lesson.pf) > 0);
   if (!order.cancelled && student && [30,40].includes(status) && !reviewed) actions.push('review');

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
scripts/deploy-admin-functions.mjs


+ 10 - 0
scripts/smoke-mobile-teaching-flow.mjs

@@ -67,6 +67,16 @@ try{
  const firstReview=(await call('xiaoshu/app/gateway',studentToken,review)).data;
  const repeatedReview=(await call('xiaoshu/app/gateway',studentToken,{...review,score:1,comment:'不能覆盖首次评价'})).data;
  expect(Number(firstReview.score)===5&&Number(repeatedReview.score)===5,'评价仅首次生效');
+ const editableDetail=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_detail',id:generalId})).data[0];
+ expect(editableDetail.allowedActions.includes('editFeedback')&&!!editableDetail.feedback.revision,'已结课老师获得修改权限和精确报告版本');
+ const amendment={action:'app_lesson_feedback_update',orderId:generalId,expectedUpdatedAt:editableDetail.feedback.revision,requestKey:randomUUID(),feedback:{...feedback.feedback,contentSummary:'隔离验收修改后的学习内容',coachComment:'修改后的点评',reviewStatus:'',trainingStatus:''}};
+ const deniedEdit=await call('xiaoshu/app/gateway',studentToken,amendment,true);expect(deniedEdit.status===403||deniedEdit.status===409,'学生不能修改老师报告');
+ const amended=await call('xiaoshu/app/gateway',coach.sessionToken,amendment);const amendedRetry=await call('xiaoshu/app/gateway',coach.sessionToken,amendment);
+ expect(amended.data.saved&&amendedRetry.data.saved,'老师修改保存且重试幂等');
+ const staleEdit=await call('xiaoshu/app/gateway',coach.sessionToken,{...amendment,requestKey:randomUUID()},true);expect(staleEdit.status===409,'旧报告版本拒绝覆盖新内容');
+ const amendedReport=(await call('xiaoshu/app/gateway',studentToken,{action:'app_lesson_report',id:generalId})).data;
+ expect(amendedReport.feedback.contentSummary==='隔离验收修改后的学习内容'&&amendedReport.feedback.coachComment==='修改后的点评','学生报告回读更新后的反馈');
+ expect(Number(amendedReport.feedback.vocabularyCount)===1&&Number(amendedReport.order.studentReview.score)===5&&Number(amendedReport.order.dszt)===30,'修改保留学习成果、学生评价和结课状态');
  const deniedReview=await call('xiaoshu/app/gateway',empty.sessionToken,review,true);expect(deniedReview.status===403||deniedReview.status===409,'其他学生不能评价');
  const oldWrite=await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_update_v2',status:11,content:JSON.stringify({GeneralID:generalId})},true);expect(oldWrite.status===409,'旧客户端不能回退结课状态');
  const trace=(await ops('ops/lesson-feedback/detail',{targetId:appointment})).data;

+ 43 - 0
scripts/sql/lesson-workflow.sql

@@ -187,3 +187,46 @@ BEGIN
  RETURN result;
 END $$;
 REVOKE ALL ON FUNCTION xs_admin_lesson_correction(text,text,text,text) FROM PUBLIC;
+
+-- Feedback-only amendment: never re-run completion, credit or learning projections.
+CREATE OR REPLACE FUNCTION xs_lesson_feedback_update(co text,gid text,actor text,actor_uid numeric,expected timestamptz,request_key text,feedback jsonb)
+RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE a "CourseAppointment"; c "CommonModel"; report "LessonCompletionReport";
+ previous "LessonWorkflowCommand"; result jsonb; old_feedback jsonb;
+BEGIN
+ IF request_key IS NULL OR length(request_key)<10 OR length(request_key)>160 THEN RAISE EXCEPTION '缺少有效请求编号';END IF;
+ IF NOT EXISTS(SELECT 1 FROM "_User" WHERE "objectId"=actor AND company=co AND "legacyUserId"=actor_uid AND "legacyGroupId"=3 AND NOT COALESCE("isDisabled",false)) THEN RAISE EXCEPTION '用户身份已失效';END IF;
+ IF NULLIF(trim(feedback->>'contentSummary'),'') IS NULL OR NULLIF(trim(feedback->>'trainingSummary'),'') IS NULL
+ OR (NOT COALESCE((feedback->>'noHomework')::boolean,false) AND NULLIF(trim(feedback->>'homework'),'') IS NULL)
+ OR EXISTS(SELECT 1 FROM jsonb_each_text(feedback) f WHERE length(f.value)>CASE WHEN f.key IN ('reviewStatus','trainingStatus') THEN 200 ELSE 1000 END)
+ THEN RAISE EXCEPTION '请完整填写学习内容、进度说明和作业,并检查文字长度';END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
+ SELECT * INTO c FROM "CommonModel" WHERE company=co AND "modelId"=54 AND "generalId"::text=gid FOR UPDATE;
+ SELECT * INTO a FROM "CourseAppointment" WHERE company=co AND id=c."itemId" FOR UPDATE;
+ IF a."objectId" IS NULL OR c.status=-2 THEN RAISE EXCEPTION '预约不存在或已取消';END IF;
+ IF a.pl::text IS DISTINCT FROM actor_uid::text THEN RAISE EXCEPTION '仅预约老师可以修改报告';END IF;
+ IF a.dszt NOT IN ('30','40') THEN RAISE EXCEPTION '请先完成课程再修改报告';END IF;
+ SELECT * INTO previous FROM "LessonWorkflowCommand" WHERE company=co AND "LessonWorkflowCommand".actor=xs_lesson_feedback_update.actor AND "requestKey"=request_key;
+ IF FOUND THEN
+  IF previous.fingerprint<>jsonb_build_object('action','feedback_update','id',gid,'feedback',feedback) THEN RAISE EXCEPTION '请求编号已用于其他内容';END IF;
+  RETURN previous.result - 'previousFeedback';
+ END IF;
+ IF (SELECT count(*) FROM "LessonCompletionReport" WHERE company=co AND "appointmentGeneralId"::text=gid)<>1 THEN RAISE EXCEPTION '报告不存在或不唯一,请联系管理员核对';END IF;
+ SELECT * INTO report FROM "LessonCompletionReport" WHERE company=co AND "appointmentGeneralId"::text=gid FOR UPDATE;
+ IF expected IS NULL OR report."updatedAt" IS DISTINCT FROM expected THEN RAISE EXCEPTION '报告已被更新,请关闭编辑并刷新详情后重试';END IF;
+ SELECT jsonb_object_agg(key,value) INTO old_feedback FROM jsonb_each(to_jsonb(report)) WHERE key IN ('contentSummary','reviewStatus','reviewSummary','trainingStatus','trainingSummary','homework','noHomework','coachComment','notes');
+ UPDATE "LessonCompletionReport" SET
+ "contentSummary"=feedback->>'contentSummary',"reviewStatus"=feedback->>'reviewStatus',"reviewSummary"=feedback->>'reviewSummary',
+ "trainingStatus"=feedback->>'trainingStatus',"trainingSummary"=feedback->>'trainingSummary',
+ homework=CASE WHEN COALESCE((feedback->>'noHomework')::boolean,false) THEN '无作业' ELSE feedback->>'homework' END,
+ "noHomework"=COALESCE((feedback->>'noHomework')::boolean,false),"coachComment"=feedback->>'coachComment',notes=feedback->>'notes',"updatedAt"=clock_timestamp()
+ WHERE "objectId"=report."objectId";
+ UPDATE "LessonRecord" SET pldp=COALESCE(feedback->>'coachComment',''),plpjsj=now()::text,"updatedAt"=now()
+ WHERE company=co AND "objectId"=report."lessonId" AND yyds::text=gid;
+ UPDATE "CourseAppointment" SET "updatedAt"=now() WHERE "objectId"=a."objectId";
+ UPDATE "CommonModel" SET "updatedAt"=now() WHERE "objectId"=c."objectId";
+ result=jsonb_build_object('GeneralID',gid,'feedbackId',report."objectId",'saved',true,'previousFeedback',old_feedback);
+ INSERT INTO "LessonWorkflowCommand" VALUES(co,actor,request_key,jsonb_build_object('action','feedback_update','id',gid,'feedback',feedback),result,now());
+ RETURN result - 'previousFeedback';
+END $$;
+REVOKE ALL ON FUNCTION xs_lesson_feedback_update(text,text,text,numeric,timestamptz,text,jsonb) FROM PUBLIC;

+ 21 - 0
scripts/tests/lesson-workflow.test.mjs

@@ -206,3 +206,24 @@ test('选填复习情况和训练状态允许留空或自由文本,报告保
  await assert.rejects(complete(a.gid,a.updatedAt,'missing-progress-'+a.gid,'teacher',30,missing),/进度说明/);
  assert.equal(await balance(),before);
 });
+
+test('修改报告只更新文字;重试幂等、旧版本和越权拒绝,保留课时、学习成果与评价',async()=>{
+ const a=await appointment();await complete(a.gid,a.updatedAt,'edit-setup-request');await review(a.gid,4,'学生原评价');
+ const report=await one('SELECT *,"updatedAt"::text revision FROM "LessonCompletionReport" WHERE "appointmentGeneralId"::text=$1',[a.gid]);
+ const before=await balance();
+ const lesson=await one('SELECT * FROM "LessonRecord" WHERE yyds=$1',[a.gid]);
+ const edit=async(version,key='edit-feedback-request',actor='teacher',uid=30,f={...feedback,contentSummary:'修改后的课本内容',reviewStatus:'',trainingStatus:'',coachComment:'新的点评'})=>(await one('SELECT xs_lesson_feedback_update($1,$2,$3,$4,$5,$6,$7::jsonb) result',['co',a.gid,actor,uid,version,key,JSON.stringify(f)])).result;
+ await assert.rejects(edit(report.revision,'edit-denied-request','other-teacher',31),/仅预约老师/);
+ await assert.rejects(edit(report.revision,'edit-student-request','student',20),/身份/);
+ assert.equal((await edit(report.revision)).saved,true);
+ assert.equal((await edit(report.revision)).saved,true);
+ await assert.rejects(edit(report.revision,'edit-stale-request'),/已被更新/);
+ const updated=await one('SELECT * FROM "LessonCompletionReport" WHERE "objectId"=$1',[report.objectId]);
+ for(const key of ['vocabularyCount','creditCosts','actualStartAt','actualEndAt','durationMinutes','submittedAt','lessonId'])assert.deepEqual(updated[key],report[key],key);
+ assert.equal(updated.contentSummary,'修改后的课本内容');assert.equal(updated.reviewStatus,'');
+ const afterLesson=await one('SELECT * FROM "LessonRecord" WHERE yyds=$1',[a.gid]);
+ assert.equal(afterLesson.kzsj,lesson.kzsj);assert.equal(afterLesson.pf,lesson.pf);assert.equal(afterLesson.pjnr,lesson.pjnr);assert.equal(afterLesson.pldp,'新的点评');assert.equal(await balance(),before);
+ assert.equal((await one('SELECT dszt FROM "CourseAppointment" WHERE "objectId"=$1',[a.objectId])).dszt,'30');
+ const audit=await one('SELECT result FROM "LessonWorkflowCommand" WHERE "requestKey"=$1',['edit-feedback-request']);assert.equal(audit.result.previousFeedback.contentSummary,feedback.contentSummary);
+ await db.query('UPDATE "CommonModel" SET status=-2 WHERE "objectId"=$1',[a.common]);await assert.rejects(edit(updated.updatedAt,'edit-cancel-request'),/已取消/);
+});

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно