Explorar o código

修复生词串选并完善写作课闭环、排课间隔和课堂附件

彭峰 hai 3 días
pai
achega
070716b955
Modificáronse 22 ficheiros con 574 adicións e 30 borrados
  1. 1 0
      projects/xiaoshu-admin/src/app/pages/lesson-record-detail.component.html
  2. 2 1
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.html
  3. 11 0
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.spec.ts
  4. 7 0
      projects/xiaoshu-mobile/src/app/core/api.service.ts
  5. 80 0
      projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts
  6. 30 0
      projects/xiaoshu-mobile/src/app/core/lesson-attachments.ts
  7. 65 2
      projects/xiaoshu-mobile/src/app/core/lesson-workflow.integration.spec.ts
  8. 28 4
      projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.html
  9. 10 0
      projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.scss
  10. 46 10
      projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts
  11. 17 2
      projects/xiaoshu-mobile/src/app/features/appointment/lesson-feedback.component.ts
  12. 7 1
      projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts
  13. 61 0
      scripts/check-lesson-attachments-browser.mjs
  14. 48 0
      scripts/cloud/lesson-attachments.js
  15. 6 0
      scripts/cloud/lesson-workflow.js
  16. 1 0
      scripts/deploy-admin-functions.mjs
  17. 8 3
      scripts/migrate-lesson-workflow.mjs
  18. 12 4
      scripts/sql/lesson-workflow.sql
  19. 59 0
      scripts/tests/lesson-attachments.test.mjs
  20. 2 1
      scripts/tests/lesson-course-selection.test.mjs
  21. 49 2
      scripts/tests/lesson-workflow.test.mjs
  22. 24 0
      scripts/tests/member-enrollment-gateway.test.mjs

+ 1 - 0
projects/xiaoshu-admin/src/app/pages/lesson-record-detail.component.html

@@ -13,6 +13,7 @@
 @if(d.hasReport){<article class="wide"><h3>老师点评</h3><p>{{d.coachComment||'未记录'}}</p></article>}
 <article class="wide"><h3>学生评价</h3><p>{{d.studentReview.score ? d.studentReview.score+' 分' : '尚未评分'}} · {{d.studentReview.comment||'暂无文字评价'}}</p></article>
 <article class="wide"><h3>备注</h3><p>{{report('notes')}}</p></article>
+@if($any(d.report)?.attachments?.length){<article class="wide"><h3>课堂照片与文件</h3>@for(file of $any(d.report).attachments;track file.id){<p><a [href]="file.url" target="_blank" rel="noopener noreferrer" [attr.download]="file.name">{{file.name}}</a></p>}</article>}
 </div>
 <section class="review-section"><header><h3>抗遗忘复习安排</h3>@if(d.canTransfer){<button class="ui-button secondary" [disabled]="busy()" (click)="beginTransfer()">更换抗遗忘陪练</button>}</header><p class="muted">已完成任务和费用归属保留原记录。</p>
 <div class="table-scroll"><table class="data-table review-table"><thead><tr><th>计划日期</th><th>复习陪练</th><th>状态</th></tr></thead><tbody>@for(t of d.tasks;track t.objectId){<tr><td>{{t.plannedDate}}</td><td>{{t.coachName}}</td><td>{{t.status==='1'?'已完成':'未完成'}}</td></tr>}@empty{<tr><td colspan="3">本课暂无关联复习任务</td></tr>}</tbody></table></div>

+ 2 - 1
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.html

@@ -202,7 +202,7 @@
         }
       </label>
       @if(!editing()){
-        <label class="recurrence-step">重复方式<select [ngModel]="recurrence()" (ngModelChange)="recurrence.set($event);preview.set(null)"><option value="once">单次</option><option value="daily">每天</option><option value="alternate">隔天</option><option value="weekly">每周</option><option value="same_day_30">同日间隔30分钟</option><option value="same_day_60">同日间隔60分钟</option></select></label>
+        <label class="recurrence-step">间隔时间<select aria-label="间隔时间" [ngModel]="recurrence()" (ngModelChange)="recurrence.set($event);preview.set(null)"><option value="once">单次</option><option value="same_day_gap_0">同一天(间隔0分钟)</option><option value="same_day_gap_30">同一天(间隔30分钟)</option><option value="daily">每天</option><option value="interval_1">每隔1天</option><option value="interval_2">每隔2天</option><option value="interval_3">每隔3天</option><option value="interval_4">每隔4天</option><option value="interval_5">每隔5天</option><option value="weekly">每隔一周</option></select></label>
         <label class="occurrences-step">排课次数<input type="number" min="1" max="60" [ngModel]="occurrences()" (ngModelChange)="occurrences.set(+$event);preview.set(null)" /></label>
       }
       <label class="wide reason-step">操作原因<textarea rows="2" [ngModel]="reason()" (ngModelChange)="reason.set($event)"></textarea></label>
@@ -223,6 +223,7 @@
     <header><div><p>LESSON FEEDBACK</p><h2>完课反馈</h2><span>预约 {{report['appointmentGeneralId']}} · {{report['courseCategoryName']||'历史课程'}}</span></div><button class="icon-button" type="button" aria-label="关闭" (click)="closeFeedback()"><lucide-icon [img]="icons.X" [size]="20" /></button></header>
     <section class="feedback-facts"><article><span>计划训练</span><strong>{{report['plannedAt']}}</strong></article><article><span>实际训练</span><strong>{{$any(report['actualStartAt'])|date:'yyyy-MM-dd HH:mm'}} – {{$any(report['actualEndAt'])|date:'HH:mm'}}</strong></article><article><span>课程标准</span><strong>{{report['courseCategoryName']||'未映射'}} · {{report['durationMinutes']||'—'}}分钟 · {{report['deliveryLabel']}}</strong></article><article><span>生词总量</span><strong>{{report['vocabularyCount']||0}}</strong></article></section>
     <section class="feedback-sections"><article><span>预约编号</span><p>{{report['appointmentGeneralId']}}</p></article><article><span>课时处理</span><p>{{report['creditState']}} · 有效记录 {{report['lessonCount']}} 条</p></article><article><span>学生评价</span><p>{{$any(report['studentReview'])?.score ? $any(report['studentReview']).score+' 分' : '尚未评价'}} {{$any(report['studentReview'])?.comment}}</p></article>@if(report['issue']){<article role="alert"><span>待核对异常</span><p>{{report['issue']}}</p></article>}@if(!report['hasReport']){<article><p>尚未生成完课报告</p></article>}<article><span>1. 学习内容</span><p>{{report['contentSummary']||'—'}}</p></article><article><span>2. 复习 / 生词本</span><strong>{{report['reviewStatus']}}</strong><p>{{report['reviewSummary']||'无补充说明'}}</p></article><article><span>3. 训练进程</span><strong>{{report['trainingStatus']}}</strong><p>{{report['trainingSummary']}}</p></article><article><span>4. 当日抗遗忘</span><p>应复习 {{$any(report['antiForgetSummary'])?.total||0}} · 已完成 {{$any(report['antiForgetSummary'])?.completed||0}} · 待完成 {{$any(report['antiForgetSummary'])?.pending||0}}</p></article><article><span>5. 作业安排</span><p>{{report['homework']||'无作业'}}</p></article><article><span>老师点评</span><p>{{report['coachComment']||'无'}}</p></article><article><span>6. 备注</span><p>{{report['notes']||'无'}}</p></article></section>
+    @if($any(report['attachments'])?.length){<section class="feedback-sections"><article><span>课堂照片与文件</span>@for(file of $any(report['attachments']);track file.id){<p><a [href]="file.url" target="_blank" rel="noopener noreferrer" [attr.download]="file.name">{{file.name}}</a></p>}</article></section>}
   </aside>
 }
 

+ 11 - 0
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.spec.ts

@@ -353,6 +353,17 @@ describe('排课完整课型选择与教材联动',()=>{
     page.openCreate();page.studentId.set(20);page.selectedMemberObjectId.set('member');page.selectStandard('word:30');
   });
   afterEach(()=>fixture.destroy());
+  for(const mode of ['coach','time'] as const)it(`${mode}排课按截图展示间隔选项,切换后清除旧预检并传入所选间隔`,async()=>{
+    page.mode.set(mode);page.occurrences.set(3);fixture.detectChanges();await fixture.whenStable();
+    const select=fixture.nativeElement.querySelector('select[aria-label="间隔时间"]') as HTMLSelectElement;
+    expect(Array.from(select.options,option=>option.textContent)).toEqual(['单次','同一天(间隔0分钟)','同一天(间隔30分钟)','每天','每隔1天','每隔2天','每隔3天','每隔4天','每隔5天','每隔一周']);
+    for(const option of Array.from(select.options)){
+      page.preview.set({availableCount:3,conflictCount:0});
+      select.value=option.value;select.dispatchEvent(new Event('change'));fixture.detectChanges();
+      expect(page.preview()).toBeNull();expect(page.recurrence()).toBe(option.value);
+      await page.preflight();expect(request.calls.mostRecent().args[1].payload).toEqual(jasmine.objectContaining({recurrence:option.value,occurrences:3}));
+    }
+  });
   it('显示截图中全部十种标准,包括未开课的分类',()=>{
     fixture.detectChanges();
     const options=Array.from(fixture.nativeElement.querySelectorAll('select[aria-label="课型与时长"] option')) as HTMLOptionElement[];

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

@@ -6,6 +6,7 @@ import { API_CONFIG } from './app.constants';
 import { requiresCloudSession } from './cloud-action-migration';
 import { ApiEnvelope } from './models';
 import { SessionService } from './session.service';
+import { LessonAttachment } from './lesson-attachments';
 
 export class ApiError extends Error {
   constructor(message: string, readonly status = 0, readonly payload?: unknown) {
@@ -71,6 +72,12 @@ export class ApiService {
     }, this.parseSessionToken())));
   }
 
+  uploadLessonAttachment(orderId: string, file: File, contentType: string): Observable<ApiEnvelope<LessonAttachment>> {
+    return from(this.fileAsBase64(file)).pipe(switchMap(base64 => this.callCloud<LessonAttachment>('app_lesson_attachment_upload', {
+      orderId, fileName: file.name, contentType, base64,
+    }, this.parseSessionToken())));
+  }
+
   assetUrl(path: unknown): string {
     const value = String(path ?? '').trim();
     if (!value) return `${API_CONFIG.baseUrl}/UploadFiles/nopic.svg`;

+ 80 - 0
projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts

@@ -760,6 +760,86 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelector('.pending-column .word-select-list > button.selected')).not.toBeNull();
   });
 
+  for (const legacy of [false, true]) {
+    it(`selects wordbook words independently when practice IDs repeat (${legacy ? 'legacy' : 'paged'} response)`, async () => {
+      const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+      const get = api.get.bind(api);
+      const pending = Array.from({ length: 35 }, (_, index) => ({
+        GeneralID: 601,
+        scid: 701 + index,
+        xxcs: 1,
+        detail: [{ GeneralID: 701 + index, Title: `word-${index + 1}`, yp: 'word.mp3' }],
+      }));
+      const completed = [{ GeneralID: 601, scid: 801, xxcs: 2, detail: [{ GeneralID: 801, Title: 'done' }] }];
+      spyOn(api, 'get').and.callFake((action, params) => {
+        if (action !== 'e_words_list') return get(action, params);
+        const rows = legacy ? [...pending, ...completed] : params?.['learningStatus'] === 'completed' ? completed : pending;
+        const page = Number(params?.['cpage']);
+        const size = Number(params?.['psize']);
+        return of({ retcode: 1, result: rows.slice((page - 1) * size, page * size), page: {
+          itemCount: rows.length, pageCount: Math.ceil(rows.length / size), pageIndex: page, pageSize: size,
+          ...(!legacy ? { pendingCount: pending.length, completedCount: completed.length } : {}),
+        } } as ApiEnvelope<any>);
+      });
+      const harness = await RouterTestingHarness.create();
+      await harness.navigateByUrl('/pages/member/starshop');
+      const view = () => harness.routeNativeElement!;
+      const rows = () => view().querySelectorAll<HTMLButtonElement>('.word-select-list > button');
+      const selected = () => Array.from(view().querySelectorAll('.word-select-list > button.selected strong'), node => node.textContent);
+      const click = (element: HTMLElement) => { element.click(); harness.fixture.detectChanges(); };
+      const expectCount = (count: number) => expect(view().querySelector('.selection-header strong')?.textContent).toContain(`已选 ${count} 个单词`);
+
+      click(rows()[3]);
+      expect(selected()).toEqual(['word-4']);
+      expectCount(1);
+      click(rows()[1]);
+      expect(selected()).toEqual(['word-2', 'word-4']);
+      click(rows()[3]);
+      expect(selected()).toEqual(['word-2']);
+      click(rows()[1].querySelector('.audio') as HTMLElement);
+      expect(selected()).toEqual(['word-2']);
+
+      click(view().querySelectorAll<HTMLButtonElement>('.mobile-pagination button')[1]);
+      click(rows()[0]);
+      expect(selected()).toEqual(['word-31']);
+      expectCount(2);
+      click(view().querySelectorAll<HTMLButtonElement>('.word-selection-tabs button')[1]);
+      expect(selected()).toEqual([]);
+      click(rows()[0]);
+      expectCount(3);
+      click(view().querySelectorAll<HTMLButtonElement>('.word-selection-tabs button')[0]);
+      expect(selected()).toEqual(['word-2']);
+      click(view().querySelector('.selection-header .primary-button') as HTMLButtonElement);
+      await harness.fixture.whenStable();
+      harness.fixture.detectChanges();
+      const learning = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+      expect(learning.words().map(word => word.word)).toEqual(['word-2', 'word-31', 'done']);
+      expect(new Set(learning.words().map(word => word.id)).size).toBe(3);
+      learning.leave();
+      await harness.fixture.whenStable();
+    });
+  }
+
+  it('deletes only the selected vocabulary ID when a wordbook detail uses scid as its identity', async () => {
+    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    const get = api.get.bind(api);
+    spyOn(api, 'get').and.callFake((action, params) => action === 'e_words_list'
+      ? of({ retcode: 1, result: [
+        { GeneralID: 601, scid: 701, xxcs: 1, detail: [{ GeneralID: 701, Title: 'first' }] },
+        { GeneralID: 601, scid: 702, xxcs: 1, detail: { Title: 'second' } },
+      ], page: { itemCount: 2, pendingCount: 2, completedCount: 0, pageCount: 1, pageIndex: 1, pageSize: 30 } } as ApiEnvelope<any>)
+      : get(action, params));
+    const post = spyOn(api, 'post').and.callThrough();
+    const harness = await RouterTestingHarness.create();
+    await harness.navigateByUrl('/pages/member/starshop');
+    const view = harness.routeNativeElement!;
+    view.querySelectorAll<HTMLButtonElement>('.word-select-list > button')[1].click();
+    harness.fixture.detectChanges();
+    expect(view.querySelectorAll('.word-select-list > button.selected').length).toBe(1);
+    (view.querySelector('.danger-button') as HTMLButtonElement).click();
+    expect(post).toHaveBeenCalledWith('e_add_words', jasmine.objectContaining({ uid: 100, wordsId: '702', ifnew: 0 }));
+  });
+
   it('pronounces a wordbook word on row click and only once on speaker click', async () => {
     const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
     const get = api.get.bind(api);

+ 30 - 0
projects/xiaoshu-mobile/src/app/core/lesson-attachments.ts

@@ -0,0 +1,30 @@
+export interface LessonAttachment {
+  id: string;
+  name: string;
+  url: string;
+  contentType: string;
+  size: number;
+}
+
+export const LESSON_ATTACHMENT_MAX_COUNT = 10;
+export const LESSON_ATTACHMENT_MAX_SIZE = 5 * 1024 * 1024;
+const TYPES: Record<string, string> = {
+  jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif',
+  pdf: 'application/pdf', doc: 'application/msword',
+  docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+  xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+  ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+  txt: 'text/plain',
+};
+export const LESSON_ATTACHMENT_ACCEPT = Object.keys(TYPES).map(extension => '.' + extension).join(',');
+
+export function lessonAttachmentContentType(file: File): string {
+  const expected = TYPES[file.name.split('.').pop()?.toLowerCase() || ''];
+  return expected && (!file.type || file.type === 'application/octet-stream' || file.type === expected) ? expected : '';
+}
+
+export function lessonAttachments(value: unknown): LessonAttachment[] {
+  return Array.isArray(value) ? value.filter((item): item is LessonAttachment => item && typeof item.id === 'string'
+    && typeof item.name === 'string' && typeof item.url === 'string' && /^https?:\/\//i.test(item.url)
+    && typeof item.contentType === 'string' && Number.isFinite(item.size)) : [];
+}

+ 65 - 2
projects/xiaoshu-mobile/src/app/core/lesson-workflow.integration.spec.ts

@@ -31,7 +31,7 @@ describe('appointment workflow screens',()=>{
    if(action==='app_lesson_unit_complete')return response({nodeId:params['nodeId'],total:300,learned:300,percent:100,syjd:JSON.stringify({[String(params['nodeId'])]:params['nodeId']+'@Unit@1'})});
    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_complete'){order={...order,dszt:30,allowedActions:['editFeedback'],reviewStatus:'pending',feedback:params['feedback']?{...JSON.parse(String(params['feedback'])),feedbackStatus:'submitted'}:{feedbackStatus:'pending',revision:'2026-10-13T00:00:00Z'}};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([]);
@@ -43,7 +43,7 @@ describe('appointment workflow screens',()=>{
     {path:'pages/shop/stu_get_order',component:AppointmentPageComponent,data:{meta:{path:'pages/shop/stu_get_order'}}},
     {path:'pages/stu/stu_abc',component:ClassroomStub},
    ]),
-   {provide:ApiService,useValue:{get:invoke,post:invoke}},
+   {provide:ApiService,useValue:{get:invoke,post:invoke,uploadLessonAttachment:(orderId:string,file:File,contentType:string)=>{calls.push({action:'attachment-upload',params:{orderId,file,contentType}});return response({id:'file-'+file.name,name:file.name,url:'https://files.example/'+encodeURIComponent(file.name),contentType,size:file.size});}}},
    {provide:SessionService,useValue:{user,student,groupId,token:signal('r:test'),isLoggedIn:()=>true,setStudent:(s:unknown)=>student.set(s)}},
    {provide:PlatformService,useValue:{loading:signal(false),toast:()=>{},confirm:async()=>true}},
   ]});
@@ -68,6 +68,35 @@ describe('appointment workflow screens',()=>{
   (harness.routeNativeElement?.querySelector('.appointment-actions .primary-button') as HTMLButtonElement).click();
   harness.fixture.detectChanges();expect(calls.filter(c=>c.action==='e_order_update_v2').length).toBe(1);expect(component.status()).toBe(10);
  });
+ for(const stage of ['primary','middle','high'])for(const duration of [30,60]) {
+  it(`closes a ${duration}-minute ${stage} writing lesson without enrollment or vocabulary before saving teacher feedback`,async()=>{
+   order={...order,kcid:0,bindingId:'',courseCategoryKey:stage+'_writing',durationMinutes:duration,yykcmc:'阅读与写作',bxrq:beijingDate()};
+   // A writing lesson must not depend on loading unrelated vocabulary enrollments.
+   failCourses=true;
+   const {harness,component}=await screen();
+   expect(component.isWriting).toBeTrue();
+   expect(harness.routeNativeElement?.querySelector('select[aria-label="本次学习课本或词库"]')).toBeNull();
+   const start=harness.routeNativeElement?.querySelector('.appointment-actions .primary-button') as HTMLButtonElement;
+   expect(start.disabled).toBeFalse();start.click();await harness.fixture.whenStable();harness.fixture.detectChanges();
+   expect(component.status()).toBe(10);expect(TestBed.inject(Router).url).toBe('/pages/shop/get_order?id=901');
+   const started=calls.filter(c=>c.action==='e_order_update_v2');expect(started.length).toBe(1);expect(started[0].params['learningBindingId']).toBeUndefined();
+   expect(calls.some(c=>['app_lesson_courses','node_list','app_lesson_study'].includes(c.action))).toBeFalse();
+   const end=Array.from(harness.routeNativeElement!.querySelectorAll<HTMLButtonElement>('.appointment-actions button')).find(button=>button.textContent?.includes('结束课程'))!;
+   expect(end.disabled).toBeFalse();end.click();await harness.fixture.whenStable();harness.fixture.detectChanges();
+   expect(component.status()).toBe(30);expect(component.can('editFeedback')).toBeTrue();
+   expect(calls.filter(c=>c.action==='app_lesson_complete').length).toBe(1);
+   expect(calls.find(c=>c.action==='app_lesson_complete')!.params['feedback']).toBeUndefined();
+   const edit=Array.from(harness.routeNativeElement!.querySelectorAll<HTMLButtonElement>('.appointment-actions button')).find(button=>button.textContent?.includes('补充反馈'))!;
+   edit.click();harness.fixture.detectChanges();
+   expect(component.completionDialog()?.nativeElement.open).toBeTrue();
+   component.contentSummary='阅读一篇短文,完成写作练习';component.trainingSummary='下次继续修改作文';component.coachComment='能主动表达观点';component.noHomework=true;
+   (harness.routeNativeElement!.querySelector('.feedback-actions .primary-button') as HTMLButtonElement).click();
+   await harness.fixture.whenStable();harness.fixture.detectChanges();
+   expect(component.status()).toBe(30);expect(component.feedback()?.['coachComment']).toBe('能主动表达观点');
+   expect(calls.filter(c=>c.action==='app_lesson_feedback_update').length).toBe(1);expect(calls.filter(c=>c.action==='app_lesson_complete').length).toBe(1);
+   expect(order['kcid']).toBe(0);expect(order['bindingId']).toBe('');
+  });
+ }
  for (const time of ['00:01', '23:59']) {
   it(`starts a same-day lesson scheduled at ${time} without new server capabilities`,async()=>{
    order={...order,bxrq:beijingDate(),sdsd:time};delete order['allowedActions'];
@@ -245,6 +274,40 @@ describe('appointment workflow screens',()=>{
   expect(component.feedbackError()).toContain('已被更新');expect(component.submitting()).toBeFalse();expect(component.feedbackSaved()).toBeFalse();
  });
 
+ for(const stage of ['primary','middle','high'])it(`uploads multiple classroom attachments and reopens saved ${stage} writing feedback`,async()=>{
+  order={...order,courseCategoryKey:stage+'_writing',dszt:30,allowedActions:['editFeedback'],feedback:{contentSummary:'课堂内容',trainingSummary:'课堂进度',noHomework:true,revision:'2026-10-13T00:00:00Z'}};
+  const {harness,component}=await screen();component.openFeedbackEditor();harness.fixture.detectChanges();
+  const input=harness.routeNativeElement!.querySelector('.attachment-picker input') as HTMLInputElement;
+  const files=new DataTransfer();files.items.add(new File(['photo'],'笔记.png',{type:'image/png'}));files.items.add(new File(['pdf'],'知识点.pdf',{type:'application/pdf'}));input.files=files.files;
+  input.dispatchEvent(new Event('change'));await harness.fixture.whenStable();harness.fixture.detectChanges();
+  expect(component.feedbackAttachments().length).toBe(2);expect(harness.routeNativeElement!.querySelector('.attachment-list img')).not.toBeNull();
+  component.finishCoachLesson();await harness.fixture.whenStable();harness.fixture.detectChanges();
+  const saved=JSON.parse(String(calls.find(c=>c.action==='app_lesson_feedback_update')!.params['feedback']));expect(saved.attachments.map((file:any)=>file.name)).toEqual(['笔记.png','知识点.pdf']);
+  expect(harness.routeNativeElement!.querySelectorAll('.saved-attachments a').length).toBe(2);
+  component.openFeedbackEditor();expect(component.feedbackAttachments().length).toBe(2);component.removeFeedbackAttachment(component.feedbackAttachments()[0].id);expect(component.feedbackAttachments()[0].name).toBe('知识点.pdf');
+  component.closeCompletionFeedback();component.openFeedbackEditor();expect(component.feedbackAttachments().length).toBe(2);
+ });
+ it('keeps a teacher draft and attachments after upload failure, and blocks submit while uploading',async()=>{
+  const file={id:'saved',name:'原笔记.png',url:'https://files.example/old.png',contentType:'image/png',size:12};
+  order={...order,courseCategoryKey:'primary_writing',dszt:30,allowedActions:['editFeedback'],feedback:{contentSummary:'内容',trainingSummary:'进度',noHomework:true,attachments:[file]}};
+  const {harness,component}=await screen();component.openFeedbackEditor();component.coachComment='未丢失的点评';harness.fixture.detectChanges();
+  const pending=new Subject<any>();spyOn(TestBed.inject(ApiService),'uploadLessonAttachment').and.returnValue(pending);
+  const input=harness.routeNativeElement!.querySelector('.attachment-picker input') as HTMLInputElement,files=new DataTransfer();files.items.add(new File(['data'],'新笔记.pdf',{type:'application/pdf'}));input.files=files.files;
+  const upload=component.uploadFeedbackAttachments({target:input} as unknown as Event);harness.fixture.detectChanges();
+  expect(component.uploadingAttachments()).toBeTrue();expect((harness.routeNativeElement!.querySelector('.feedback-actions .primary-button') as HTMLButtonElement).disabled).toBeTrue();
+  component.finishCoachLesson();component.closeCompletionFeedback();expect(component.completionDialog()?.nativeElement.open).toBeTrue();expect(calls.some(c=>c.action==='app_lesson_feedback_update')).toBeFalse();
+  pending.error(new ApiError('上传失败,请重试'));await upload;
+  expect(component.uploadingAttachments()).toBeFalse();expect(component.feedbackError()).toContain('上传失败');expect(component.coachComment).toBe('未丢失的点评');expect(component.feedbackAttachments()).toEqual([file]);
+ });
+ it('does not upload oversize, unsupported or excess attachments',async()=>{
+  order={...order,courseCategoryKey:'high_writing',dszt:30,allowedActions:['editFeedback'],feedback:{}};
+  const {harness,component}=await screen();component.openFeedbackEditor();harness.fixture.detectChanges();
+  const input=harness.routeNativeElement!.querySelector('.attachment-picker input') as HTMLInputElement;
+  for(const files of [[new File(['bad'],'program.exe')],[new File([new Uint8Array(5*1024*1024+1)],'large.pdf',{type:'application/pdf'})],Array.from({length:11},(_,i)=>new File(['pdf'],i+'.pdf',{type:'application/pdf'}))]){
+   const data=new DataTransfer();files.forEach(file=>data.items.add(file));input.files=data.files;await component.uploadFeedbackAttachments({target:input} as unknown as Event);expect(component.feedbackError()).toBeTruthy();
+  }
+  expect(calls.some(c=>c.action==='attachment-upload')).toBeFalse();
+ });
  it('opens the editor when entering details from a report edit link',async()=>{
   order={...order,dszt:30,allowedActions:['editFeedback'],feedback:{contentSummary:'原内容',trainingSummary:'进度',noHomework:true,revision:'2026-10-13T00:00:00Z'}};
   const {component}=await screen('/pages/shop/get_order?id=901&editReport=1');

+ 28 - 4
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.html

@@ -46,6 +46,9 @@
 
     @if (!order()['cancelled'] && !isStudentView && status() <= 10) {
       <section class="course-section">
+        @if (isWritingAppointment) {
+          <p class="schedule-hint">阅读与写作课可直接开始上课。上完后点击“结束课程”,结课后可填写课堂反馈与陪练点评。</p>
+        } @else {
         <label><span>本次学习课本 / 词库</span>
           <select aria-label="本次学习课本或词库" [(ngModel)]="selectedBindingId" (ngModelChange)="chooseCourse()" [disabled]="coursesLoading() || submitting()">
             <option value="">{{coursesLoading() ? '正在加载学员课程…' : '请选择已开通课程'}}</option>
@@ -79,6 +82,7 @@
           } @empty { <div class="empty-state"><p>{{unitsLoading() ? '正在加载学习单元…' : selectedBindingId ? '暂无可用学习单元' : '请选择课程后加载单元'}}</p></div> }
         </div>
         }
+        }
       </section>
     }
 
@@ -87,10 +91,10 @@
         <section class="completion-feedback">
           <header>
             <div><h2 id="completion-title">{{ editingFeedback() ? (feedbackPending() ? '补充课堂反馈' : '修改学习报告') : '完课反馈' }}</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>
+            <button type="button" class="feedback-close" aria-label="关闭完课反馈" [disabled]="submitting() || uploadingAttachments()" (click)="closeCompletionFeedback()"><lucide-icon [img]="icons.X" [size]="20" /></button>
           </header>
           <p class="feedback-hint">{{ editingFeedback() ? '本次修改仅更新反馈内容,课时和学习记录保持不变。' : '日期、时间、课时扣减、生词和抗遗忘数据由系统自动记录' }}</p>
-          @if (!editingFeedback()) {
+          @if (!editingFeedback() && !isWritingAppointment) {
             <section class="feedback-hint" aria-label="结课词表核对">
               @if (classStudyLoading()) { <p>正在核对本节学习成果…</p> }
               @else if (classStudyError()) { <p>{{ classStudyError() }}</p><button type="button" (click)="loadClassStudy()">重新核对</button> }
@@ -116,12 +120,32 @@
               <label class="checkbox-row"><input type="checkbox" [(ngModel)]="noHomework" /><span>本次无作业</span></label>
             </div>
             <label><span>陪练点评</span><textarea [(ngModel)]="coachComment" maxlength="1000" placeholder="本次学习表现与建议"></textarea></label>
+            @if (isWritingAppointment) {
+              <section class="feedback-attachments" aria-label="课堂照片与文件" [attr.aria-busy]="uploadingAttachments()">
+                <strong>课堂照片与文件 <small>选填</small></strong>
+                <p id="attachment-help">保存做题笔记和知识点。支持照片、PDF、Word、Excel、PPT、文本;每个不超过5MB,每节课最多10个。</p>
+                <label class="attachment-picker"><span>选择照片或文件(可多选)</span><input type="file" multiple [accept]="attachmentAccept" aria-describedby="attachment-help" [disabled]="uploadingAttachments() || feedbackAttachments().length >= 10" (change)="uploadFeedbackAttachments($event)" /></label>
+                @if (attachmentProgress()) { <p role="status" aria-live="polite">{{ attachmentProgress() }}</p> }
+                <div class="attachment-list">
+                  @for (file of feedbackAttachments(); track file.id) {
+                    <article>
+                      <a [href]="file.url" target="_blank" rel="noopener noreferrer" [attr.download]="file.name">
+                        @if (file.contentType.startsWith('image/')) { <img [src]="file.url" [alt]="file.name" loading="lazy" /> }
+                        <span>{{ file.name }}<small>{{ (file.size / 1024) | number:'1.0-0' }} KB</small></span>
+                      </a>
+                      <button type="button" class="secondary-button" [disabled]="uploadingAttachments()" [attr.aria-label]="'移除附件:' + file.name" (click)="removeFeedbackAttachment(file.id)">移除</button>
+                    </article>
+                  }
+                </div>
+                @if (feedbackAttachments().length) { <p>提交反馈后,附件会与本次课堂记录一同保存。</p> }
+              </section>
+            }
             <label><span>备注 <small>选填</small></span><textarea [(ngModel)]="notes" maxlength="300" placeholder="记录需要运营或家长关注的情况"></textarea></label>
           </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()">{{ editingFeedback() ? '取消修改' : '返回课程' }}</button>
-            <button type="button" class="primary-button" [disabled]="submitting() || (!editingFeedback() && (classStudyLoading() || !!classStudyError() || !!classStudy()?.pending))" (click)="finishCoachLesson()">{{ submitting() ? (editingFeedback() ? '正在保存…' : '正在提交…') : (editingFeedback() ? (feedbackPending() ? '提交反馈' : '保存修改') : '提交并结束课程') }}</button>
+            <button type="button" class="secondary-button" [disabled]="submitting() || uploadingAttachments()" (click)="closeCompletionFeedback()">{{ editingFeedback() ? '取消修改' : '返回课程' }}</button>
+            <button type="button" class="primary-button" [disabled]="submitting() || uploadingAttachments() || (!editingFeedback() && (classStudyLoading() || !!classStudyError() || !!classStudy()?.pending))" (click)="finishCoachLesson()">{{ uploadingAttachments() ? '正在上传附件…' : submitting() ? (editingFeedback() ? '正在保存…' : '正在提交…') : (editingFeedback() ? (feedbackPending() ? '提交反馈' : '保存修改') : '提交并结束课程') }}</button>
           </footer>
         </section>
       </dialog>

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

@@ -104,3 +104,13 @@ button:active:not(:disabled) { opacity: .8; }
 .lesson-study-summary details { padding: 12px 0; border-top: 1px solid var(--line); }
 .lesson-study-summary summary { min-height: 32px; cursor: pointer; line-height: 1.6; }
 .lesson-study-summary button { min-height: 44px; margin: 6px 8px 0 0; }
+ .feedback-attachments { display:grid; gap:12px; padding:16px; border:1px solid var(--line); border-radius:12px; background:var(--surface-muted); }
+ .feedback-attachments p { margin:0; color:var(--ink-soft); font-size:12px; line-height:1.6; overflow-wrap:anywhere; }
+ .feedback-attachments strong small { font-weight:400; color:var(--ink-soft); }
+ .attachment-picker input { min-height:44px; max-width:100%; }
+ .attachment-list { display:grid; gap:10px; }
+ .attachment-list article { display:flex; align-items:center; gap:12px; min-width:0; }
+ .attachment-list a { display:flex; flex:1; align-items:center; gap:12px; min-width:0; color:var(--brand-strong); overflow-wrap:anywhere; }
+ .attachment-list img { width:64px; height:64px; object-fit:cover; border-radius:8px; flex-shrink:0; }
+ .attachment-list a small { display:block; margin-top:4px; color:var(--ink-soft); }
+ .attachment-list button { flex-shrink:0; min-height:44px; }

+ 46 - 10
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts

@@ -6,7 +6,8 @@ import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, injec
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { BookCheck, Check, ChevronRight, CircleHelp, Clock3, Flag, GraduationCap, LucideAngularModule, Play, RefreshCw, Save, Star, X } from 'lucide-angular';
-import { finalize, Subscription } from 'rxjs';
+import { finalize, firstValueFrom, Subscription } from 'rxjs';
+import { LessonAttachment, lessonAttachments, lessonAttachmentContentType, LESSON_ATTACHMENT_ACCEPT, LESSON_ATTACHMENT_MAX_COUNT, LESSON_ATTACHMENT_MAX_SIZE } from '../../core/lesson-attachments';
 import { LiveDataRefreshService } from '../../core/live-data-refresh.service';
 import { ApiError, ApiService } from '../../core/api.service';
 import { PageMeta } from '../../core/models';
@@ -50,6 +51,10 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
   readonly classStudyLoading = signal(false);
   readonly editingFeedback = signal(false);
   readonly feedbackSaved = signal(false);
+  readonly feedbackAttachments = signal<LessonAttachment[]>([]);
+  readonly uploadingAttachments = signal(false);
+  readonly attachmentProgress = signal('');
+  readonly attachmentAccept = LESSON_ATTACHMENT_ACCEPT;
   private feedbackRevision = '';
   private autoEditOpened = false;
   readonly dateLabel = lessonDateLabel;
@@ -106,7 +111,8 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
   get isStudentView(): boolean { return this.meta.path === 'pages/shop/stu_get_order'; }
   get orderId(): string { return this.route.snapshot.queryParamMap.get('id') ?? this.route.snapshot.queryParamMap.get('cart') ?? ''; }
   get selectedCourseItem(): SourceItem | undefined { return this.courses().find(row => String(row['bindingId']) === this.selectedBindingId); }
-  get isWriting(): boolean { return ['primary_writing','middle_writing','high_writing'].includes(String(this.selectedCourseItem?.['categoryKey'] || this.order()['courseCategoryKey'] || '')); }
+  get isWritingAppointment(): boolean { return ['primary_writing','middle_writing','high_writing'].includes(String(this.order()['courseCategoryKey'] || '')); }
+  get isWriting(): boolean { return this.isWritingAppointment || ['primary_writing','middle_writing','high_writing'].includes(String(this.selectedCourseItem?.['categoryKey'] || '')); }
 
   load(background = false): void {
     this.loadRequest?.unsubscribe();
@@ -127,7 +133,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
         // Older servers already support starting lessons. Missing new workflow metadata
         // must not disable that endpoint; never derive write access from the cached row.
         if (!Array.isArray(detail['allowedActions'])) Object.assign(detail, this.legacyStartActions(detail));
-        if (background) { const changed = this.order()['bindingId'] !== detail['bindingId']; this.order.set({...this.order(),...detail}); if (changed && !this.isStudentView) this.loadCourses(); return; }
+        if (background) { const changed = this.order()['bindingId'] !== detail['bindingId']; this.order.set({...this.order(),...detail}); if (changed && !this.isStudentView && !this.isWritingAppointment) this.loadCourses(); return; }
         this.applyOrder({ ...fallback, ...detail });
         this.loadClassStudy();
       },
@@ -147,6 +153,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
 
   loadClassStudy(): void {
     this.classStudyError.set('');
+    if (this.isWritingAppointment) { this.classStudy.set(null); this.classStudyLoading.set(false); return; }
     this.classStudyLoading.set(true);
     this.api.get<any>('app_lesson_study', {orderId: this.orderId}).pipe(finalize(() => this.classStudyLoading.set(false))).subscribe({next: ({result}) => this.classStudy.set(result), error: (e: ApiError) => this.classStudyError.set(e.message)});
   }
@@ -210,7 +217,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
     if (!this.coachComment) this.coachComment = String(order['pldp'] ?? '');
     this.loadPracticeRecord();
     if (!this.isStudentView) {
-      if (this.status() <= 10) this.loadCourses();
+      if (this.status() <= 10 && !this.isWritingAppointment) this.loadCourses();
       this.loadLearningRecord();
     }
   }
@@ -289,7 +296,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
     this.actionError.set('');
     if (!this.can('start') && !this.can('continue')) { this.actionError.set(String(this.order()['blockedReason'] || '详情未核实,请刷新后重试')); return; }
     if(this.order()['cancelled']===true){this.platform.toast('此预约已取消,不能开始课程','error');return;}
-    if (this.coursesLoading() || this.unitsLoading() || !this.selectedCourseItem || this.coursesError()) { this.actionError.set('请先加载并选择该学员已开通的课程'); return; }
+    if (!this.isWritingAppointment && (this.coursesLoading() || this.unitsLoading() || !this.selectedCourseItem || this.coursesError())) { this.actionError.set('请先加载并选择该学员已开通的课程'); return; }
     if (!this.isWriting && !this.selectedUnits().size) {
       this.actionError.set(this.units().length ? '请选择本次学习单元' : '暂无可用学习单元,请刷新重试或联系管理员核对课程内容');
       return;
@@ -318,6 +325,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
   openFeedbackEditor(): void {
     if (this.isStudentView || !this.can('editFeedback') || this.submitting()) return;
     const feedback = this.feedback() || {};
+    this.feedbackAttachments.set(lessonAttachments(feedback['attachments']));
     this.contentSummary = String(feedback['contentSummary'] || '');
     this.reviewStatus = String(feedback['reviewStatus'] || '');
     this.reviewSummary = String(feedback['reviewSummary'] || '');
@@ -336,12 +344,39 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
 
   closeCompletionFeedback(event?: Event): void {
     event?.preventDefault();
-    if (!this.submitting()) this.completionDialog()?.nativeElement.close();
+    if (!this.submitting() && !this.uploadingAttachments()) this.completionDialog()?.nativeElement.close();
+  }
+
+  async uploadFeedbackAttachments(event: Event): Promise<void> {
+    const input = event.target as HTMLInputElement, files = Array.from(input.files || []);
+    input.value = '';
+    if (!files.length || this.uploadingAttachments() || this.submitting() || !this.isWritingAppointment
+      || this.isStudentView || !this.can(this.editingFeedback() ? 'editFeedback' : 'complete') || !this.completionDialog()?.nativeElement.open) return;
+    this.feedbackError.set('');
+    if (this.feedbackAttachments().length + files.length > LESSON_ATTACHMENT_MAX_COUNT) { this.feedbackError.set('每节课最多保存10个附件'); return; }
+    const invalid = files.find(file => !lessonAttachmentContentType(file) || file.size < 1 || file.size > LESSON_ATTACHMENT_MAX_SIZE);
+    if (invalid) { this.feedbackError.set(`“${invalid.name}”不符合要求,请选择5MB以内的照片或常用文档`); return; }
+    this.uploadingAttachments.set(true);
+    try {
+      for (const [index, file] of files.entries()) {
+        this.attachmentProgress.set(`正在上传 ${index + 1}/${files.length}:${file.name}`);
+        const { result } = await firstValueFrom(this.api.uploadLessonAttachment(this.orderId, file, lessonAttachmentContentType(file)));
+        const attachment = lessonAttachments([result])[0];
+        if (!attachment) throw new Error('上传未返回有效附件,请重试');
+        this.feedbackAttachments.update(items => [...items, attachment]);
+      }
+    } catch (error) { this.feedbackError.set((error as Error).message || '附件上传失败,请重试;已上传的附件和填写内容已保留'); }
+    finally { this.uploadingAttachments.set(false); this.attachmentProgress.set(''); }
+  }
+
+  removeFeedbackAttachment(id: string): void {
+    if (this.uploadingAttachments() || this.submitting() || this.isStudentView || !this.can(this.editingFeedback() ? 'editFeedback' : 'complete')) return;
+    this.feedbackAttachments.update(items => items.filter(item => item.id !== id));
   }
 
   async endLesson(): Promise<void> {
     if (this.submitting() || !this.can('complete')) return;
-    if (this.classStudyLoading() || this.classStudyError() || !this.classStudy()) { this.actionError.set('请先加载本节学习成果,再结束课程'); return; }
+    if (!this.isWritingAppointment && (this.classStudyLoading() || this.classStudyError() || !this.classStudy())) { this.actionError.set('请先加载本节学习成果,再结束课程'); return; }
     if (this.classStudy()?.pending) { this.actionError.set('还有未确认的学习批次,请先处理'); return; }
     if (!await this.platform.confirm('确认结束本次课程?系统将记录课时,课堂反馈可以稍后补充。')) return;
     if (this.submitting() || !this.can('complete')) return;
@@ -354,9 +389,9 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
   readonly completionNotice=signal('');
 
   finishCoachLesson(): void {
-    if (this.submitting() || !this.can(this.editingFeedback() ? 'editFeedback' : 'complete') || !this.completionDialog()?.nativeElement.open) return;
+    if (this.submitting() || this.uploadingAttachments() || !this.can(this.editingFeedback() ? 'editFeedback' : 'complete') || !this.completionDialog()?.nativeElement.open) return;
     this.feedbackError.set('');
-    if (!this.editingFeedback() && (this.classStudyLoading() || this.classStudyError() || !this.classStudy())) { this.feedbackError.set('请先加载本节学习成果,再提交结课'); return; }
+    if (!this.editingFeedback() && !this.isWritingAppointment && (this.classStudyLoading() || this.classStudyError() || !this.classStudy())) { this.feedbackError.set('请先加载本节学习成果,再提交结课'); return; }
     if (!this.editingFeedback() && this.classStudy()?.pending) { this.feedbackError.set('还有未确认的学习批次,请返回课程先处理'); return; }
     if (!this.contentSummary.trim()) { this.feedbackError.set('请填写本次学习内容'); return; }
     if (!this.trainingSummary.trim()) { this.feedbackError.set('请填写进度说明'); return; }
@@ -377,6 +412,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
         noHomework: this.noHomework,
         notes: this.notes.trim(),
         coachComment: this.coachComment.trim(),
+        ...(this.isWritingAppointment ? { attachments: this.feedbackAttachments() } : {}),
       }),
     }).pipe(finalize(() => this.submitting.set(false))).subscribe({
       next: () => { this.completionDialog()?.nativeElement.close(); this.feedbackSaved.set(editing); this.platform.toast(editing ? (supplement ? '课堂反馈已提交' : '报告修改已保存') : '课程已结课,学习报告已生成'); this.load(); },
@@ -395,7 +431,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
   }
   private updateOrderStatus(status:number,next:()=>void):void{
     if(this.submitting())return;this.submitting.set(true);
-    this.api.post('e_order_update_v2',{status,uid:this.session.user()?.userId,learningBindingId:this.selectedBindingId,expectedUpdatedAt:this.order()['updatedAt'],content:JSON.stringify({GeneralID:this.orderId})}).pipe(finalize(()=>this.submitting.set(false))).subscribe({next:()=>next(),error:(error:ApiError)=>this.actionError.set(error.message)});
+    this.api.post('e_order_update_v2',{status,uid:this.session.user()?.userId,learningBindingId:this.isWritingAppointment?undefined:this.selectedBindingId,expectedUpdatedAt:this.order()['updatedAt'],content:JSON.stringify({GeneralID:this.orderId})}).pipe(finalize(()=>this.submitting.set(false))).subscribe({next:()=>next(),error:(error:ApiError)=>this.actionError.set(error.message)});
   }
 
   private loadPracticeRecord(): void {

+ 17 - 2
projects/xiaoshu-mobile/src/app/features/appointment/lesson-feedback.component.ts

@@ -1,4 +1,5 @@
-import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
+import { lessonAttachments } from '../../core/lesson-attachments';
 
 @Component({
   selector: 'app-lesson-feedback', standalone: true,
@@ -10,6 +11,16 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core';
       <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>
+      @if (attachments().length) {
+        <div class="saved-attachments"><dt>课堂照片与文件</dt>
+          @for (file of attachments(); track file.id) {
+            <dd><a [href]="file.url" target="_blank" rel="noopener noreferrer" [attr.download]="file.name">
+              @if (file.contentType.startsWith('image/')) { <img [src]="file.url" [alt]="file.name" loading="lazy" /> }
+              <span>{{ file.name }}</span>
+            </a></dd>
+          }
+        </div>
+      }
     </dl>
   `,
   styles: [`
@@ -18,7 +29,11 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core';
     .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)}
+    .saved-attachments{grid-column:1/-1}.saved-attachments a{display:flex;align-items:center;gap:12px;min-height:44px;color:var(--brand-strong);overflow-wrap:anywhere}.saved-attachments img{width:80px;height:80px;object-fit:cover;border-radius:8px;flex-shrink:0}
     @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>>(); }
+export class LessonFeedbackComponent {
+  readonly feedback = input.required<Record<string, unknown>>();
+  readonly attachments = computed(() => lessonAttachments(this.feedback()['attachments']));
+}

+ 7 - 1
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -2229,7 +2229,13 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     const detail = detailValue && typeof detailValue === 'object' ? detailValue as SourceItem : {};
     const detailTitle = String(detail['Title'] ?? '').trim();
     const storedTitle = String(item['Title'] ?? '').split('@')[0].trim();
-    return this.toWord({ ...item, Title: detailTitle || storedTitle });
+    // The envelope identifies a practice record; selection and study need the vocabulary ID.
+    // Normalize GeneralID too so stored words keep the same identity in subsequent stages.
+    return this.toWord({
+      ...item,
+      GeneralID: detail['GeneralID'] ?? item['scid'] ?? item['GeneralID'],
+      Title: detailTitle || storedTitle,
+    });
   }
 
   private toQuestion(item: SourceItem): ExamQuestion {

+ 61 - 0
scripts/check-lesson-attachments-browser.mjs

@@ -0,0 +1,61 @@
+// Local UI acceptance with isolated API and storage fixtures. No online writes.
+import {chromium} from 'playwright';
+import {createServer} from 'node:http';
+import {readFileSync,existsSync,mkdirSync,writeFileSync} from 'node:fs';
+import {resolve,extname} from 'node:path';
+import assert from 'node:assert/strict';
+const root=resolve('dist/xiaoshu-mobile/browser'),out=resolve('tmp/lesson-attachments-qa');mkdirSync(out,{recursive:true});
+const png=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/wckAAAAASUVORK5CYII=','base64');
+const server=createServer((req,res)=>{
+ let file=resolve(root,'.'+decodeURIComponent(new URL(req.url,'http://local').pathname));
+ if(!file.startsWith(root+'/')||!existsSync(file)||!extname(file))file=root+'/index.html';
+ res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.css':'text/css','.svg':'image/svg+xml'})[extname(file)]||'application/octet-stream');res.end(readFileSync(file));
+});
+await new Promise(r=>server.listen(0,'127.0.0.1',r));const origin='http://127.0.0.1:'+server.address().port;
+const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
+let role=3,failUpload=false,sequence=0,feedback={feedbackStatus:'pending',revision:'2026-10-29T00:00:00Z'};
+const calls=[],errors=[];
+try{
+ const context=await browser.newContext({viewport:{width:375,height:812},reducedMotion:'reduce'});
+ await context.addInitScript(()=>{if(!localStorage.getItem('xiaoshu.angular.session'))localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:local-fixture',user:{userId:300,groupId:3,honeyName:'测试老师'}}));});
+ await context.route('**/*',async route=>{
+  const url=new URL(route.request().url());if(url.origin!==origin)return route.abort();
+  if(url.pathname.startsWith('/fixtures/'))return route.fulfill({contentType:url.pathname.endsWith('.png')?'image/png':'application/pdf',body:url.pathname.endsWith('.png')?png:Buffer.from('%PDF-1.4\nnotes')});
+  if(!url.pathname.startsWith('/cloud-functions/'))return route.continue();
+  const {params:p}=route.request().postDataJSON();calls.push(p);let result=[];
+  if(p.action==='e_order_detail')result=[{GeneralID:901,szyh:100,pl:300,HoneyName:'测试学员',kcid:0,bindingId:'',courseCategoryKey:'primary_writing',yykcmc:'小学语法写作',dszt:30,updatedAt:'2026-10-29T00:00:00Z',allowedActions:role===3?['editFeedback']:['review'],feedback}];
+  if(p.action==='app_live_versions')result={version:'1',scopeVersions:{},syncStatus:{state:'current'}};
+  if(p.action==='app_lesson_attachment_upload'){
+   if(failUpload)return route.fulfill({status:409,json:{retcode:-1,retmsg:'模拟上传失败,稍后重试'}});
+   result={id:'upload-'+ ++sequence,name:p.fileName,contentType:p.contentType,size:Buffer.from(p.base64,'base64').length,url:origin+'/fixtures/'+sequence+(p.contentType.startsWith('image/')?'.png':'.pdf')};
+  }
+  if(p.action==='app_lesson_feedback_update'){feedback={...JSON.parse(p.feedback),feedbackStatus:'submitted',revision:'2026-10-29T00:01:00Z'};result={saved:true};}
+  await route.fulfill({json:{retcode:1,result}});
+ });
+ const page=await context.newPage();page.on('pageerror',error=>errors.push(error.message));
+ await page.goto(origin+'/pages/shop/get_order?id=901&editReport=1');await page.locator('dialog[open]').waitFor();
+ await page.getByPlaceholder('例:人教版三年级 Unit 3,重点词汇与句型').fill('阅读短文,整理语法知识点并练习写作');
+ await page.getByPlaceholder('说明当前进度与下次起点').fill('完成课堂练习,下次复习错题');await page.getByText('本次无作业',{exact:true}).click();
+ const input=page.locator('.attachment-picker input');
+ await input.setInputFiles([{name:'课堂笔记.png',mimeType:'image/png',buffer:png},{name:'语法知识点.pdf',mimeType:'application/pdf',buffer:Buffer.from('%PDF-1.4\nnotes')}]);
+ await page.locator('.attachment-list article').nth(1).waitFor();
+ assert.equal(await page.locator('.attachment-list img').count(),1);
+ await page.getByRole('button',{name:'移除附件:语法知识点.pdf'}).click();await page.locator('.attachment-list article').nth(1).waitFor({state:'detached'});assert.equal(await page.locator('.attachment-list article').count(),1);
+ await input.setInputFiles({name:'语法知识点.pdf',mimeType:'application/pdf',buffer:Buffer.from('%PDF-1.4\nnotes')});await page.locator('.attachment-list article').nth(1).waitFor();
+ failUpload=true;await input.setInputFiles({name:'补充.txt',mimeType:'text/plain',buffer:Buffer.from('课堂笔记')});await page.locator('.feedback-error').waitFor();
+ assert.equal(await page.locator('.attachment-list article').count(),2);assert.match(await page.getByPlaceholder('说明当前进度与下次起点').inputValue(),/完成课堂练习/);failUpload=false;
+ for(const viewport of [{width:375,height:812},{width:812,height:375}]){
+  await page.setViewportSize(viewport);await page.locator('.feedback-attachments').scrollIntoViewIfNeeded();
+  assert.ok(await page.locator('dialog').evaluate(element=>element.scrollWidth<=element.clientWidth+1),'dialog must not overflow horizontally');
+  await page.screenshot({path:out+'/teacher-'+viewport.width+'.png'});
+ }
+ await page.setViewportSize({width:375,height:812});await page.getByRole('button',{name:'提交反馈',exact:true}).click();await page.locator('dialog[open]').waitFor({state:'hidden'});
+ assert.equal(await page.locator('.saved-attachments a').count(),2);
+ await page.getByRole('button',{name:'修改报告',exact:true}).last().click();await page.locator('.attachment-list article').nth(1).waitFor();await page.getByRole('button',{name:'取消修改',exact:true}).click();
+ role=1;await page.evaluate(()=>localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:student-fixture',user:{userId:100,groupId:1,honeyName:'测试学员'}})));
+ await page.goto(origin+'/pages/shop/stu_get_order?id=901');await page.locator('.saved-attachments a').nth(1).waitFor();assert.equal(await page.locator('input[type=file]').count(),0);
+ await page.locator('.saved-attachments').scrollIntoViewIfNeeded();await page.screenshot({path:out+'/student-375.png'});
+ assert.equal(calls.filter(call=>call.action==='app_lesson_feedback_update').length,1);assert.equal(calls.filter(call=>call.action==='app_lesson_complete').length,0);assert.deepEqual(errors,[]);
+ writeFileSync(out+'/result.json',JSON.stringify({passed:true,checks:['375px与横屏无横向溢出','多文件上传与图片预览','移除与再次添加','失败保留已上传文件和文字','保存重开仍有附件','学生可看附件且没有上传入口'],environment:'local production build with mocked API and storage; no online writes'},null,2));
+ console.log('Lesson attachment browser checks passed');
+}finally{await browser.close();await new Promise(r=>server.close(r));}

+ 48 - 0
scripts/cloud/lesson-attachments.js

@@ -0,0 +1,48 @@
+// Attachments are uploaded by the assigned teacher and linked to one appointment.
+const LESSON_ATTACHMENT_TYPES = {
+ jpg:['image/jpeg','/9j/'],jpeg:['image/jpeg','/9j/'],png:['image/png','iVBOR'],webp:['image/webp','UklGR'],gif:['image/gif','R0lG'],
+ pdf:['application/pdf','JVBERi0'],doc:['application/msword','0M8R4KGxGuE'],
+ docx:['application/vnd.openxmlformats-officedocument.wordprocessingml.document','UEsDB'],
+ xls:['application/vnd.ms-excel','0M8R4KGxGuE'],xlsx:['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet','UEsDB'],
+ ppt:['application/vnd.ms-powerpoint','0M8R4KGxGuE'],pptx:['application/vnd.openxmlformats-officedocument.presentationml.presentation','UEsDB'],txt:['text/plain',''],
+};
+
+async function lessonAttachmentOrder(input,current,requireEditable=true) {
+ assertLessonTenant(current);
+ const order=await orderDetailRow(String(input.orderId||''),current,false);
+ if(!order)fail(404,'预约不存在');
+ if(number(current.get('legacyGroupId'))!==3||ownLegacyId(current)!==number(order.pl))fail(403,'仅本节课老师可以上传和保存课堂附件');
+ if(!['primary_writing','middle_writing','high_writing'].includes(String(order.courseCategoryKey)))fail(403,'课堂附件仅用于语法写作课');
+ if(requireEditable){
+  const capabilities=await lessonOrderCapabilities(order,current);
+  if(!capabilities.allowedActions.some(action=>['complete','editFeedback'].includes(action)))fail(403,'当前课程不可编辑课堂反馈');
+ }
+ return order;
+}
+
+async function uploadLessonAttachment(input,current) {
+ const order=await lessonAttachmentOrder(input,current);
+ const name=String(input.fileName||'').trim(),extension=name.split('.').pop().toLowerCase(),rule=LESSON_ATTACHMENT_TYPES[extension];
+ const contentType=String(input.contentType||'').toLowerCase(),base64=String(input.base64||'').replace(/^data:[^;]+;base64,/i,'');
+ if(!name||name.length>180||/[\x00-\x1f/\\]/.test(name)||!rule||contentType!==rule[0]||base64.length>6990508||!base64||!/^[A-Za-z0-9+/]+={0,2}$/.test(base64))fail(400,'请选择5MB以内的照片或常用文档');
+ let binary;try{binary=atob(base64);}catch(_){fail(400,'附件编码无效');}
+ if(!binary.length||binary.length>5*1024*1024||!base64.startsWith(rule[1])||contentType==='text/plain'&&binary.includes('\x00'))fail(400,'附件内容与文件类型不符或超过5MB');
+ const extraTypes=Object.fromEntries(Object.values(LESSON_ATTACHMENT_TYPES).map(type=>[type[0],type[1]]));
+ const url=await uploadAppMedia({...input,base64},extraTypes);
+ if(!/^https?:\/\//i.test(url))fail(502,'上传未返回有效附件地址');
+ const attachment={id:randomObjectId(),name,url,contentType,size:binary.length};
+ await Psql.none('INSERT INTO "LessonFeedbackAttachment" ("objectId",company,"appointmentGeneralId","coachId","fileName","publicUrl","contentType",size,"createdAt") VALUES($1,$2,$3,$4,$5,$6,$7,$8,now())',
+  [attachment.id,DEFAULT_COMPANY_ID,String(order.GeneralID),ownLegacyId(current),name,url,contentType,attachment.size]);
+ return attachment;
+}
+
+async function resolveLessonFeedbackAttachments(input,current,feedback) {
+ // The SQL command checks edit locks after replaying successful requests. Resolve
+ // ownership here without rejecting an already successful retry after review.
+ const order=await lessonAttachmentOrder(input,current,false),ids=feedback.attachments.map(file=>file.id);
+ const rows=await Psql.query('SELECT "objectId" AS id,"fileName" AS name,"publicUrl" AS url,"contentType",size::int AS size FROM "LessonFeedbackAttachment" WHERE company=$1 AND "appointmentGeneralId"=$2 AND "coachId"=$3 AND "objectId"=ANY($4::text[])',
+  [DEFAULT_COMPANY_ID,String(order.GeneralID),ownLegacyId(current),ids]);
+ const byId=new Map(rows.map(row=>[row.id,row]));
+ if(byId.size!==ids.length)fail(403,'附件不属于本节课或上传尚未完成,请重新选择');
+ feedback.attachments=ids.map(id=>byId.get(id));
+}

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

@@ -15,6 +15,10 @@ function lessonFeedbackInput(input) {
     if (feedback[key].length > 200) fail(400, '复习情况和训练状态每项最多200字');
   }
   feedback.noHomework = raw.noHomework === true;
+  if (Object.prototype.hasOwnProperty.call(raw,'attachments')) {
+    if(!Array.isArray(raw.attachments)||raw.attachments.length>10||raw.attachments.some(file=>!file||typeof file.id!=='string'||!file.id||file.id.length>80)||new Set(raw.attachments.map(file=>file.id)).size!==raw.attachments.length)fail(400,'每节课最多10个附件,附件编号不能重复或缺失');
+    feedback.attachments=raw.attachments.map(file=>({id:file.id}));
+  }
   if (!feedback.contentSummary || !feedback.trainingSummary
     || !feedback.noHomework && !feedback.homework) fail(400, '请完整填写学习内容、进度说明和作业');
   return feedback;
@@ -24,6 +28,7 @@ async function appLessonComplete(input, 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 = input.feedback == null ? null : lessonFeedbackInput(input);
+  if(feedback?.attachments?.length)await resolveLessonFeedbackAttachments(input,current,feedback);
   try {
     return (await Psql.one('SELECT xs_lesson_complete($1,$2,$3,$4,$5::timestamptz,$6,$7::jsonb) AS result',
       [DEFAULT_COMPANY_ID,id,current.id,ownLegacyId(current),input.expectedUpdatedAt,key,feedback === null ? null : JSON.stringify(feedback)])).result;
@@ -38,6 +43,7 @@ async function appLessonFeedbackUpdate(input, 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);
+  if(feedback.attachments?.length)await resolveLessonFeedbackAttachments(input,current,feedback);
   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;

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 0
scripts/deploy-admin-functions.mjs


+ 8 - 3
scripts/migrate-lesson-workflow.mjs

@@ -26,12 +26,17 @@ function invokeMigration(url,token) {
   return new Promise((resolve,reject)=>{const body=JSON.stringify({token,params:{}}),req=httpsRequest(url,{method:'POST',headers:{'Content-Type':'application/json','Content-Length':Buffer.byteLength(body)}},(res)=>{let raw='';res.setEncoding('utf8');res.on('data',(chunk)=>raw+=chunk);res.on('end',()=>{try{resolve({ok:Number(res.statusCode)>=200&&Number(res.statusCode)<300,data:JSON.parse(raw)})}catch(error){reject(error)}});});req.on('error',reject);req.write(body);req.end();});
 }
 // Register additive Parse fields before SQL, so schema metadata and physical columns agree.
+const knownSchemas=(await request('/schemas')).results||[];
 for(const [name,fields] of Object.entries({
  CommonModel:{teachingManaged:{type:'Boolean'}},CourseAppointment:{teachingManaged:{type:'Boolean'}},
- LessonRecord:{teachingManaged:{type:'Boolean'}},LessonCompletionReport:{feedbackStatus:{type:'String'},coachComment:{type:'String'}},
+ LessonRecord:{teachingManaged:{type:'Boolean'}},LessonCompletionReport:{feedbackStatus:{type:'String'},coachComment:{type:'String'},attachments:{type:'Array'}},
+ LessonFeedbackAttachment:{company:{type:'Pointer',targetClass:'Company'},appointmentGeneralId:{type:'String'},coachId:{type:'Number'},fileName:{type:'String'},publicUrl:{type:'String'},contentType:{type:'String'},size:{type:'Number'}},
 })){
- const schema=await request('/schemas/'+name),missing=Object.fromEntries(Object.entries(fields).filter(([key])=>!schema.fields?.[key]));
- if(Object.keys(missing).length)await request('/schemas/'+name,{method:'PUT',body:JSON.stringify({fields:missing})});
+ const schema=knownSchemas.find(row=>row.className===name);
+ const permissions=name==='LessonFeedbackAttachment'?{classLevelPermissions:{find:{},count:{},get:{},create:{},update:{},delete:{},addField:{}}}:{};
+ if(!schema){await request('/schemas',{method:'POST',body:JSON.stringify({className:name,fields,...permissions})});continue;}
+ const missing=Object.fromEntries(Object.entries(fields).filter(([key])=>!schema.fields?.[key]));
+ if(Object.keys(missing).length||Object.keys(permissions).length)await request('/schemas/'+name,{method:'PUT',body:JSON.stringify({fields:missing,...permissions})});
 }
 const company=(await request('/classes/Company?limit=1&keys=objectId')).results?.[0];
 if(!company)throw new Error('未找到帐套');

+ 12 - 4
scripts/sql/lesson-workflow.sql

@@ -17,6 +17,13 @@ ALTER TABLE "CourseAppointment" ADD COLUMN IF NOT EXISTS "teachingManaged" boole
 ALTER TABLE "CommonModel" ADD COLUMN IF NOT EXISTS "teachingManaged" boolean;
 ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "teachingManaged" boolean;
 ALTER TABLE "LessonCompletionReport" ADD COLUMN IF NOT EXISTS "coachComment" text;
+ALTER TABLE "LessonCompletionReport" ADD COLUMN IF NOT EXISTS attachments jsonb NOT NULL DEFAULT '[]'::jsonb;
+CREATE TABLE IF NOT EXISTS "LessonFeedbackAttachment" (
+ "objectId" text PRIMARY KEY, company text NOT NULL, "appointmentGeneralId" text NOT NULL,
+ "coachId" numeric NOT NULL, "fileName" text NOT NULL, "publicUrl" text NOT NULL,
+ "contentType" text NOT NULL, size numeric NOT NULL, "createdAt" timestamptz DEFAULT now(), "updatedAt" timestamptz
+);
+CREATE INDEX IF NOT EXISTS lesson_feedback_attachment_owner ON "LessonFeedbackAttachment" (company,"appointmentGeneralId","coachId");
 
 -- Existing reports were submitted; new feedback-free completions explicitly use pending.
 ALTER TABLE "LessonCompletionReport" ADD COLUMN IF NOT EXISTS "feedbackStatus" text NOT NULL DEFAULT 'submitted';
@@ -135,7 +142,7 @@ BEGIN
   'appointmentId',a."objectId",'appointmentGeneralId',gid,'lessonId',l."objectId",'studentId',a.szyh,'coachId',a.pl,
   'courseCategoryKey',a."courseCategoryKey",'courseCategoryName',a."courseCategoryName",'deliveryMode',a."deliveryMode",'durationMinutes',a."durationMinutes",
   'actualStartAt',NULLIF(a.kssj,'')::timestamptz,'actualEndAt',COALESCE(NULLIF(a.jssj,'')::timestamptz,now()),'creditCosts',a."creditCostsSnapshot",
-  'vocabularyCount',jsonb_array_length(words),'antiForgetSummary',jsonb_build_object('total',total_reviews,'completed',completed_reviews,'pending',total_reviews-completed_reviews),
+  'vocabularyCount',jsonb_array_length(words),'antiForgetSummary',jsonb_build_object('total',total_reviews,'completed',completed_reviews,'pending',total_reviews-completed_reviews),'attachments',COALESCE(feedback->'attachments','[]'::jsonb),
   'homework',CASE WHEN COALESCE((feedback->>'noHomework')::boolean,false) THEN '无作业' ELSE feedback->>'homework' END,'feedbackStatus',CASE WHEN feedback IS NULL THEN 'pending' ELSE 'submitted' END,'submittedBy',CASE WHEN feedback IS NULL THEN NULL ELSE actor END,'submittedAt',CASE WHEN feedback IS NULL THEN NULL ELSE now() END));
  DELETE FROM "LessonWorkflowIssue" WHERE company=co AND "appointmentGeneralId"=gid;
  result=jsonb_build_object('GeneralID',gid,'status',30,'lessonId',l."objectId",'feedbackId',report_id,'periodDeducted',was_charged,'feedbackStatus',CASE WHEN feedback IS NULL THEN 'pending' ELSE 'submitted' END,'teacherPay',COALESCE(l."teacherPaySnapshot",a."teacherPaySnapshot"),'payStatus',CASE WHEN COALESCE(l."teacherPaySnapshot",a."teacherPaySnapshot") IS NULL THEN 'pending' ELSE 'earned' END);
@@ -216,7 +223,7 @@ BEGIN
  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)
+ OR EXISTS(SELECT 1 FROM jsonb_each_text(feedback) f WHERE f.key<>'attachments' AND 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;
@@ -249,14 +256,15 @@ BEGIN
    'sourceKey','cloud:lesson-feedback:'||co||':'||gid,'appointmentId',a."objectId",'appointmentGeneralId',gid,
    'lessonId',lesson."objectId",'studentId',a.szyh,'coachId',a.pl,'courseCategoryKey',a."courseCategoryKey",
    'courseCategoryName',a."courseCategoryName",'deliveryMode',a."deliveryMode",'durationMinutes',a."durationMinutes",
-   'creditCosts',a."creditCostsSnapshot",'feedbackStatus','pending')) RETURNING * INTO report;
+   'creditCosts',a."creditCostsSnapshot",'feedbackStatus','pending','attachments','[]'::jsonb)) RETURNING * INTO report;
  ELSE
   IF expected IS NULL OR report."updatedAt" IS DISTINCT FROM expected THEN RAISE EXCEPTION '报告已被更新,请关闭编辑并刷新详情后重试';END IF;
  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');
+ 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','attachments');
  UPDATE "LessonCompletionReport" SET
  "contentSummary"=feedback->>'contentSummary',"reviewStatus"=feedback->>'reviewStatus',"reviewSummary"=feedback->>'reviewSummary',
  "trainingStatus"=feedback->>'trainingStatus',"trainingSummary"=feedback->>'trainingSummary',
+ attachments=CASE WHEN feedback?'attachments' THEN feedback->'attachments' ELSE COALESCE(report.attachments,'[]'::jsonb) END,
  homework=CASE WHEN COALESCE((feedback->>'noHomework')::boolean,false) THEN '无作业' ELSE feedback->>'homework' END,
  "feedbackStatus"='submitted',"submittedBy"=actor,"submittedAt"=COALESCE(report."submittedAt",clock_timestamp()),"noHomework"=COALESCE((feedback->>'noHomework')::boolean,false),"coachComment"=feedback->>'coachComment',notes=feedback->>'notes',"updatedAt"=clock_timestamp()
  WHERE "objectId"=report."objectId";

+ 59 - 0
scripts/tests/lesson-attachments.test.mjs

@@ -0,0 +1,59 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import vm from 'node:vm';
+import {readFileSync} from 'node:fs';
+const read=path=>readFileSync(new URL(path,import.meta.url),'utf8');
+const gateway=read('../deploy-admin-functions.mjs').match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1];
+const feedback={contentSummary:'阅读短文',trainingSummary:'写作练习',noHomework:true};
+const teacher={id:'teacher',get:key=>({company:{id:'7pIbDBJmKx'},legacyUserId:30,legacyGroupId:3}[key])};
+const pdf={fileName:'知识点.pdf',contentType:'application/pdf',base64:Buffer.from('%PDF-1.4\nnotes').toString('base64'),orderId:'901'};
+function setup(){
+ let order={GeneralID:901,pl:30,courseCategoryKey:'primary_writing'},actions=['editFeedback'],uploads=0;
+ const receipts=new Map(),requests=[];
+ const c=vm.createContext({atob,Blob,FormData,AbortSignal,fetch:async(url,request)=>{
+  uploads++;assert.equal(request.body.get('file').type,requests.at(-1)?.contentType||pdf.contentType);
+  return {ok:true,text:async()=>JSON.stringify({retcode:1,result:'/UploadFiles/notes.pdf'})};
+ }});
+ vm.runInContext(gateway+'\n'+read('../cloud/lesson-workflow.js')+'\n'+read('../cloud/lesson-attachments.js'),c);
+ c.orderDetailRow=async()=>order;c.lessonOrderCapabilities=async()=>({allowedActions:actions});c.appLegacyConfig=async()=>({url:'https://media.example/api',apiId:'test',apiKey:'test'});
+ c.randomObjectId=()=>String(receipts.size+1).padStart(10,'0');
+ c.Psql={none:async(sql,args)=>{const[id,company,appointment,coach,name,url,contentType,size]=args;receipts.set(id,{id,company,appointment,coach,name,url,contentType,size});requests.push({contentType});},
+  query:async(sql,[company,appointment,coach,ids])=>ids.map(id=>receipts.get(id)).filter(row=>row&&row.company===company&&row.appointment===appointment&&row.coach===coach).map(({id,name,url,contentType,size})=>({id,name,url,contentType,size}))};
+ return {c,receipts,requests,setOrder:value=>order={...order,...value},setActions:value=>actions=value,uploads:()=>uploads};
+}
+test('本节语法写作老师上传常用文档和照片,服务端保存原始中文名及课次归属',async()=>{
+ const {c,receipts,setOrder}=setup();
+ for(const category of ['primary_writing','middle_writing','high_writing']){
+  setOrder({courseCategoryKey:category});const file=await c.uploadLessonAttachment(pdf,teacher);
+  assert.equal(file.name,'知识点.pdf');assert.equal(file.contentType,'application/pdf');assert.equal(file.url,'https://media.example/UploadFiles/notes.pdf');assert.equal(receipts.get(file.id).appointment,'901');assert.equal(receipts.get(file.id).coach,30);
+ }
+ const png={...pdf,fileName:'笔记.png',contentType:'image/png',base64:Buffer.from([137,80,78,71,13,10,26,10]).toString('base64')};
+ // Verify transport type independently of the previous document upload.
+ c.fetch=async(_url,request)=>{assert.equal(request.body.get('file').type,'image/png');return{ok:true,text:async()=>JSON.stringify({retcode:1,result:'https://media.example/note.png'})};};
+ assert.equal((await c.uploadLessonAttachment(png,teacher)).name,'笔记.png');
+});
+test('学生、其他老师、外帐套、非写作课和已锁定反馈不能上传,拦截发生在媒体请求之前',async()=>{
+ const {c,setOrder,setActions,uploads}=setup();
+ for(const user of [{get:key=>({company:{id:'7pIbDBJmKx'},legacyUserId:20,legacyGroupId:1}[key])},{get:key=>({company:{id:'7pIbDBJmKx'},legacyUserId:31,legacyGroupId:3}[key])},{get:key=>key==='company'?{id:'foreign'}:null}])await assert.rejects(c.uploadLessonAttachment(pdf,user),/仅本节课|无权/);
+ setOrder({courseCategoryKey:'word'});await assert.rejects(c.uploadLessonAttachment(pdf,teacher),/语法写作课/);
+ setOrder({courseCategoryKey:'primary_writing'});setActions([]);await assert.rejects(c.uploadLessonAttachment(pdf,teacher),/不可编辑/);assert.equal(uploads(),0);
+});
+test('拒绝伪造文件类型、超限、无效编码及不支持的文件,原通用上传仍仅允许图片',async()=>{
+ const {c,uploads}=setup();
+ for(const patch of [{fileName:'test.exe'},{base64:Buffer.from('not a PDF').toString('base64')},{base64:'!!!'},{base64:Buffer.alloc(5*1024*1024+1).toString('base64')},{fileName:'../note.pdf'}])await assert.rejects(c.uploadLessonAttachment({...pdf,...patch},teacher),/请选择|内容|编码/);
+ await assert.rejects(c.uploadAppMedia(pdf),/文件类型/);assert.equal(uploads(),0);
+});
+test('反馈只接受本节课的已上传附件编号,客户端伪造的名称和地址会被覆盖',async()=>{
+ const {c,setOrder}=setup();const file=await c.uploadLessonAttachment(pdf,teacher);
+ const parsed=c.lessonFeedbackInput({feedback:JSON.stringify({...feedback,attachments:[{id:file.id,name:'伪造',url:'javascript:alert(1)'}]})});
+ await c.resolveLessonFeedbackAttachments({orderId:'901'},teacher,parsed);assert.equal(parsed.attachments[0].name,'知识点.pdf');assert.equal(parsed.attachments[0].url,file.url);
+ setOrder({GeneralID:902});await assert.rejects(c.resolveLessonFeedbackAttachments({orderId:'902'},teacher,{attachments:[{id:file.id}]}),/不属于本节课/);
+ for(const attachments of [[{id:''}],Array.from({length:11},(_,i)=>({id:String(i)})),[{id:'same'},{id:'same'}],'invalid'])assert.throws(()=>c.lessonFeedbackInput({feedback:JSON.stringify({...feedback,attachments})}),/附件/);
+});
+test('附件归属检查不会拦截成功提交后学生评价触发的幂等重试,锁定由SQL事务判断',async()=>{
+ const {c,setActions}=setup(),file=await c.uploadLessonAttachment(pdf,teacher);
+ setActions([]);
+ let commands=0;c.Psql.one=async(sql,args)=>{commands++;assert.match(sql,/xs_lesson_feedback_update/);assert.equal(JSON.parse(args[6]).attachments[0].id,file.id);return{result:{saved:true}};};
+ const result=await c.appLessonFeedbackUpdate({orderId:'901',requestKey:'attachment-retry-001',expectedUpdatedAt:'2026-10-29T00:00:00Z',feedback:JSON.stringify({...feedback,attachments:[file]})},teacher);
+ assert.equal(result.saved,true);assert.equal(commands,1);
+});

+ 2 - 1
scripts/tests/lesson-course-selection.test.mjs

@@ -14,7 +14,8 @@ let version;before(async()=>{
  INSERT INTO "CourseBinding" VALUES('b1','co',1,'20','100','word','单词课','active','{}'),('b2','co',2,'20','102','trial','体验课','active','{}'),('pending','co',3,'20','100','word','单词课','pending','{}'),('recycled','co',4,'20','100','word','单词课','recycled','{}'),('foreign','co',5,'21','100','word','单词课','active','{}'),('disabled','co',6,'20','104','word','单词课','active','{}');
  INSERT INTO "CommonModel"("objectId",company,"modelId","itemId","generalId",status) VALUES('appointment','co',54,1,901,99);
  INSERT INTO "CourseAppointment" VALUES('addon','co',1,'20','30','100','100','0',to_char(now() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD'),'','', 'b1',false,now(),'ready','[{"account":"Purse","amount":1}]',37);
- INSERT INTO "CourseCreditReservation" VALUES('co',20,'appointment','reserved','[{"account":"Purse","amount":1}]');`);
+ INSERT INTO "CourseCreditReservation" VALUES('co',20,'appointment','reserved','[{"account":"Purse","amount":1}]');
+ ALTER TABLE "CourseAppointment" ADD COLUMN "courseCategoryKey" text DEFAULT 'word';`);
  const enrollment=read('../sql/member-enrollment.sql');for(const name of ['xs_appointment_day','xs_appointment_valid_at','xs_reserved','xs_course_size','xs_validate_appointment','xs_start_appointment'])await db.exec(enrollment.match(new RegExp('CREATE OR REPLACE FUNCTION '+name+'\\([\\s\\S]*?\\$\\$;'))[0]);
  await db.exec(read('../sql/lesson-workflow.sql').match(/CREATE OR REPLACE FUNCTION xs_start_appointment_for_actor\([\s\S]*?\$\$;/)[0]);
  const app=source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1].replace("const DEFAULT_COMPANY_ID = '7pIbDBJmKx'","const DEFAULT_COMPANY_ID = 'co'");vm.runInContext(app+read('../cloud/lesson-workflow.js')+read('../cloud/lesson-courses.js'),c);version=(await one('SELECT "updatedAt"::text v FROM "CourseAppointment"')).v;

+ 49 - 2
scripts/tests/lesson-workflow.test.mjs

@@ -41,11 +41,11 @@ before(async()=>{
 });
 after(()=>db.close());
 let serial=0;
-async function appointment(start=true,timeOverride=''){
+async function appointment(start=true,timeOverride='',addonOverrides={}){
  const date=(await one("SELECT to_char(now() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD') AS day")).day;
  const time=timeOverride||String(5+(serial++%16)).padStart(2,'0')+':00';
  if(timeOverride)serial++;
- const entries=[{common:{title:'学员',subtitle:'词表'},addon:{szyh:'20',pl:'30',fxpl:'30',plxm:'老师',kcid:'100',yykcid:'100',dslx:'1',jffs:'线上',bindingId:binding,courseCategoryKey:'word',durationMinutes:30,deliveryMode:'online',creditCostsSnapshot:[{account:'Purse',amount:1}],teacherPaySnapshot:20,bxrq:date,sdsd:time,yysj:date+' '+time,dszt:'0'}}];
+ const entries=[{common:{title:'学员',subtitle:'词表'},addon:{szyh:'20',pl:'30',fxpl:'30',plxm:'老师',kcid:'100',yykcid:'100',dslx:'1',jffs:'线上',bindingId:binding,courseCategoryKey:'word',durationMinutes:30,deliveryMode:'online',creditCostsSnapshot:[{account:'Purse',amount:1}],teacherPaySnapshot:20,bxrq:date,sdsd:time,yysj:date+' '+time,dszt:'0',...addonOverrides}}];
  const result=(await one("SELECT xs_schedule_save('co','admin','{}',$1::jsonb,$2) result",[JSON.stringify(entries),'schedule-'+serial])).result;
  const common=result.targetIds[0];
  if(start) await db.query("SELECT xs_start_appointment('co',$1)",[common]);
@@ -53,6 +53,53 @@ async function appointment(start=true,timeOverride=''){
  return {...row,common};
 }
 const balance=async()=>Number((await one(`SELECT "legacyUserData"->>'Purse' balance FROM "_User" WHERE "objectId"='student'`)).balance);
+for(const category of ['primary_writing','middle_writing','high_writing'])for(const duration of [30,60]) {
+ test(`${category} ${duration}分钟:免教材排课、老师开始、直接结课、稍后点评只扣一次课时`,async()=>{
+  await db.exec('BEGIN');
+  try {
+   for(const account of ['SilverCoin','UserPoint']) {
+    await db.query('SELECT xs_credit_post($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)',['co','admin','fund','store',account,10,'','测试充值','转账','writing-fund-'+account]);
+    await db.query('SELECT xs_credit_post($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)',['co','admin','transfer','student',account,10,'','测试划拨','转账','writing-transfer-'+account]);
+   }
+   const account=duration===30?'Purse':'SilverCoin',hours=duration/60;
+   const costs=[{account,amount:1},{account:'UserPoint',amount:hours}];
+   const before=(await one('SELECT "legacyUserData" FROM "_User" WHERE "objectId"=$1',['student'])).legacyUserData;
+   const a=await appointment(false,'',{kcid:'0',yykcid:'0',bindingId:'',courseCategoryKey:category,durationMinutes:duration,dslx:duration===30?'1':'2',creditCostsSnapshot:costs});
+   await db.query('SELECT xs_start_appointment_for_actor($1,$2,$3,$4)',['co',a.common,'teacher',30]);
+   const started=await one('SELECT *,"updatedAt"::text version FROM "CourseAppointment" WHERE "objectId"=$1',[a.objectId]);
+   assert.equal(started.dszt,'10');assert.ok(started.kssj);assert.equal(started.bindingId,'');
+   const finish=()=>one('SELECT xs_lesson_complete($1,$2,$3,$4,$5,$6,NULL) result',['co',a.gid,'teacher',30,started.version,'writing-finish-'+a.gid]);
+   const done=(await finish()).result;assert.equal(done.status,30);assert.equal(done.feedbackStatus,'pending');assert.equal(done.periodDeducted,true);
+   await finish();
+   const report=await one('SELECT *,"updatedAt"::text revision FROM "LessonCompletionReport" WHERE "appointmentGeneralId"::text=$1',[a.gid]);
+   assert.equal(Number(report.vocabularyCount),0);assert.equal(Number(report.durationMinutes),duration);assert.equal(report.courseCategoryKey,category);
+   const tasks=await one('SELECT count(*)::int n FROM "MemoryPracticeRecord" WHERE "orderId"::text=$1',[a.gid]);assert.equal(tasks.n,0);
+   const edit=await one('SELECT xs_lesson_feedback_update($1,$2,$3,$4,$5,$6,$7::jsonb) result',['co',a.gid,'teacher',30,report.revision,'writing-feedback-'+a.gid,JSON.stringify({...feedback,contentSummary:'阅读短文并练习写作',coachComment:'表达清晰'})]);assert.equal(edit.result.saved,true);
+   const after=(await one('SELECT "legacyUserData" FROM "_User" WHERE "objectId"=$1',['student'])).legacyUserData;
+   assert.equal(Number(after[account]),Number(before[account])-1);assert.equal(Number(after.UserPoint),Number(before.UserPoint)-hours);
+   const ended=await one('SELECT dszt,kssj,jssj FROM "CourseAppointment" WHERE "objectId"=$1',[a.objectId]);assert.equal(ended.dszt,'30');assert.ok(ended.kssj);assert.ok(ended.jssj);
+   assert.equal((await one('SELECT count(*)::int n FROM "LessonRecord" WHERE yyds=$1',[a.gid])).n,1);
+   assert.equal((await one('SELECT "feedbackStatus" FROM "LessonCompletionReport" WHERE "objectId"=$1',[report.objectId])).feedbackStatus,'submitted');
+  } finally { await db.exec('ROLLBACK'); }
+ });
+}
+test('课堂附件随反馈保存与重开,旧客户端修改保留附件,显式移除不改变课时',async()=>{
+ await db.exec('BEGIN');
+ try{
+  const a=await appointment(true,'',{kcid:'0',yykcid:'0',bindingId:'',courseCategoryKey:'primary_writing'}),before=await balance();
+  const attachments=Array.from({length:10},(_,index)=>({id:'attachment-'+index,name:'课堂笔记'+index+'.pdf',url:'https://files.example/notes-'+index+'.pdf',contentType:'application/pdf',size:1024}));
+  assert.ok(JSON.stringify(attachments).length>1000);
+  await complete(a.gid,a.updatedAt,'attachment-complete','teacher',30,{...feedback,attachments});
+  const report=await one('SELECT *,"updatedAt"::text revision FROM "LessonCompletionReport" WHERE "appointmentGeneralId"::text=$1',[a.gid]);assert.deepEqual(report.attachments,attachments);
+  const edit=async(f,key,version)=>one('SELECT xs_lesson_feedback_update($1,$2,$3,$4,$5,$6,$7::jsonb) result',['co',a.gid,'teacher',30,version,key,JSON.stringify(f)]);
+  await edit({...feedback,coachComment:'旧客户端更新文字'},'attachments-preserve',report.revision);
+  const preserved=await one('SELECT attachments,"updatedAt"::text revision FROM "LessonCompletionReport" WHERE "objectId"=$1',[report.objectId]);assert.deepEqual(preserved.attachments,attachments);
+  await edit({...feedback,attachments:[]},'attachments-remove',preserved.revision);
+  assert.deepEqual((await one('SELECT attachments FROM "LessonCompletionReport" WHERE "objectId"=$1',[report.objectId])).attachments,[]);
+  assert.equal(await balance(),before-1);
+  assert.deepEqual((await one('SELECT result FROM "LessonWorkflowCommand" WHERE "requestKey"=$1',['attachments-remove'])).result.previousFeedback.attachments,attachments);
+ }finally{await db.exec('ROLLBACK');}
+});
 test('老师一次结课生成报告和学习快照,重复并发只扣一次;学生随后评价不改结课状态',async()=>{
  const a=await appointment(), before=await balance();
  await db.query('INSERT INTO "DailyStudyRecord" ("objectId",company,id,"userId",dsid,xxqs,fxrl,"updatedAt") VALUES($1,$2,$3,$4,$5,$6,$7,now())',['study','co',999,20,a.gid,'[{"GeneralID":1,"Title":"apple","check":1}]','20300101,20300102']);

+ 24 - 0
scripts/tests/member-enrollment-gateway.test.mjs

@@ -7,6 +7,30 @@ const ops=source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/
 const extension=readFileSync(new URL('../cloud/member-enrollment.js',import.meta.url),'utf8');
 function gateway(){const c={Date};vm.createContext(c);vm.runInContext(ops+'\n'+extension,c);return c;}
 const plain=x=>JSON.parse(JSON.stringify(x));
+test('截图中的每天、每隔1至5天和每隔一周按空闲天数生成跨月排课日期',()=>{
+ const c=gateway();
+ const cases=[
+  ['daily',['2026-10-29','2026-10-30','2026-10-31']],
+  ['interval_1',['2026-10-29','2026-10-31','2026-11-02']],
+  ['interval_2',['2026-10-29','2026-11-01','2026-11-04']],
+  ['interval_3',['2026-10-29','2026-11-02','2026-11-06']],
+  ['interval_4',['2026-10-29','2026-11-03','2026-11-08']],
+  ['interval_5',['2026-10-29','2026-11-04','2026-11-10']],
+  ['weekly',['2026-10-29','2026-11-05','2026-11-12']],
+ ];
+ for(const [recurrence,dates] of cases){
+  const rows=c.recurrenceCandidates({date:'2026-10-29',startTime:'09:00',durationMinutes:60,recurrence,occurrences:3});
+  assert.deepEqual(Array.from(rows,row=>row.start.toISOString().slice(0,10)),dates,recurrence);
+  assert.ok(rows.every(row=>row.start.toISOString().endsWith('T01:00:00.000Z')&&row.end-row.start===3600000));
+ }
+ assert.equal(c.recurrenceCandidates({date:'2026-10-29',startTime:'09:00',recurrence:'once',occurrences:3}).length,1);
+});
+for(const duration of [30,60])for(const gap of [0,30])test(`${duration}分钟课同一天间隔${gap}分钟从上节结束计算,预检无批内重叠`,async()=>{
+ const c=setup();c.Psql={oneOrNone:async()=>({nodeName:'词表'})};c.enrollmentBindings=async()=>[{bindingId:'b',courseId:100,categoryKey:'word'}];c.allAppointmentRows=async()=>[];c.availabilityRows=async()=>[];
+ const result=await c.preflightAppointments({}, {},{studentId:20,courseId:100,bindingId:'b',durationMinutes:duration,deliveryMode:'online',date:'2026-10-29',startTime:'09:00',recurrence:'same_day_gap_'+gap,occurrences:3});
+ assert.equal(result.availableCount,3);assert.equal(result.conflictCount,0);assert.equal(result.candidates.length,3);
+ for(let index=1;index<3;index++)assert.equal(Date.parse(result.candidates[index].startsAt)-Date.parse(result.candidates[index-1].endsAt),gap*60000);
+});
 function setup(){const c=gateway();c.companyIdOf=()=> 'co';c.requireRole=()=>{};c.reasonOf=i=>i.reason;c.userByLegacyId=async()=>({objectId:'student',legacyUserData:{Purse:5,SilverCoin:5,UserPoint:5,UserExp:1}});c.reservedCreditsFor=async()=>({});c.teachingCategoryRows=async()=>vm.runInContext('TEACHING_CATEGORY_DEFAULTS',c);c.teachingRuleRows=async()=>c.defaultTeachingRules();c.businessDate=()=> '2026-09-09';c.enrollmentBindings=async()=>[];c.enrollmentCatalog=async()=>['primary','middle','high'].map((stageKey,index)=>({courseId:100+index,courseName:stageKey,stageKey,wordCount:20,active:true}));return c;}
 test('六个新分类完整可选,语法写作仅选择对应学阶',async()=>{const c=setup();for(const category of await c.teachingCategoryRows()){const options=await c.enrollmentOptions({}, {studentId:20,categoryKey:category.key});assert.equal(options.categories.length,6);assert.equal(options.eligible,true,category.key);assert.equal(options.items.length,category.stageKey?1:3);if(category.stageKey)assert.equal(options.items[0].stageKey,category.stageKey);}});
 test('零余额、配套陪练不足和空内容分别说明原因',async()=>{const c=setup();c.userByLegacyId=async()=>({legacyUserData:{}});let o=await c.enrollmentOptions({}, {studentId:20,categoryKey:'word'});assert.equal(o.eligible,false);assert.match(o.reason,/课时|陪练/);c.userByLegacyId=async()=>({legacyUserData:{Purse:5,SilverCoin:5}});o=await c.enrollmentOptions({}, {studentId:20,categoryKey:'word'});assert.equal(o.eligible,false);c.userByLegacyId=async()=>({legacyUserData:{Purse:5,SilverCoin:5,UserPoint:5}});c.enrollmentCatalog=async()=>[{courseId:100,wordCount:0,active:true},{courseId:101,wordCount:5,active:false}];o=await c.enrollmentOptions({}, {studentId:20,categoryKey:'word'});assert.match(o.items[0].reason,/空/);assert.match(o.items[1].reason,/停用/);});

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio