Просмотр исходного кода

Exclude wordless writing records from word review tasks

彭峰 1 неделя назад
Родитель
Сommit
48d4d44b33

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

@@ -647,6 +647,62 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelector('.readonly-word-header')?.textContent).toContain('第 1–30 条 · 共 65 个单词');
     expect(harness.routeNativeElement?.querySelector('.word-preview-pagination')?.textContent).toContain('1 / 3 页');
     (harness.routeNativeElement?.querySelector('[aria-label="第 2 页"]') as HTMLButtonElement).click();
+  it('syncs a missing learning record and reopens its review words', async () => {
+    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    const post = api.post.bind(api);
+    let detailReads = 0;
+    spyOn(api, 'post').and.callFake((action, params) => {
+      if (action === 'e_record_detail') {
+        detailReads += 1;
+        return of({ retcode: 1, result: detailReads === 1 ? [] : [{ GeneralID: 701, Title: 'apple', sy: '苹果' }], addon: [] } as ApiEnvelope<any>);
+      }
+      if (action === 'app_learning_overview') return of({ retcode: 1, result: {} } as ApiEnvelope<any>);
+      return post(action, params);
+    });
+    const harness = await RouterTestingHarness.create();
+    await harness.navigateByUrl('/pages/stu/record?id=864038&gid=864047&kcid=398');
+    harness.fixture.detectChanges();
+    expect(detailReads).toBe(2);
+    expect(harness.routeNativeElement?.querySelector('.record-word')?.textContent).toContain('apple');
+    expect(harness.routeNativeElement?.querySelector('.empty-state.error')).toBeNull();
+  });
+
+  it('reads the original word list when the detail stays empty after syncing', async () => {
+    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    const post = api.post.bind(api);
+    const get = api.get.bind(api);
+    spyOn(api, 'post').and.callFake((action, params) => action === 'e_record_detail'
+      ? of({ retcode: 1, result: [], addon: [] } as ApiEnvelope<any>) : action === 'app_learning_overview'
+        ? of({ retcode: 1, result: {} } as ApiEnvelope<any>) : post(action, params));
+    spyOn(api, 'get').and.callFake((action, params) => action === 'content_get'
+      ? of({ retcode: 1, result: [{ GeneralID: 864038, xxqs: JSON.stringify([{ GeneralID: 701, Title: 'apple' }]) }] } as ApiEnvelope<any>)
+      : get(action, params));
+    const harness = await RouterTestingHarness.create();
+    await harness.navigateByUrl('/pages/stu/record?id=864038&gid=864047&kcid=398');
+    harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelector('.record-word')?.textContent).toContain('apple');
+    expect(harness.routeNativeElement?.querySelector('.empty-state.error')).toBeNull();
+  });
+
+  it('identifies a legacy writing review with no original words and keeps it incomplete', async () => {
+    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    const post = api.post.bind(api);
+    const get = api.get.bind(api);
+    spyOn(api, 'post').and.callFake((action, params) => action === 'e_record_detail'
+      ? of({ retcode: 1, result: [], addon: [] } as ApiEnvelope<any>) : action === 'app_learning_overview'
+        ? of({ retcode: 1, result: {} } as ApiEnvelope<any>) : post(action, params));
+    spyOn(api, 'get').and.callFake((action, params) => action === 'content_get'
+      ? of({ retcode: 1, result: [{ GeneralID: 864038, xxqs: '', learned: 0, con: '完成了写作学习' }] } as ApiEnvelope<any>)
+      : get(action, params));
+    const harness = await RouterTestingHarness.create();
+    await harness.navigateByUrl('/pages/stu/record?id=864038&gid=864047&kcid=398');
+    harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.textContent).toContain('写作学习记录,原本没有单词');
+    expect(harness.routeNativeElement?.querySelector('.record-list')).toBeNull();
+    expect(harness.routeNativeElement?.querySelector('a[href="/pages/stu/stu_calendar"]')).not.toBeNull();
+    expect(harness.routeNativeElement?.textContent).not.toContain('结束本次复习');
+  });
+
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();
     expect(wordApi.lastWordParams['cpage']).toBe(2);

+ 5 - 1
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html

@@ -6,7 +6,11 @@
       <div>
         <lucide-icon [img]="icons.CircleHelp" [size]="32" />
         <p>{{ error() }}</p>
-        <button type="button" class="secondary-button" (click)="load()">重新加载</button>
+        @if (isRecordPage && recordWordless()) {
+          <a class="primary-button" routerLink="/pages/stu/stu_calendar">返回复习日历</a>
+        } @else {
+          <button type="button" class="secondary-button" (click)="load()">重新加载</button>
+        }
         @if (canReturnToLearningEntry) { <button type="button" class="primary-button" (click)="returnToLearningEntry()">返回课程</button> }
       </div>
     </div>

+ 33 - 5
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -3,7 +3,7 @@ import { LearningDraft } from '../../core/learning-draft';
 import { ReviewPage, reviewCalendar } from '../../core/review-page';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
-import { ActivatedRoute, Router } from '@angular/router';
+import { ActivatedRoute, Router, RouterLink } from '@angular/router';
 import {
   Activity,
   BookOpen,
@@ -26,7 +26,7 @@ import {
   Volume2,
   X,
 } from 'lucide-angular';
-import { catchError, finalize, forkJoin, Observable, of, Subscription, switchMap, throwError } from 'rxjs';
+import { catchError, finalize, forkJoin, map, Observable, of, Subscription, switchMap, throwError } from 'rxjs';
 import { GAME_AUDIO } from '../../core/app.constants';
 import { ApiError, ApiService } from '../../core/api.service';
 import {
@@ -103,7 +103,7 @@ const WORD_CATALOG_CATEGORIES = [
 @Component({
   selector: 'app-learning-page',
   standalone: true,
-  imports: [DropdownDirective, CommonModule, LucideAngularModule],
+  imports: [DropdownDirective, CommonModule, LucideAngularModule, RouterLink],
   templateUrl: './learning-page.component.html',
   styleUrl: './learning-page.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
@@ -134,6 +134,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly submitting = signal(false);
   readonly selectionTotal=signal(0);readonly selectionPage=signal(1);readonly selectionLoading=signal(false);private selectionRequest?:Subscription;
   readonly error = signal('');
+  readonly recordWordless = signal(false);
 
   readonly studyStep = signal<1 | 2 | 3>(1);
   readonly repetitions = signal<Record<string, number>>({});
@@ -440,6 +441,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     if (this.isSelectionPage) {
       this.loadSelection();
       return;
+    this.recordWordless.set(false);
     }
     if (this.isCalendarPage) {
       this.loadCalendar(true);
@@ -1564,11 +1566,37 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   private loadRecord(): void {
     const user = this.session.user();
     const learnerId = Number(user?.groupId) === 3 ? this.session.student()?.userId : user?.userId;
-    this.api.post<unknown>('e_record_detail', { id: this.query('id'), studentId: learnerId }).pipe(finalize(() => this.loading.set(false))).subscribe({
+    const recordId = this.query('id');
+    const detailParams = { id: recordId, studentId: learnerId };
+    const hasWords = (result: unknown) => this.findList(result).some((item) => this.toWord(item).word);
+    let originalRecord: SourceItem | undefined;
+    this.api.post<unknown>('e_record_detail', detailParams).pipe(
+      switchMap((detail) => hasWords(detail.result) ? of(detail) : this.api.get<unknown>('content_get', { id: recordId }).pipe(
+        map(({ result }) => {
+          originalRecord = this.findList(result)[0];
+          return { ...detail, result: this.parseArray(originalRecord?.['xxqs']) };
+        }),
+        catchError(() => of(detail)),
+      )),
+      switchMap((detail) => hasWords(detail.result) ? of(detail) : this.api.post<unknown>('app_learning_overview', {
+        studentId: learnerId, page: 1, pageSize: 1, refresh: true,
+      }).pipe(
+        switchMap(() => this.api.post<unknown>('e_record_detail', detailParams)),
+        catchError(() => of(detail)),
+      )),
+      finalize(() => this.loading.set(false)),
+    ).subscribe({
       next: ({ result, addon }) => {
         const words = this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word);
         if (!words.length) {
-          this.error.set('这条复习任务暂时没有读取到单词,请点击重新加载;任务进度不会被修改。');
+          const wordless = originalRecord && 'xxqs' in originalRecord
+            && !this.parseArray(originalRecord['xxqs']).length
+            && Number(originalRecord['learned'] ?? 0) === 0;
+          this.recordWordless.set(Boolean(wordless));
+          const writingRecord = wordless && String(originalRecord?.['con'] ?? '').includes('写作');
+          this.error.set(wordless
+            ? `${writingRecord ? '这条任务关联的是写作学习记录,原本没有单词' : '这条任务关联的原学习记录没有保存单词'},无法进行单词复习。任务进度不会被修改。`
+            : '这条复习任务暂时没有读取到单词,请点击重新加载;任务进度不会被修改。');
           return;
         }
         this.words.set(words);

+ 2 - 0
scripts/cloud/mobile-teaching.js

@@ -24,6 +24,8 @@ async function appReviewPage(input,current) {
   const uid=await authorizeRequestedUser(current,input.uid),coach=number(current.get('legacyGroupId'))===3,p=mobilePage(input);
   const reviewTarget=coach&&number(input.studentId)?await appLearningTarget(current,input.studentId):coach?0:uid,legacyReviewSync=reviewTarget?await syncLegacyReviews(reviewTarget,current,input.refresh===true):undefined;
   const args=[DEFAULT_COMPANY_ID,String(uid)],clauses=['m."company"=$1','m."'+(coach?'plid':'yhid')+'"::text=$2'];
+  // A legacy writing lesson can have a ten-round plan but no saved words. Do not list it as a word-review task.
+  clauses.push('NOT EXISTS (SELECT 1 FROM "CommonModel" sc JOIN "DailyStudyRecord" d ON d."company"=sc."company" AND d."id"=sc."itemId" WHERE sc."company"=m."company" AND sc."modelId"=56 AND sc."generalId"::text=m."xxjlid"::text AND COALESCE(d."learned",0)=0 AND COALESCE(NULLIF(BTRIM(d."xxqs"),\'\'),\'[]\')=\'[]\')');
   const add=(sql,value)=>{args.push(value);clauses.push(sql.replace('?', '$'+args.length));};
   const students=coach?await Psql.query('SELECT m."yhid"::text AS id,COALESCE(MAX(NULLIF(BTRIM(u."realName"),\'\')),MAX(NULLIF(BTRIM(u."nickname"),\'\')),MAX(NULLIF(BTRIM(u."legacyUserData"->>\'HoneyName\'),\'\')),MAX(NULLIF(BTRIM(u."username"),\'\')),\'学员 \'||m."yhid"::text) AS name,COUNT(*)::int AS "taskCount" FROM "MemoryPracticeRecord" m LEFT JOIN "_User" u ON u."company"=m."company" AND u."legacyUserId"=m."yhid" WHERE '+clauses.join(' AND ')+' GROUP BY m."yhid"',args):[];
   if(coach&&number(input.studentId))add('m."yhid"::text=?',String(input.studentId));

Разница между файлами не показана из-за своего большого размера
+ 2 - 2
scripts/deploy-admin-functions.mjs


Некоторые файлы не были показаны из-за большого количества измененных файлов