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

Paginate student wordbook by learning status

彭峰 1 день назад
Родитель
Сommit
421b5f1caa

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

@@ -456,10 +456,10 @@ describe('home learning modules', () => {
     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, Title: 'tend@张雅茜', xxcs: 0, detail: [{ GeneralID: 701, Title: 'tend', sy: '照顾' }] },
-        { GeneralID: 602, Title: 'prepare@张雅茜', xxcs: 2, detail: [{ GeneralID: 702, Title: 'prepare', sy: '准备' }] },
-      ], page: { itemCount: 2, pageCount: 1, pageIndex: 1, pageSize: 100 } } as ApiEnvelope<any>)
+      ? of({ retcode: 1, result: params?.['learningStatus'] === 'completed'
+        ? [{ GeneralID: 602, Title: 'prepare@张雅茜', xxcs: 2, detail: [{ GeneralID: 702, Title: 'prepare', sy: '准备' }] }]
+        : [{ GeneralID: 601, Title: 'tend@张雅茜', xxcs: 0, detail: [{ GeneralID: 701, Title: 'tend', sy: '照顾' }] }],
+        page: { itemCount: 1, pendingCount: 1, completedCount: 1, pageCount: 1, pageIndex: 1, pageSize: 30 } } as ApiEnvelope<any>)
       : get(action, params));
     const harness = await RouterTestingHarness.create();
     await harness.navigateByUrl('/pages/member/starshop');
@@ -481,6 +481,44 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelector('.pending-column .word-select-list > button.selected')).not.toBeNull();
   });
 
+  it('shows 30 wordbook words per page while counting every learning status', async () => {
+    const api = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    const get = api.get.bind(api);
+    const pending = Array.from({ length: 65 }, (_, index) => ({ GeneralID: 601 + index, xxcs: 0, detail: [{ GeneralID: 701 + index, Title: `pending-${index + 1}` }] }));
+    const completed = Array.from({ length: 7 }, (_, index) => ({ GeneralID: 801 + index, xxcs: 1, detail: [{ GeneralID: 901 + index, Title: `completed-${index + 1}` }] }));
+    const requests: Record<string, unknown>[] = [];
+    spyOn(api, 'get').and.callFake((action, params) => {
+      if (action !== 'e_words_list') return get(action, params);
+      requests.push(params ?? {});
+      const rows = 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, pendingCount: pending.length, completedCount: completed.length,
+          pageCount: Math.ceil(rows.length / size), pageIndex: page, pageSize: size } } as ApiEnvelope<any>);
+    });
+    const harness = await RouterTestingHarness.create();
+    await harness.navigateByUrl('/pages/member/starshop');
+    harness.fixture.detectChanges();
+    const view = () => harness.routeNativeElement!;
+    const rows = () => view().querySelectorAll('.pending-column .word-select-list > button');
+    expect(requests[0]).toEqual(jasmine.objectContaining({ learningStatus: 'pending', cpage: 1, psize: 30 }));
+    expect(rows().length).toBe(30);
+    expect(view().querySelectorAll('.word-selection-tabs b')[0].textContent).toContain('65');
+    expect(view().querySelectorAll('.word-selection-tabs b')[1].textContent).toContain('7');
+    (rows()[0] as HTMLButtonElement).click();
+    (view().querySelectorAll('.mobile-pagination button')[1] as HTMLButtonElement).click();
+    harness.fixture.detectChanges();
+    expect(requests[1]).toEqual(jasmine.objectContaining({ learningStatus: 'pending', cpage: 2, psize: 30 }));
+    expect(rows().length).toBe(30);
+    expect(view().querySelector('.mobile-pagination')?.textContent).toContain('第 2 / 3 页');
+    (view().querySelectorAll('.word-selection-tabs button')[1] as HTMLButtonElement).click();
+    harness.fixture.detectChanges();
+    expect(requests[2]).toEqual(jasmine.objectContaining({ learningStatus: 'completed', cpage: 1, psize: 30 }));
+    expect(view().querySelectorAll('.completed-column .word-select-list > button').length).toBe(7);
+    expect(view().querySelector('.selection-header strong')?.textContent).toContain('已选 1 个单词');
+  });
+
   it('saves completed wordbook study before showing the word in the learned tab', async () => {
     const prefix = 'xiaoshu.learning.100.100.practice.';
     const word = { GeneralID: 701, Title: 'tend', sy: '照顾' };
@@ -491,7 +529,12 @@ describe('home learning modules', () => {
     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, Title: 'tend@张雅茜', xxcs: api.lastLearningWordUpdate['save'] ? 1 : 0, detail: [word] }], page: { itemCount: 1, pageCount: 1, pageIndex: 1, pageSize: 100 } } as ApiEnvelope<any>)
+      ? of({ retcode: 1, result: api.lastLearningWordUpdate['save'] === 1 && params?.['learningStatus'] === 'completed'
+        ? [{ GeneralID: 601, Title: 'tend@张雅茜', xxcs: 1, detail: [word] }]
+        : api.lastLearningWordUpdate['save'] === 1 ? [] : [{ GeneralID: 601, Title: 'tend@张雅茜', xxcs: 0, detail: [word] }],
+        page: { itemCount: params?.['learningStatus'] === 'completed' ? 1 : api.lastLearningWordUpdate['save'] === 1 ? 0 : 1,
+          pendingCount: api.lastLearningWordUpdate['save'] === 1 ? 0 : 1, completedCount: api.lastLearningWordUpdate['save'] === 1 ? 1 : 0,
+          pageCount: 1, pageIndex: 1, pageSize: 30 } } as ApiEnvelope<any>)
       : get(action, params));
     const harness = await RouterTestingHarness.create();
     const router = TestBed.inject(Router);

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

@@ -108,7 +108,7 @@
           <span>已学完 {{ completedSelectionWords().length }} · 还没学 {{ pendingSelectionWords().length }}</span>
         } @else if (isNewWordSelectionPage) {
           <strong>已选 {{ selected().size }} 个单词</strong>
-          <span>已加载 {{ words().length }} 个 · 未学习 {{ unlearnedNewWords().length }} · 已学习 {{ learnedNewWords().length }}</span>
+          <span>未学习 {{ newWordCounts().pending }} 个 · 已学习 {{ newWordCounts().completed }} 个</span>
         } @else {
           <strong>已选 {{ selected().size }} / {{ selectionLimit }}</strong>
           <span>选择本次要学习的单词</span>
@@ -126,8 +126,8 @@
     @if (isNewWordSelectionPage) {
       <div class="word-selection-panel">
         <nav class="word-selection-tabs" role="tablist" aria-label="生词学习状态">
-          <button type="button" role="tab" [class.active]="activeSelectionBucket() === 'pending'" [attr.aria-selected]="activeSelectionBucket() === 'pending'" (click)="activeSelectionBucket.set('pending')"><span>未学习</span><b>{{ unlearnedNewWords().length }}</b></button>
-          <button type="button" role="tab" [class.active]="activeSelectionBucket() === 'completed'" [attr.aria-selected]="activeSelectionBucket() === 'completed'" (click)="activeSelectionBucket.set('completed')"><span>已学习</span><b>{{ learnedNewWords().length }}</b></button>
+          <button type="button" role="tab" [class.active]="activeSelectionBucket() === 'pending'" [attr.aria-selected]="activeSelectionBucket() === 'pending'" (click)="selectNewWordBucket('pending')"><span>未学习</span><b>{{ newWordCounts().pending }}</b></button>
+          <button type="button" role="tab" [class.active]="activeSelectionBucket() === 'completed'" [attr.aria-selected]="activeSelectionBucket() === 'completed'" (click)="selectNewWordBucket('completed')"><span>已学习</span><b>{{ newWordCounts().completed }}</b></button>
         </nav>
         <section class="word-selection-list" [class.pending-column]="activeSelectionBucket() === 'pending'" [class.completed-column]="activeSelectionBucket() === 'completed'" role="tabpanel" [attr.aria-label]="activeSelectionBucket() === 'pending' ? '未学习的生词' : '已学习的生词'">
           <div class="word-select-list">
@@ -138,7 +138,7 @@
                 <span class="audio" (click)="$event.stopPropagation(); play(word)"><lucide-icon [img]="icons.Volume2" [size]="18" /></span>
               </button>
             } @empty {
-              <div class="word-column-empty"><lucide-icon [img]="icons.CheckCheck" [size]="24" /><span>{{ activeSelectionBucket() === 'pending' ? '已加载的生词都学过了' : '还没有学过的生词' }}</span></div>
+              <div class="word-column-empty"><lucide-icon [img]="icons.CheckCheck" [size]="24" /><span>{{ selectionLoading() ? '正在加载生词…' : activeSelectionBucket() === 'pending' ? '没有未学习的生词' : '还没有学过的生词' }}</span></div>
             }
           </div>
         </section>
@@ -233,7 +233,15 @@
         <button type="button" class="danger-button" (click)="deleteSelectedNewWords()"><lucide-icon [img]="icons.Trash2" [size]="18" />删除所选</button>
       </div>
     }
-    @if(selectionTotal()>0){<div class="mobile-pagination"><span>已加载 {{words().length}} / {{selectionTotal()}} 个单词</span>@if(words().length<selectionTotal()){<button (click)="loadMoreSelection()" [disabled]="selectionLoading()">{{selectionLoading()?'加载中…':'加载更多单词'}}</button>}</div>}
+    @if (isNewWordSelectionPage && selectionTotal() > 0) {
+      <div class="mobile-pagination">
+        <span>第 {{ selectionPage() }} / {{ newWordPageCount() }} 页 · 共 {{ selectionTotal() }} 个单词</span>
+        <button type="button" (click)="goToNewWordPage(selectionPage() - 1)" [disabled]="selectionLoading() || selectionPage() === 1">上一页</button>
+        <button type="button" (click)="goToNewWordPage(selectionPage() + 1)" [disabled]="selectionLoading() || selectionPage() >= newWordPageCount()">下一页</button>
+      </div>
+    } @else if (!isNewWordSelectionPage && selectionTotal() > 0) {
+      <div class="mobile-pagination"><span>已加载 {{words().length}} / {{selectionTotal()}} 个单词</span>@if(words().length<selectionTotal()){<button (click)="loadMoreSelection()" [disabled]="selectionLoading()">{{selectionLoading()?'加载中…':'加载更多单词'}}</button>}</div>
+    }
   } @else if (isStudyListPage) {
     <div class="stage-banner">
       <div>

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

@@ -123,6 +123,9 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly selectionRevealed = signal<Set<string>>(new Set());
   readonly selectionAssessment = signal<Record<string, 'known' | 'unknown'>>({});
   readonly activeSelectionBucket = signal<'pending' | 'completed'>('pending');
+  readonly newWordCounts = signal({ pending: 0, completed: 0 });
+  private readonly newWordPageSize = 30;
+  private readonly newWordSelectionCache = new Map<string, Word>();
   readonly currentIndex = signal(0);
   readonly revealed = signal(false);
   readonly completed = signal(false);
@@ -208,6 +211,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly learnedNewWords = computed(() => this.words().filter((word) => this.newWordLearned(word)));
   readonly unlearnedNewWords = computed(() => this.words().filter((word) => !this.newWordLearned(word)));
   readonly visibleNewWords = computed(() => this.activeSelectionBucket() === 'completed' ? this.learnedNewWords() : this.unlearnedNewWords());
+  readonly newWordPageCount = computed(() => Math.max(1, Math.ceil(this.selectionTotal() / this.newWordPageSize)));
   readonly allVisibleNewWordsSelected = computed(() => this.visibleNewWords().length > 0 && this.visibleNewWords().every((word) => this.selected().has(word.id)));
   readonly newlyKnownWords = computed(() => this.words().filter((word) => !this.wordHistoricallyLearned(word) && this.selectionAssessment()[word.id] === 'known'));
   readonly selectionActionLabel = computed(() => {
@@ -433,7 +437,9 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   get selectionLimit(): number {
-    return this.meta.path === 'pages/stu/stu_abc' ? 30 : this.words().length;
+    if (this.meta.path === 'pages/stu/stu_abc') return 30;
+    if (this.isNewWordSelectionPage) return 100;
+    return this.words().length;
   }
 
   load(): void {
@@ -620,7 +626,10 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       const next = new Set(this.selected());
       for (const word of visible) {
         if (clearVisible) next.delete(word.id);
-        else next.add(word.id);
+        else if (next.size < this.selectionLimit) next.add(word.id);
+      }
+      if (!clearVisible && next.size === this.selectionLimit && visible.some((word) => !next.has(word.id))) {
+        this.platform.toast(`本次最多选择 ${this.selectionLimit} 个单词`, 'error');
       }
       this.selected.set(next);
       return;
@@ -633,7 +642,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   startSelected(): void {
-    const chosen = (this.isRefineSelectionPage ? this.pendingSelectionWords() : this.words())
+    const available = this.isNewWordSelectionPage ? [...this.newWordSelectionCache.values()] : this.isRefineSelectionPage ? this.pendingSelectionWords() : this.words();
+    const chosen = available
       .filter((word) => this.selected().has(word.id));
     const newlyKnown = this.newlyKnownWords();
     if (!chosen.length && !newlyKnown.length) {
@@ -666,7 +676,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   deleteSelectedNewWords(): void {
-    const chosen = this.words().filter((word) => this.selected().has(word.id));
+    const chosen = [...this.newWordSelectionCache.values()].filter((word) => this.selected().has(word.id));
     if (!chosen.length) {
       this.platform.toast('请选择单词', 'error');
       return;
@@ -1181,6 +1191,22 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   loadMoreSelection():void{if(!this.selectionLoading()&&this.words().length<this.selectionTotal()){this.selectionPage.update(p=>p+1);this.loadSelection(true);}}
+  selectNewWordBucket(bucket: 'pending' | 'completed'): void {
+    if (this.activeSelectionBucket() === bucket) return;
+    this.selectionRequest?.unsubscribe();
+    this.activeSelectionBucket.set(bucket);
+    this.selectionPage.set(1);
+    this.selectionTotal.set(this.newWordCounts()[bucket]);
+    this.words.set([]);
+    this.loadSelection();
+  }
+
+  goToNewWordPage(page: number): void {
+    if (this.selectionLoading() || page < 1 || page > this.newWordPageCount() || page === this.selectionPage()) return;
+    this.selectionPage.set(page);
+    this.loadSelection();
+  }
+
   private loadSelection(more=false): void {
     const accept=(result:unknown,total:unknown)=>{const incoming=this.findList(result).map(item=>this.meta.path==='pages/member/starshop'?this.toNewWord(item):this.toWord(item)).filter(w=>w.word);this.words.set(more?[...this.words(),...incoming]:incoming);this.selectionTotal.set(Number(total)||this.words().length);};
     const failure=(error:ApiError)=>{if(more){this.selectionPage.update(p=>p-1);this.platform.toast(error.message,'error');}else this.error.set(error.message);};
@@ -1206,7 +1232,18 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
     const owner = Number(this.session.user()?.groupId) === 3 ? this.session.student() : this.session.user();
     this.selectionLoading.set(true);
-    this.selectionRequest=this.api.get<unknown>('e_words_list',{uid:owner?.userId,cpage:this.selectionPage(),psize:100}).pipe(finalize(()=>{this.selectionLoading.set(false);this.loading.set(false);})).subscribe({next:({result,page})=>accept(result,page?.itemCount),error:failure});
+    this.selectionRequest=this.api.get<unknown>('e_words_list',{uid:owner?.userId,cpage:this.selectionPage(),psize:this.newWordPageSize,learningStatus:this.activeSelectionBucket()}).pipe(finalize(()=>{this.selectionLoading.set(false);this.loading.set(false);})).subscribe({
+      next: ({result,page}) => {
+        const incoming = this.findList(result).map((item) => this.toNewWord(item)).filter((word) => word.word);
+        this.words.set(incoming);
+        for (const word of incoming) this.newWordSelectionCache.set(word.id, word);
+        const counts = {pending: Number(page?.['pendingCount'] ?? 0), completed: Number(page?.['completedCount'] ?? 0)};
+        this.newWordCounts.set(counts);
+        this.selectionTotal.set(Number(page?.itemCount ?? counts[this.activeSelectionBucket()]));
+        this.error.set('');
+      },
+      error: failure,
+    });
   }
 
   private loadStageWords(): void {
@@ -1938,6 +1975,8 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.calendarLoading.set(false);
     this.calendarError.set('');
     this.selected.set(new Set());
+    this.newWordSelectionCache.clear();
+    this.newWordCounts.set({ pending: 0, completed: 0 });
     this.selectionRevealed.set(new Set());
     this.selectionAssessment.set({});
     this.activeSelectionBucket.set('pending');

+ 15 - 2
scripts/deploy-admin-functions.mjs

@@ -2588,8 +2588,21 @@ async function recordDetailData(input, current) {
   const order = recordAddon.dsid ? await orderDetailRow(recordAddon.dsid, current, false) : null; return { words, addon: order ? [order] : [], record };
 }
 async function newWordPage(input, current) {
-  const uid = await authorizeRequestedUser(current, input.uid); const paging = largePageInput(input); const values = [DEFAULT_COMPANY_ID, String(uid)]; const where = 'p."company" = $1 AND CAST(p."yhid" AS text) = $2 AND LOWER(CAST(p."jrscb" AS text)) IN (\'1\',\'true\')'; const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "PracticeRecord" p WHERE ' + where, values); const rows = await Psql.query('SELECT p.*, pc."generalId" AS "__practiceGeneralId", c.*, row_to_json(v) AS "__addon" FROM "PracticeRecord" p JOIN "CommonModel" c ON c."company" = $1 AND CAST(c."modelId" AS text) = \'52\' AND CAST(c."generalId" AS text) = CAST(p."scid" AS text) LEFT JOIN "VocabularyWord" v ON v."company" = $1 AND CAST(v."id" AS text) = CAST(c."itemId" AS text) LEFT JOIN "CommonModel" pc ON pc."company" = $1 AND CAST(pc."modelId" AS text) = \'53\' AND CAST(pc."itemId" AS text) = CAST(p."id" AS text) WHERE ' + where + ' ORDER BY p."updatedAt" DESC LIMIT $3 OFFSET $4', values.concat([paging.size, (paging.index - 1) * paging.size]));
-  const result = rows.map((source) => { const practice = { id: source.id, kcid: source.kcid, scid: source.scid, xxcs: source.xxcs, yhid: source.yhid, jrscb: source.jrscb }; const detail = normalizeWordRow(source); return { ...practice, GeneralID: number(source.__practiceGeneralId || source.id), Title: detail.Title || detail.title || '', detail: [detail] }; }); const total = number(countRow.total); return { rows: result, page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
+  const uid = await authorizeRequestedUser(current, input.uid);
+  const paging = largePageInput(input);
+  const status = text(input.learningStatus);
+  if (status && status !== 'pending' && status !== 'completed') fail(400, '生词学习状态无效');
+  const values = [DEFAULT_COMPANY_ID, String(uid)];
+  const from = ' FROM "PracticeRecord" p JOIN "CommonModel" c ON c."company" = $1 AND CAST(c."modelId" AS text) = \'52\' AND CAST(c."generalId" AS text) = CAST(p."scid" AS text)';
+  const where = ' WHERE p."company" = $1 AND CAST(p."yhid" AS text) = $2 AND LOWER(CAST(p."jrscb" AS text)) IN (\'1\',\'true\')';
+  const learned = 'CASE WHEN CAST(p."xxcs" AS text) ~ \'^[0-9]+(?:\\.[0-9]+)?$\' THEN CAST(p."xxcs" AS numeric) ELSE 0 END > 0';
+  const countRow = await Psql.one('SELECT COUNT(*) FILTER (WHERE NOT (' + learned + '))::int AS "pendingCount", COUNT(*) FILTER (WHERE ' + learned + ')::int AS "completedCount"' + from + where, values);
+  const filter = status ? ' AND ' + (status === 'completed' ? learned : 'NOT (' + learned + ')') : '';
+  const rows = await Psql.query('SELECT p.*, pc."generalId" AS "__practiceGeneralId", c.*, row_to_json(v) AS "__addon"' + from + ' LEFT JOIN "VocabularyWord" v ON v."company" = $1 AND CAST(v."id" AS text) = CAST(c."itemId" AS text) LEFT JOIN "CommonModel" pc ON pc."company" = $1 AND CAST(pc."modelId" AS text) = \'53\' AND CAST(pc."itemId" AS text) = CAST(p."id" AS text)' + where + filter + ' ORDER BY p."updatedAt" DESC, p."id" DESC LIMIT $3 OFFSET $4', values.concat([paging.size, (paging.index - 1) * paging.size]));
+  const result = rows.map((source) => { const practice = { id: source.id, kcid: source.kcid, scid: source.scid, xxcs: source.xxcs, yhid: source.yhid, jrscb: source.jrscb }; const detail = normalizeWordRow(source); return { ...practice, GeneralID: number(source.__practiceGeneralId || source.id), Title: detail.Title || detail.title || '', detail: [detail] }; });
+  const pendingCount = number(countRow.pendingCount), completedCount = number(countRow.completedCount);
+  const total = status === 'pending' ? pendingCount : status === 'completed' ? completedCount : pendingCount + completedCount;
+  return { rows: result, page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size, pendingCount, completedCount } };
 }
 async function courseWordPage(input, current) {
   const uid = await authorizeRequestedUser(current, input.uid); const nodes = String(input.nids || input.nid || '').split(',').map((value) => String(number(value))).filter((value) => value !== '0'); if (!nodes.length) fail(400, '缺少词库单元 ID'); const paging = { index: Math.max(1, number(input.cpage || input.page, 1)), size: Math.min(500, Math.max(1, number(input.psize || input.pageSize, 100))) }; const values = [DEFAULT_COMPANY_ID, String(uid), nodes]; const where = 'c."company" = $1 AND c."modelId" = 52 AND CAST(c."nodeId" AS text) = ANY($3::text[])';