Răsfoiți Sursa

fix(mobile): correct learning completion, coach record access and return navigation

彭峰 1 lună în urmă
părinte
comite
1b30681943

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

@@ -4,8 +4,9 @@ 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, Subject } from 'rxjs';
+import { Observable, of, Subject, throwError } from 'rxjs';
 import { AppointmentPageComponent } from '../features/appointment/appointment-page.component';
+import { LearningPageComponent } from '../features/learning/learning-page.component';
 import { routes } from '../app.routes';
 import { ApiService } from './api.service';
 import { ApiEnvelope, AppUser } from './models';
@@ -23,12 +24,22 @@ class LearningModulesApiService {
   appointmentStatus = 0;
   completionRequests: Record<string, unknown>[] = [];
   completionResponse = new Subject<ApiEnvelope<unknown>>();
+  lastAppointmentRecordParams: Record<string, unknown> = {};
+  studyUpdates: Record<string, 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 },
   ];
 
   get<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
+    if (action === 'content_list' && Number(params['ModelID'] ?? params['modelId']) === 56 && String(params['myfield2']).includes('dsid=')) {
+      this.lastAppointmentRecordParams = params;
+      if (!String(params['myfield2']).includes('|pl=300')) return throwError(() => new Error('无权查看该学员的内容记录'));
+      return this.result<T>([{ GeneralID: 9901 }]);
+    }
+    if (action === 'e_record_detail') {
+      return this.result<T>([{ GeneralID: 1001, NodeID: 811, Title: 'apple', sy: '苹果', check: 0 }]);
+    }
     if (action === 'e_order_detail') {
       return this.result<T>([{
         GeneralID: 901,
@@ -119,6 +130,10 @@ class LearningModulesApiService {
   }
 
   post<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
+    if (action === 'stu_record_update_v2') {
+      this.studyUpdates.push(params);
+      return this.result<T>({ GeneralID: 9901 });
+    }
     if (action === 'e_order_complete_feedback') {
       this.completionRequests.push(params);
       return this.completionResponse as Observable<ApiEnvelope<T>>;
@@ -859,6 +874,122 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelector('.pending-column')).toBeNull();
   });
 
+  it('finishes the last vocabulary group, loads the coach record and opens completion feedback', async () => {
+    user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
+    session.groupId.set(3);
+    session.student.set({ userId: 100, honeyName: '张雅茜', groupId: 1 });
+    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    api.appointmentStatus = 10;
+    const word = { GeneralID: 1001, NodeID: 811, Title: 'apple', sy: '苹果' };
+    localStorage.setItem('xiaoshu.learning._page_word_orilist', JSON.stringify([[word]]));
+    localStorage.setItem('xiaoshu.learning._page_current_learn', '1');
+    // Stale page-count metadata must not create a nonexistent next group.
+    localStorage.setItem('xiaoshu.learning._page_total_page', '2');
+    localStorage.removeItem('xiaoshu.learning.learn_mode');
+    const harness = await RouterTestingHarness.create();
+    const router = TestBed.inject(Router);
+    const navigate = spyOn(router, 'navigate').and.callThrough();
+    await harness.navigateByUrl('/pages/stu/stu_con?nids=811&pnid=801&orderId=901');
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    const learning = () => harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    learning().skipStudyList();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    expect(router.url).toContain('/pages/stu/stu3');
+    const pair = learning().words()[0];
+    learning().chooseMatch('left', pair);
+    learning().chooseMatch('right', pair);
+    learning().continueAfterStage();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    expect(router.url).toContain('/pages/stu/stu4');
+    learning().chooseMeaning(learning().words()[0]);
+    learning().continueAfterStage();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    expect(router.url).toContain('/pages/stu/pl_stu5');
+    learning().skipSpellingWord();
+    harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelector('.compact-report')?.textContent).toContain('本次所选单词已全部完成');
+    expect(harness.routeNativeElement?.querySelector('.compact-report button')?.textContent).toContain('查看学习结果');
+    expect(learning().hasNextWordBatch).toBeFalse();
+    learning().continueAfterStage();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    harness.fixture.detectChanges();
+    expect(router.url).toContain('/pages/stu/pl_end');
+    expect(api.lastAppointmentRecordParams['myfield2']).toBe('UserId=100|dsid=901|pl=300');
+    expect(harness.routeNativeElement?.querySelector('.empty-state.error')).toBeNull();
+    expect(learning().words()[0].word).toBe('apple');
+    learning().markAllKnown();
+    learning().submitReview();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    harness.fixture.detectChanges();
+    expect(router.url).toBe('/pages/shop/get_order?id=901&feedback=1');
+    expect((harness.routeNativeElement?.querySelector('dialog') as HTMLDialogElement).open).toBeTrue();
+    expect(api.appointmentStatus).toBe(10);
+    expect(api.completionRequests.length).toBe(0);
+    expect(api.studyUpdates.length).toBe(2);
+    expect(JSON.parse(String(api.studyUpdates[1]['addon']))['ygg']).toBe(1);
+    expect(navigate.calls.all().every(call => call.args[1]?.replaceUrl === true)).toBeTrue();
+  }, 20000);
+
+  it('advances only to a real next group and returns from the last stage to its course in one click', async () => {
+    user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
+    session.groupId.set(3);
+    session.student.set({ userId: 100, honeyName: '张雅茜', groupId: 1 });
+    const pages = [[{ GeneralID: 1001, Title: 'apple', sy: '苹果' }], [{ GeneralID: 1002, Title: 'banana', sy: '香蕉' }]];
+    localStorage.setItem('xiaoshu.learning._page_word_orilist', JSON.stringify(pages));
+    localStorage.setItem('xiaoshu.learning._page_total_page', '0');
+    localStorage.setItem('xiaoshu.learning._page_current_learn', '1');
+    const harness = await RouterTestingHarness.create();
+    const router = TestBed.inject(Router);
+    await harness.navigateByUrl('/pages/stu/pl_stu5?nids=811&pnid=801&orderId=901');
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    const component = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    component.skipSpellingWord();
+    harness.fixture.detectChanges();
+    expect(component.stageActionLabel).toBe('学习下一组单词');
+    component.continueAfterStage();
+    component.continueAfterStage();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    expect(router.url).toContain('/pages/stu/stu_con');
+    const next = harness.fixture.debugElement.query(By.directive(LearningPageComponent)).componentInstance as LearningPageComponent;
+    expect(next.words()[0].word).toBe('banana');
+    expect((TestBed.inject(ApiService) as unknown as LearningModulesApiService).studyUpdates.length).toBe(0);
+    (harness.routeNativeElement?.querySelector('.app-header button[aria-label="返回"]') as HTMLButtonElement).click();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    expect(router.url).toBe('/pages/shop/get_order?id=901');
+    for (const key of ['_page_word_orilist', '_page_total_page', '_page_current_learn']) localStorage.removeItem(`xiaoshu.learning.${key}`);
+  });
+
+  it('resolves the appointment student before reading end results and offers an exit on failure', async () => {
+    user.set({ userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 });
+    session.groupId.set(3);
+    session.student.set({ userId: 999, honeyName: '之前的学员', groupId: 1 });
+    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    const get = api.get.bind(api);
+    spyOn(api, 'get').and.callFake((action, params) => action === 'e_record_detail'
+      ? throwError(() => new Error('学习记录暂时无法加载')) : get(action, params));
+    const harness = await RouterTestingHarness.create();
+    await harness.navigateByUrl('/pages/stu/pl_end?id=901');
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    harness.fixture.detectChanges();
+    expect(session.student()?.userId).toBe(100);
+    expect(api.lastAppointmentRecordParams['myfield2']).toBe('UserId=100|dsid=901|pl=300');
+    expect(harness.routeNativeElement?.querySelector('.empty-state.error')?.textContent).toContain('学习记录暂时无法加载');
+    (harness.routeNativeElement?.querySelector('.empty-state.error .primary-button') as HTMLButtonElement).click();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    expect(TestBed.inject(Router).url).toBe('/pages/shop/get_order?id=901');
+  });
+
   it('shows learning history in pages of twenty records', async () => {
     user.set({ userId: 777, userName: 'paged-student', honeyName: '分页学员', groupId: 1 });
     session.groupId.set(1);

+ 26 - 0
projects/xiaoshu-mobile/src/app/core/learning-navigation.ts

@@ -0,0 +1,26 @@
+import { ParamMap } from '@angular/router';
+
+const STAGE_PATHS = new Set([
+  'pages/stu/stu_con', 'pages/stu/stu3', 'pages/stu/stu4', 'pages/stu/stu5',
+  'pages/stu/pl_stu5', 'pages/stu/stu6', 'pages/stu/pl_end', 'pages/stu/stu_ok',
+]);
+const RETURN_PATHS = new Set([
+  '/pages/stu/stu_abc', '/pages/stu/stu_list', '/pages/member/starshop', '/pages/his/his_words', '/pages/his/his',
+]);
+
+export function isLearningStage(path: string, params: ParamMap): boolean {
+  return params.get('readonly') !== '1' && (STAGE_PATHS.has(path)
+    || (path === 'pages/stu/stu_abc' && Boolean(params.get('orderId'))));
+}
+
+export function learningReturnUrl(params: ParamMap, savedUrl?: unknown): string {
+  const orderId = params.get('orderId');
+  if (orderId) return `/pages/shop/get_order?id=${encodeURIComponent(orderId)}`;
+  if (typeof savedUrl === 'string' && RETURN_PATHS.has(savedUrl.split('?')[0])) return savedUrl;
+  const nids = params.get('nids') || params.get('nid');
+  if (!nids) return '/pages/home/cart';
+  const query = new URLSearchParams({ nids });
+  const course = params.get('pnid');
+  if (course) query.set('pnid', course);
+  return `/pages/stu/stu_abc?${query}`;
+}

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

@@ -1,5 +1,5 @@
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, computed, ElementRef, inject, OnInit, signal, viewChild } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, effect, 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';
@@ -57,6 +57,13 @@ export class AppointmentPageComponent implements OnInit {
   private readonly session = inject(SessionService);
   private readonly platform = inject(PlatformService);
 
+  constructor() {
+    effect(() => {
+      const dialog = this.completionDialog()?.nativeElement;
+      if (dialog && this.route.snapshot.queryParamMap.get('feedback') === '1') dialog.showModal();
+    });
+  }
+
   ngOnInit(): void {
     this.load();
   }
@@ -196,6 +203,7 @@ export class AppointmentPageComponent implements OnInit {
         return;
       }
       void this.router.navigate(['/pages/stu/stu_abc'], {
+        replaceUrl: true,
         queryParams: { nids: [...this.selectedUnits()].join(','), pnid: this.selectedCourse, orderId: this.orderId },
       });
     });
@@ -437,7 +445,7 @@ export class AppointmentPageComponent implements OnInit {
   private loadLearningRecord(): void {
     const studentId = this.appointmentStudentId();
     if (!studentId) return;
-    this.api.get<unknown>('content_list', { ModelID: 56, psize: 1, myfield: `dsid=${this.orderId}`, myfield2: `UserId=${studentId}` }).subscribe({
+    this.api.get<unknown>('content_list', { ModelID: 56, psize: 1, myfield: `dsid=${this.orderId}`, myfield2: `UserId=${studentId}|pl=${this.session.user()?.userId}` }).subscribe({
       next: ({ result }) => this.learningRecord.set(this.findList(result)[0] ?? {}),
       error: () => undefined,
     });

+ 4 - 3
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html

@@ -7,6 +7,7 @@
         <lucide-icon [img]="icons.CircleHelp" [size]="32" />
         <p>{{ error() }}</p>
         <button type="button" class="secondary-button" (click)="load()">重新加载</button>
+        @if (canReturnToLearningEntry) { <button type="button" class="primary-button" (click)="returnToLearningEntry()">返回课程</button> }
       </div>
     </div>
   } @else if (isNodePage) {
@@ -257,7 +258,7 @@
     }
   } @else if (isSpellingPage) {
     @if (completed()) {
-      <ng-container *ngTemplateOutlet="stageComplete; context: { title: '拼写完成', text: '本轮单词已全部完成' }" />
+      <ng-container *ngTemplateOutlet="stageComplete; context: { title: '拼写完成', text: hasNextWordBatch ? '本组已完成,可以继续学习下一组单词' : '本次所选单词已全部完成' }" />
     } @else {
       @if (current(); as word) {
         <div class="study-progress"><div><span [style.width.%]="progress()"></span></div><strong>{{ currentIndex() + 1 }} / {{ words().length }}</strong></div>
@@ -297,7 +298,7 @@
     </div>
     <div class="sticky-actions">
       <button type="button" class="secondary-button" (click)="markAllKnown()">{{ reviewCount(1) === words().length ? '取消全选' : '全部掌握' }}</button>
-      <button type="button" class="primary-button" [disabled]="submitting()" (click)="submitReview()">{{ meta.path === 'pages/stu/pl_end' ? '结束课程' : '提交检测' }}</button>
+      <button type="button" class="primary-button" [disabled]="submitting()" (click)="submitReview()">{{ submitting() ? '正在保存…' : meta.path === 'pages/stu/pl_end' ? '结束课程' : '提交检测' }}</button>
     </div>
   } @else if (isCalendarPage) {
     <section class="memory-overview" aria-label="抗遗忘计划概览">
@@ -492,7 +493,7 @@
     <h2>{{ title }}</h2>
     <p>{{ text }}</p>
     <button type="button" class="primary-button" [disabled]="submitting()" (click)="continueAfterStage()">
-      {{ submitting() ? '正在保存…' : '继续下一关' }}
+      {{ submitting() ? '正在保存…' : stageActionLabel }}
       <lucide-icon [img]="icons.ChevronRight" [size]="18" />
     </button>
   </div>

+ 91 - 87
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -23,7 +23,7 @@ import {
   Volume2,
   X,
 } from 'lucide-angular';
-import { catchError, finalize, forkJoin, Observable, of, switchMap } from 'rxjs';
+import { catchError, finalize, forkJoin, Observable, of, switchMap, throwError } from 'rxjs';
 import { GAME_AUDIO } from '../../core/app.constants';
 import { ApiError, ApiService } from '../../core/api.service';
 import {
@@ -38,6 +38,7 @@ import {
 } from '../../core/anti-forget';
 import { AppUser, PageMeta } from '../../core/models';
 import { canStartTeacherLedLearning, isTeacherLedLearningPath } from '../../core/learning-access';
+import { isLearningStage, learningReturnUrl } from '../../core/learning-navigation';
 import { PlatformService } from '../../core/platform.service';
 import { SessionService } from '../../core/session.service';
 
@@ -144,7 +145,6 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly memorySelectedDate = signal('');
   readonly orderInfo = signal<SourceItem>({});
   readonly learnedRecord = signal<SourceItem>({});
-  readonly plRecord = signal<SourceItem>({});
   readonly antiHistory = signal<SourceItem>({});
   readonly myNewWords = signal<SourceItem>({});
 
@@ -337,6 +337,33 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     return ['pages/stu/stu5', 'pages/stu/pl_stu5'].includes(this.meta.path);
   }
 
+  get hasNextWordBatch(): boolean {
+    const currentPage = Number(this.storageGet('_page_current_learn') ?? 1);
+    const pages = this.parseArray(this.storageGet('_page_word_orilist'));
+    return Number.isInteger(currentPage) && currentPage > 0
+      && Array.isArray(pages[currentPage]) && pages[currentPage].length > 0;
+  }
+
+  get stageActionLabel(): string {
+    if (this.isMatchingPage) return '进入听音识意';
+    if (this.isListeningPage) return '进入组合拼写';
+    if (this.hasNextWordBatch) return '学习下一组单词';
+    const mode = String(this.storageGet('learn_mode') ?? '');
+    if (mode === 'newword' || mode === 'history_word') return '完成学习并返回';
+    return this.meta.path === 'pages/stu/pl_stu5' && !this.query('orderId') ? '保存并结束学习' : '查看学习结果';
+  }
+
+  get canReturnToLearningEntry(): boolean {
+    return isLearningStage(this.meta.path, this.route.snapshot.queryParamMap);
+  }
+
+  returnToLearningEntry(): void {
+    const legacyOrderId = this.meta.path === 'pages/stu/pl_end' ? this.query('id') : '';
+    const url = legacyOrderId ? `/pages/shop/get_order?id=${encodeURIComponent(legacyOrderId)}`
+      : learningReturnUrl(this.route.snapshot.queryParamMap, history.state?.learningReturnUrl);
+    void this.router.navigateByUrl(url, { replaceUrl: true });
+  }
+
   get isReviewPage(): boolean {
     return ['pages/stu/stu6', 'pages/stu/pl_end'].includes(this.meta.path);
   }
@@ -729,6 +756,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   continueAfterStage(): void {
+    if (this.submitting() || !this.completed()) return;
     if (this.isMatchingPage) {
       this.completed.set(false);
       this.navigateStage('/pages/stu/stu4');
@@ -761,6 +789,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   submitReview(): void {
+    if (this.submitting() || !this.words().length) return;
     if (this.words().some((word) => this.reviewValue(word) === 0)) {
       this.platform.toast('请为每个单词选择掌握情况', 'error');
       return;
@@ -1044,7 +1073,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   leave(): void {
     this.clearLearningStorage();
-    void this.router.navigateByUrl('/pages/home/index');
+    void this.router.navigateByUrl('/pages/home/index', { replaceUrl: true });
   }
 
   private loadNodes(): void {
@@ -1177,6 +1206,10 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   private initializeStage(source: Word[]): void {
+    if (!source.length) {
+      this.error.set('本关暂无单词,请返回重新选择学习内容');
+      return;
+    }
     const words = this.isStudyListPage ? source : this.shuffle(source);
     this.words.set(words);
     if (this.isStudyListPage) {
@@ -1233,18 +1266,22 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   private finishSpellingFlow(): void {
-    const totalPages = Number(this.storageGet('_page_total_page') ?? 0);
-    const currentPage = Number(this.storageGet('_page_current_learn') ?? 0);
-    if (totalPages > currentPage) {
+    const currentPage = Number(this.storageGet('_page_current_learn') ?? 1);
+    if (this.hasNextWordBatch) {
+      this.completed.set(false);
       this.storageSet('_page_current_learn', currentPage + 1);
       this.navigateStage('/pages/stu/stu_con');
       return;
     }
     const mode = String(this.storageGet('learn_mode') ?? '');
     if (mode === 'newword') {
-      void this.router.navigateByUrl('/pages/member/starshop');
+      this.completed.set(false);
+      this.clearLearningStorage();
+      void this.router.navigateByUrl('/pages/member/starshop', { replaceUrl: true });
     } else if (mode === 'history_word') {
-      void this.router.navigateByUrl('/pages/his/his');
+      this.completed.set(false);
+      this.clearLearningStorage();
+      void this.router.navigateByUrl('/pages/his/his', { replaceUrl: true });
     } else if (this.meta.path === 'pages/stu/pl_stu5') {
       if (this.query('orderId')) {
         this.createPracticeLearningRecord();
@@ -1339,7 +1376,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     ).subscribe({
       next: () => {
         this.platform.toast('提交成功', 'success');
-        void this.router.navigate(['/pages/member/myorder'], { queryParams: { pnid: this.query('pnid') } });
+        void this.router.navigate(['/pages/member/myorder'], { queryParams: { pnid: this.query('pnid') }, replaceUrl: true });
       },
       error: (error: ApiError) => this.platform.toast(error.message, 'error'),
     });
@@ -1347,34 +1384,41 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   private loadPracticeEnd(): void {
     const orderId = this.query('orderId') || this.query('id');
-    const student = this.session.student();
-    const user = this.session.user();
-    this.api.get<unknown>('e_order_detail', { id: orderId }).subscribe({
-      next: ({ result }) => this.orderInfo.set(this.findList(result)[0] ?? {}),
-      error: () => undefined,
-    });
-    this.api.get<unknown>('content_list', { modelId: 59, psize: 2, myfield2: `yyds=${orderId}|jsmz=${user?.userId}` }).subscribe({
-      next: ({ result }) => this.plRecord.set(this.findList(result)[0] ?? {}),
-      error: () => undefined,
-    });
-    this.api.get<unknown>('content_list', { modelId: 56, psize: 1, myfield2: `UserId=${student?.userId}|dsid=${orderId}` }).subscribe({
-      next: ({ result }) => {
-        const record = this.findList(result)[0] ?? {};
-        this.learnedRecord.set(record);
-        if (!record['GeneralID']) {
-          this.loading.set(false);
-          this.error.set('未找到本次陪练学习记录');
-          return;
+    const coachId = this.session.user()?.userId;
+    if (!orderId || !coachId) {
+      this.loading.set(false);
+      this.error.set('缺少预约或老师信息,请返回课程重新进入');
+      return;
+    }
+    this.api.get<unknown>('e_order_detail', { id: orderId }).pipe(
+      switchMap(({ result }) => {
+        const order = this.findList(result)[0] ?? (result as SourceItem);
+        const studentId = Number(order?.['szyh'] ?? order?.['yhid'] ?? 0);
+        if (!studentId) return throwError(() => new ApiError('预约记录缺少学员信息,请返回课程重试'));
+        this.orderInfo.set(order);
+        if (this.session.student()?.userId !== studentId) {
+          this.session.setStudent({ userId: studentId, groupId: 1, honeyName: String(order['HoneyName'] ?? order['honeyName'] ?? '学员') });
         }
-        this.api.get<unknown>('e_record_detail', { id: record['GeneralID'] }).pipe(finalize(() => this.loading.set(false))).subscribe({
-          next: ({ result: detail }) => this.words.set(this.findList(detail).map((item) => this.toWord(item)).filter((word) => word.word)),
-          error: (error: ApiError) => this.error.set(error.message),
+        // Restrict the read to this appointment, its student and the signed-in coach.
+        return this.api.get<unknown>('content_list', {
+          modelId: 56, psize: 1,
+          myfield2: `UserId=${studentId}|dsid=${orderId}|pl=${coachId}`,
         });
+      }),
+      switchMap(({ result }) => {
+        const record = this.findList(result)[0] ?? {};
+        this.learnedRecord.set(record);
+        if (!record['GeneralID']) return throwError(() => new ApiError('未找到本次陪练学习记录,请返回课程重试'));
+        return this.api.get<unknown>('e_record_detail', { id: record['GeneralID'] });
+      }),
+      finalize(() => this.loading.set(false)),
+    ).subscribe({
+      next: ({ result }) => {
+        const words = this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word);
+        this.words.set(words);
+        this.reviewState.set(Object.fromEntries(words.map((word) => [word.id, Number(word['check']) === 1 ? 1 : Number(word['check']) === 2 ? 2 : 0])));
       },
-      error: (error: ApiError) => {
-        this.loading.set(false);
-        this.error.set(error.message);
-      },
+      error: (error: ApiError) => this.error.set(error.message),
     });
   }
 
@@ -1397,60 +1441,16 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       addon: JSON.stringify(addon),
     }).pipe(finalize(() => this.submitting.set(false))).subscribe({
       next: () => {
-        void this.platform.confirm('即将结束本次学习,确认继续?').then((confirmed) => {
-          if (confirmed) this.endPracticeOrder();
+        this.clearLearningStorage();
+        void this.router.navigate(['/pages/shop/get_order'], {
+          queryParams: { id: this.query('orderId') || this.query('id'), feedback: '1' },
+          replaceUrl: true,
         });
       },
       error: (error: ApiError) => this.platform.toast(error.message, 'error'),
     });
   }
 
-  private endPracticeOrder(): void {
-    const orderId = this.query('orderId') || this.query('id');
-    const content = { GeneralID: orderId, UpDateTime: this.dateTime() };
-    this.submitting.set(true);
-    this.api.post('e_order_update_v2', { status: 11, uid: this.session.user()?.userId, content: JSON.stringify(content) }).subscribe({
-      next: () => this.savePracticeSession(),
-      error: (error: ApiError) => {
-        this.submitting.set(false);
-        this.platform.toast(error.message, 'error');
-      },
-    });
-  }
-
-  private savePracticeSession(): void {
-    const student = this.session.student();
-    const user = this.session.user();
-    const order = this.orderInfo();
-    const content: SourceItem = {
-      ModelID: 59,
-      nodeId: 296,
-      inputer: user?.honeyName,
-      status: 99,
-      Hits: 0,
-      title: student?.honeyName,
-    };
-    let action = 'content_add';
-    if (this.plRecord()['GeneralID']) {
-      content['GeneralID'] = this.plRecord()['GeneralID'];
-      action = 'content_update';
-    }
-    const addon = {
-      jsmz: user?.userId,
-      kcid: order['kcid'],
-      xymz: student?.userId,
-      kclx: order['dslx_t'],
-      yyds: this.query('orderId') || this.query('id'),
-      jffs: order['jffs'],
-      kcmc: order['kcmc'],
-      szmdid: order['szmd'],
-    };
-    this.api.post(action, { content: JSON.stringify(content), addon: JSON.stringify(addon) }).pipe(finalize(() => this.submitting.set(false))).subscribe({
-      next: () => void this.router.navigate(['/pages/shop/get_order'], { queryParams: { id: this.query('orderId') || this.query('id') } }),
-      error: (error: ApiError) => this.platform.toast(error.message, 'error'),
-    });
-  }
-
   private createPracticeLearningRecord(): void {
     const allWords = this.readAllLearningWords();
     const user = this.session.user();
@@ -1525,7 +1525,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       next: () => {
         this.clearLearningStorage();
         this.platform.toast('学习记录已保存', 'success');
-        void this.router.navigateByUrl('/pages/his/his');
+        void this.router.navigateByUrl('/pages/his/his', { replaceUrl: true });
       },
       error: (error: ApiError) => this.platform.toast(error.message, 'error'),
     });
@@ -1745,6 +1745,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.storageSet('_page_word_orilist', pages);
     sessionStorage.setItem('xiaoshu.selectedWords', JSON.stringify(pages[0] ?? chosen));
     void this.router.navigate(['/pages/stu/stu_con'], {
+      replaceUrl: true,
+      state: { learningReturnUrl: this.router.url },
       queryParams: {
         nids: this.query('nids') || this.query('nid'),
         pnid: this.query('pnid'),
@@ -1755,6 +1757,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   private navigateStage(path: string): void {
     void this.router.navigate([path], {
+      replaceUrl: true,
+      state: { learningReturnUrl: history.state?.learningReturnUrl },
       queryParams: {
         nid: this.query('nid'),
         nids: this.query('nids'),
@@ -1765,18 +1769,18 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   private readLearningWords(): Word[] {
-    const totalPages = Number(this.storageGet('_page_total_page') ?? 0);
     const currentPage = Number(this.storageGet('_page_current_learn') ?? 1);
-    if (totalPages > 0) {
-      const pages = this.parseArray(this.storageGet('_page_word_orilist'));
+    const pages = this.parseArray(this.storageGet('_page_word_orilist'));
+    if (pages.length) {
       const page = pages[currentPage - 1];
-      if (Array.isArray(page)) {
+      if (Number.isInteger(currentPage) && currentPage > 0 && Array.isArray(page)) {
         const mode = String(this.storageGet('learn_mode') ?? '');
         const key = mode === 'newword' ? 'choose_newword_learn' : mode === 'history_word' ? 'choose_hisword_learn' : 'choose_word_learn';
         this.storageSet(key, page);
         sessionStorage.setItem('xiaoshu.selectedWords', JSON.stringify(page));
         return page.map((item) => this.toWord(item as SourceItem));
       }
+      return [];
     }
     const mode = String(this.storageGet('learn_mode') ?? '');
     const key = mode === 'newword' ? 'choose_newword_learn' : mode === 'history_word' ? 'choose_hisword_learn' : 'choose_word_learn';

+ 11 - 0
projects/xiaoshu-mobile/src/app/layout/app-shell.component.ts

@@ -17,6 +17,7 @@ import { APP_NAME } from '../core/app.constants';
 import { NavItem, PageMeta } from '../core/models';
 import { PlatformService } from '../core/platform.service';
 import { SessionService } from '../core/session.service';
+import { isLearningStage, learningReturnUrl } from '../core/learning-navigation';
 
 const PRIMARY_TAB_PATHS = new Set([
   'pages/home/index',
@@ -75,6 +76,16 @@ export class AppShellComponent {
   }
 
   goBack(): void {
+    let route = this.activatedRoute;
+    while (route.firstChild) route = route.firstChild;
+    if (isLearningStage(this.meta()?.path ?? '', route.snapshot.queryParamMap)) {
+      const params = route.snapshot.queryParamMap;
+      const legacyOrderId = this.meta()?.path === 'pages/stu/pl_end' ? params.get('id') : null;
+      const returnUrl = legacyOrderId ? `/pages/shop/get_order?id=${encodeURIComponent(legacyOrderId)}`
+        : learningReturnUrl(params, history.state?.learningReturnUrl);
+      void this.router.navigateByUrl(returnUrl, { replaceUrl: true });
+      return;
+    }
     if (this.meta()?.path === 'pages/his/his') {
       void this.router.navigateByUrl('/pages/home/index', { replaceUrl: true });
       return;