Bladeren bron

feat(mobile): show completion feedback in a responsive dialog after ending lesson

彭峰 1 maand geleden
bovenliggende
commit
a0b97f1193

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

@@ -1,9 +1,11 @@
 import { provideHttpClient } from '@angular/common/http';
 import { signal } from '@angular/core';
 import { TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
 import { provideRouter, Router } from '@angular/router';
 import { RouterTestingHarness } from '@angular/router/testing';
-import { Observable, of } from 'rxjs';
+import { Observable, of, Subject } from 'rxjs';
+import { AppointmentPageComponent } from '../features/appointment/appointment-page.component';
 import { routes } from '../app.routes';
 import { ApiService } from './api.service';
 import { ApiEnvelope, AppUser } from './models';
@@ -18,6 +20,9 @@ class LearningModulesApiService {
   lastLearningWordUpdate: Record<string, unknown> = {};
   lastMemoryParams: Record<string, unknown> = {};
   lastStandaloneLearningRecord: Record<string, unknown> = {};
+  appointmentStatus = 0;
+  completionRequests: Record<string, unknown>[] = [];
+  completionResponse = new Subject<ApiEnvelope<unknown>>();
   unitRows: Record<string, unknown>[] = [
     { NodeID: 811, NodeName: 'Unit 1', yx_word: 1, total: 20 },
     { NodeID: 812, NodeName: 'Unit 2', yx_word: 0, total: 18 },
@@ -31,7 +36,7 @@ class LearningModulesApiService {
         HoneyName: '张雅茜',
         kcid: 801,
         yykcmc: '七年级上册',
-        dszt: 0,
+        dszt: this.appointmentStatus,
         dslx: 2,
         bxrq: '2026-08-10',
         sdsd: '19:00',
@@ -114,6 +119,10 @@ class LearningModulesApiService {
   }
 
   post<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
+    if (action === 'e_order_complete_feedback') {
+      this.completionRequests.push(params);
+      return this.completionResponse as Observable<ApiEnvelope<T>>;
+    }
     if (action === 'e_add_words') {
       this.lastLearningWordUpdate = params;
       return this.result<T>({ affected: String(params['wordsId'] ?? '').split(',').filter(Boolean).length });
@@ -740,6 +749,88 @@ describe('home learning modules', () => {
     }
   });
 
+  it('opens feedback only on ending a lesson and submits once after validation', async () => {
+    user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
+    session.groupId.set(3);
+    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    api.appointmentStatus = 10;
+    const harness = await RouterTestingHarness.create();
+    await harness.navigateByUrl('/pages/shop/get_order?id=901');
+    const component = harness.fixture.debugElement.query(By.directive(AppointmentPageComponent)).componentInstance as AppointmentPageComponent;
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    const dialog = harness.routeNativeElement?.querySelector('dialog') as HTMLDialogElement;
+    const endButton = harness.routeNativeElement?.querySelector('.appointment-actions .primary-button') as HTMLButtonElement;
+    expect(dialog.open).toBeFalse();
+    endButton.click();
+    harness.fixture.detectChanges();
+    expect(dialog.open).toBeTrue();
+    expect(api.completionRequests.length).toBe(0);
+
+    const submit = dialog.querySelector('.feedback-actions .primary-button') as HTMLButtonElement;
+    submit.click();
+    harness.fixture.detectChanges();
+    expect(dialog.querySelector('[role="alert"]')?.textContent).toContain('请填写本次学习内容');
+    expect(api.completionRequests.length).toBe(0);
+    const summary = dialog.querySelector('textarea') as HTMLTextAreaElement;
+    summary.value = 'Unit 3 词汇';
+    summary.dispatchEvent(new Event('input'));
+    await harness.fixture.whenStable();
+    (dialog.querySelector('.feedback-actions .secondary-button') as HTMLButtonElement).click();
+    expect(dialog.open).toBeFalse();
+    expect(api.appointmentStatus).toBe(10);
+    endButton.click();
+    harness.fixture.detectChanges();
+    expect((dialog.querySelector('textarea') as HTMLTextAreaElement).value).toBe('Unit 3 词汇');
+    component.reviewStatus = '完成';
+    component.trainingStatus = '需要巩固';
+    component.trainingSummary = '下次复习 Unit 3';
+    component.noHomework = true;
+    submit.click();
+    submit.click();
+    harness.fixture.detectChanges();
+    expect(api.completionRequests.length).toBe(1);
+    expect(JSON.parse(String(api.completionRequests[0]['feedback']))).toEqual(jasmine.objectContaining({ contentSummary: 'Unit 3 词汇', noHomework: true, homework: '无作业' }));
+    expect(submit.disabled).toBeTrue();
+    dialog.dispatchEvent(new Event('cancel', { cancelable: true }));
+    expect(dialog.open).toBeTrue();
+    api.appointmentStatus = 11;
+    api.completionResponse.next({ retcode: 1, result: {} });
+    api.completionResponse.complete();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelector('dialog')).toBeNull();
+    expect(harness.routeNativeElement?.textContent).toContain('等待学员确认');
+  });
+
+  it('keeps feedback editable after a failed submission and allows closing with Escape', async () => {
+    user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
+    session.groupId.set(3);
+    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    api.appointmentStatus = 10;
+    const harness = await RouterTestingHarness.create();
+    await harness.navigateByUrl('/pages/shop/get_order?id=901');
+    const component = harness.fixture.debugElement.query(By.directive(AppointmentPageComponent)).componentInstance as AppointmentPageComponent;
+    await harness.fixture.whenStable();
+    component.openCompletionFeedback();
+    component.contentSummary = 'Unit 3';
+    component.reviewStatus = '完成';
+    component.trainingStatus = '正常进行';
+    component.trainingSummary = '继续 Unit 4';
+    component.noHomework = true;
+    component.finishCoachLesson();
+    api.completionResponse.error(new Error('提交失败,请重试'));
+    harness.fixture.detectChanges();
+    const dialog = harness.routeNativeElement?.querySelector('dialog') as HTMLDialogElement;
+    expect(dialog.open).toBeTrue();
+    expect(dialog.querySelector('[role="alert"]')?.textContent).toContain('提交失败,请重试');
+    expect((dialog.querySelector('fieldset') as HTMLFieldSetElement).disabled).toBeFalse();
+    expect(component.contentSummary).toBe('Unit 3');
+    expect(api.appointmentStatus).toBe(10);
+    dialog.dispatchEvent(new Event('cancel', { cancelable: true }));
+    expect(dialog.open).toBeFalse();
+  });
+
   it('counts words marked as known as completed and persists them for the student', async () => {
     user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
     session.groupId.set(3);

+ 34 - 1
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.html

@@ -50,6 +50,39 @@
       <section class="comment-panel"><label><span>陪练点评</span><textarea [(ngModel)]="coachComment" maxlength="200" placeholder="请输入本次陪练点评"></textarea></label></section>
     }
 
+    @if (!isStudentView && status() === 10) {
+      <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>
+            <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>
+          <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">
+              <label><span>复习 / 生词本情况 <b>*</b></span><select [(ngModel)]="reviewStatus" aria-required="true"><option value="">请选择</option><option>完成</option><option>部分完成</option><option>未复习</option><option>无复习任务</option></select></label>
+              <label><span>复习说明</span><input [(ngModel)]="reviewSummary" maxlength="200" placeholder="可补充复习情况" /></label>
+            </div>
+            <div class="feedback-grid">
+              <label><span>训练进程和状态 <b>*</b></span><select [(ngModel)]="trainingStatus" aria-required="true"><option value="">请选择</option><option>正常进行</option><option>需要巩固</option><option>已完成</option><option>暂停</option></select></label>
+              <label><span>进度说明 <b>*</b></span><input [(ngModel)]="trainingSummary" maxlength="200" aria-required="true" placeholder="说明当前进度与下次起点" /></label>
+            </div>
+            <div class="feedback-homework">
+              <label><span>作业安排 @if (!noHomework) { <b>*</b> }</span><textarea [(ngModel)]="homework" [disabled]="noHomework" [attr.aria-required]="!noHomework" maxlength="300" placeholder="填写作业内容和要求"></textarea></label>
+              <label class="checkbox-row"><input type="checkbox" [(ngModel)]="noHomework" /><span>本次无作业</span></label>
+            </div>
+            <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()">返回课程</button>
+            <button type="button" class="primary-button" [disabled]="submitting()" (click)="finishCoachLesson()">{{ submitting() ? '正在提交…' : '提交并结束课程' }}</button>
+          </footer>
+        </section>
+      </dialog>
+    }
+
     @if (isStudentView && status() === 11) {
       <section class="comment-panel">
         <label><span>本次评分</span><select [(ngModel)]="studentScore">@for (score of [5,4,3,2,1]; track score) { <option [ngValue]="score">{{ score }} 分</option> }</select></label>
@@ -69,7 +102,7 @@
 
     <div class="appointment-actions">
       @if (!isStudentView && status() === 0) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="startOrContinue()"><lucide-icon [img]="icons.Play" [size]="18" />开始课程</button> }
-      @if (!isStudentView && status() === 10) { <button type="button" class="secondary-button" [disabled]="submitting()" (click)="startOrContinue()">继续课程<lucide-icon [img]="icons.ChevronRight" [size]="18" /></button><button type="button" class="primary-button" [disabled]="submitting()" (click)="finishCoachLesson()">结束学习</button> }
+      @if (!isStudentView && status() === 10) { <button type="button" class="secondary-button" [disabled]="submitting()" (click)="startOrContinue()">继续课程<lucide-icon [img]="icons.ChevronRight" [size]="18" /></button><button type="button" class="primary-button" [disabled]="submitting()" (click)="openCompletionFeedback()">结束课程</button> }
       @if (!isStudentView && status() === 11) { <span class="workflow-hint">等待学员确认并评价</span> }
       @if (!isStudentView && status() === 20) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="completeCoachOrder()"><lucide-icon [img]="icons.Save" [size]="18" />生成学习报告</button> }
       @if (isStudentView && status() === 11) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="finishStudentLesson()"><lucide-icon [img]="icons.GraduationCap" [size]="18" />确认学习结束</button> }

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

@@ -15,6 +15,28 @@
 .course-section select, .comment-panel select, .comment-panel textarea { width: 100%; min-height: 45px; padding: 9px 11px; color: var(--ink); background: white; border: 1px solid var(--line); border-radius: var(--radius-sm); }
 .comment-panel label + label { margin-top: 14px; }
 .comment-panel textarea { min-height: 110px; resize: vertical; }
+.completion-dialog { width: calc(100% - 2 * var(--page-gutter, 16px)); max-width: 720px; max-height: calc(100dvh - 40px); margin: auto; padding: 0; overflow: auto; overscroll-behavior: contain; color: var(--ink); background: white; border: 1px solid var(--line); border-radius: var(--panel-radius, 8px); box-shadow: var(--shadow-lg); }
+.completion-dialog::backdrop { background: rgb(24 54 47 / 42%); }
+.completion-feedback { padding: 22px; }
+.completion-feedback header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
+.completion-feedback h2 { margin: 0; font-size: 20px; }
+.completion-feedback header p { margin: 7px 0 0; color: var(--ink-soft); font-size: 13px; line-height: 1.6; }
+.feedback-close { display: grid; width: 36px; height: 36px; flex: 0 0 36px; padding: 0; color: var(--ink-soft); background: var(--surface-muted); border: 1px solid var(--line); border-radius: var(--radius-sm); place-items: center; }
+.feedback-hint { margin: 18px 0; padding: 11px 12px; color: var(--ink-soft); background: var(--surface-muted); border: 1px solid var(--line); border-radius: var(--radius-sm); font-size: 12px; line-height: 1.6; }
+.feedback-fields { display: grid; min-width: 0; margin: 0; padding: 0; border: 0; gap: 18px; }
+.feedback-fields label { display: grid; min-width: 0; gap: 8px; }
+.feedback-fields label > span { color: var(--ink-soft); font-size: 13px; }
+.feedback-fields label b, .feedback-error { color: #b44135; }
+.feedback-fields label small { margin-left: 4px; font-size: 11px; }
+.feedback-fields input, .feedback-fields select, .feedback-fields textarea { width: 100%; min-width: 0; min-height: 45px; padding: 10px 12px; color: var(--ink); background: white; border: 1px solid var(--line); border-radius: var(--radius-sm); font-size: 14px; }
+.feedback-fields textarea { min-height: 88px; resize: vertical; }
+.feedback-fields input:disabled, .feedback-fields select:disabled, .feedback-fields textarea:disabled { color: var(--ink-soft); background: var(--surface-muted); }
+.feedback-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
+.feedback-fields .checkbox-row { display: flex; align-items: center; margin-top: 10px; gap: 8px; }
+.feedback-fields .checkbox-row input { width: 18px; min-height: 18px; margin: 0; }
+.feedback-error { margin: 16px 0 0; font-size: 13px; line-height: 1.6; }
+.feedback-actions { display: flex; margin-top: 22px; padding-top: 16px; border-top: 1px solid var(--line); gap: 12px; }
+.feedback-actions button { flex: 1; min-height: 46px; }
 .unit-heading { display: flex; align-items: flex-end; justify-content: space-between; margin: 19px 0 10px; gap: 12px; }
 .unit-heading span { color: var(--ink-soft); font-size: 10px; text-align: right; }
 .unit-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
@@ -40,3 +62,4 @@
 .appointment-actions button { display: flex; min-height: 49px; align-items: center; justify-content: center; flex: 1; gap: 6px; }
 .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 { grid-template-columns: 1fr; } }

+ 47 - 8
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts

@@ -1,5 +1,5 @@
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, ElementRef, inject, OnInit, signal, viewChild } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { BookCheck, Check, ChevronRight, CircleHelp, Clock3, Flag, GraduationCap, LucideAngularModule, Play, Save, Star, X } from 'lucide-angular';
@@ -33,11 +33,21 @@ export class AppointmentPageComponent implements OnInit {
   readonly submitting = signal(false);
   readonly error = signal('');
   readonly detailWarning = signal('');
+  readonly feedbackError = signal('');
+  readonly completionDialog = viewChild<ElementRef<HTMLDialogElement>>('completionDialog');
   readonly icons = { BookCheck, Check, ChevronRight, CircleHelp, Clock3, Flag, GraduationCap, Play, Save, Star, X };
   readonly status = computed(() => Number(this.order()['dszt'] ?? this.order()['status'] ?? 0));
 
   selectedCourse = '';
   coachComment = '';
+  contentSummary = '';
+  reviewStatus = '';
+  reviewSummary = '';
+  trainingStatus = '';
+  trainingSummary = '';
+  homework = '';
+  noHomework = false;
+  notes = '';
   studentScore = 5;
   studentComment = '';
 
@@ -191,13 +201,42 @@ export class AppointmentPageComponent implements OnInit {
     });
   }
 
-  async finishCoachLesson(): Promise<void> {
-    if (!(await this.platform.confirm('即将结束本次学习,确认继续?'))) return;
-    if (this.isWriting) {
-      this.saveWritingLearningRecord(() => this.finishCoachStatus());
-    } else {
-      this.finishCoachStatus();
-    }
+  openCompletionFeedback(): void {
+    if (this.isStudentView || this.status() !== 10 || this.submitting()) return;
+    this.feedbackError.set('');
+    this.completionDialog()?.nativeElement.showModal();
+  }
+
+  closeCompletionFeedback(event?: Event): void {
+    event?.preventDefault();
+    if (!this.submitting()) this.completionDialog()?.nativeElement.close();
+  }
+
+  finishCoachLesson(): void {
+    if (this.submitting() || !this.completionDialog()?.nativeElement.open) return;
+    this.feedbackError.set('');
+    if (!this.contentSummary.trim()) { this.feedbackError.set('请填写本次学习内容'); return; }
+    if (!this.reviewStatus) { this.feedbackError.set('请选择复习或生词本情况'); return; }
+    if (!this.trainingStatus || !this.trainingSummary.trim()) { this.feedbackError.set('请选择训练状态并填写进度说明'); return; }
+    if (!this.noHomework && !this.homework.trim()) { this.feedbackError.set('请填写作业安排,或选择“无作业”'); return; }
+    this.submitting.set(true);
+    this.api.post('e_order_complete_feedback', {
+      orderId: this.orderId,
+      content: JSON.stringify({ GeneralID: this.orderId, UpDateTime: this.dateTime() }),
+      feedback: JSON.stringify({
+        contentSummary: this.contentSummary.trim(),
+        reviewStatus: this.reviewStatus,
+        reviewSummary: this.reviewSummary.trim(),
+        trainingStatus: this.trainingStatus,
+        trainingSummary: this.trainingSummary.trim(),
+        homework: this.noHomework ? '无作业' : this.homework.trim(),
+        noHomework: this.noHomework,
+        notes: this.notes.trim(),
+      }),
+    }).pipe(finalize(() => this.submitting.set(false))).subscribe({
+      next: () => { this.completionDialog()?.nativeElement.close(); this.platform.toast('完课反馈已提交'); this.load(); },
+      error: (error: ApiError) => this.feedbackError.set(error.message),
+    });
   }
 
   completeCoachOrder(): void {