Преглед изворни кода

feat: add paginated student vocabulary preview

彭峰 пре 1 месец
родитељ
комит
14369b2453

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

@@ -13,6 +13,7 @@ import { SessionService } from './session.service';
 class LearningModulesApiService {
   lastScheduleParams: Record<string, unknown> = {};
   lastNodeParams: Record<string, unknown> = {};
+  lastWordParams: Record<string, unknown> = {};
 
   get<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
     if (action === 'content_list' && Number(params['nid']) === 291) {
@@ -58,10 +59,21 @@ class LearningModulesApiService {
       ]);
     }
     if (action === 'e_ck_list') {
-      return this.result<T>([
-        { GeneralID: 1001, NodeID: 811, Title: 'apple', sy: 'n. 苹果', yp: 'apple.mp3', w_learned: 1 },
-        { GeneralID: 1002, NodeID: 812, Title: 'banana', sy: 'n. 香蕉', yp: 'banana.mp3', w_learned: 0 },
-      ]);
+      this.lastWordParams = params;
+      const allWords = Number(params['nids']) === 813
+        ? Array.from({ length: 65 }, (_, index) => ({ GeneralID: 2001 + index, NodeID: 813, Title: `word-${index + 1}`, sy: `释义 ${index + 1}` }))
+        : [
+            { GeneralID: 1001, NodeID: 811, Title: 'apple', sy: 'n. 苹果', yp: 'apple.mp3', w_learned: 1 },
+            { GeneralID: 1002, NodeID: 812, Title: 'banana', sy: 'n. 香蕉', yp: 'banana.mp3', w_learned: 0 },
+          ];
+      const pageIndex = Math.max(1, Number(params['cpage'] ?? 1));
+      const pageSize = Math.max(1, Number(params['psize'] ?? 30));
+      const result = allWords.slice((pageIndex - 1) * pageSize, pageIndex * pageSize);
+      return of({
+        retcode: 1,
+        result: result as T,
+        page: { itemCount: allWords.length, pageCount: Math.ceil(allWords.length / pageSize), pageIndex, pageSize },
+      });
     }
     if (action === 'node_get') return this.result<T>([{ NodeID: 3, NodeName: '专项训练' }]);
     if (action === 'vote_ask') return this.result<T>([{ ID: 1, Title: '第一组问题' }]);
@@ -215,6 +227,24 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelector('.readonly-word-list')?.textContent).toContain('n. 苹果');
     expect(harness.routeNativeElement?.querySelector('.repeat-list')).toBeNull();
 
+    await harness.navigateByUrl('/pages/home/index');
+    await harness.navigateByUrl('/pages/stu/stu_con?nid=813&pnid=801&readonly=1');
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    const wordApi = TestBed.inject(ApiService) as unknown as LearningModulesApiService;
+    expect(wordApi.lastWordParams['psize']).toBe(30);
+    expect(harness.routeNativeElement?.querySelectorAll('.readonly-word-list article').length).toBe(30);
+    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();
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    expect(wordApi.lastWordParams['cpage']).toBe(2);
+    expect(harness.routeNativeElement?.querySelectorAll('.readonly-word-list article').length).toBe(30);
+    expect(harness.routeNativeElement?.querySelector('.readonly-word-header')?.textContent).toContain('第 31–60 条 · 共 65 个单词');
+    expect(harness.routeNativeElement?.querySelector('.readonly-word-list')?.textContent).toContain('word-31');
+    expect(harness.routeNativeElement?.querySelector('.word-preview-pagination')?.textContent).toContain('2 / 3 页');
+
     await harness.navigateByUrl('/pages/stu/stu_list?nid=999&bindingId=799');
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();

+ 14 - 2
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html

@@ -58,10 +58,10 @@
   } @else if (isReadOnlyWordView) {
     <section class="readonly-word-card" aria-labelledby="readonly-word-title">
       <header class="readonly-word-header">
-        <div><strong id="readonly-word-title">单词列表</strong><span>共 {{ words().length }} 个单词</span></div>
+        <div><strong id="readonly-word-title">单词列表</strong><span>第 {{ wordPreviewRangeStart() }}–{{ wordPreviewRangeEnd() }} 条 · 共 {{ wordPreviewTotal() }} 个单词</span></div>
         <em>学生只读</em>
       </header>
-      <div class="readonly-word-list">
+      <div class="readonly-word-list" [class.is-updating]="wordPreviewLoading()" [attr.aria-busy]="wordPreviewLoading()" aria-live="polite">
         @for (word of words(); track word.id) {
           <article>
             <div><strong>{{ word.word }}</strong>@if (word.phonetic) { <small>{{ word.phonetic }}</small> }</div>
@@ -71,6 +71,18 @@
           <div class="empty-state"><p>该单元暂无单词</p></div>
         }
       </div>
+      @if (wordPreviewPageCount() > 1) {
+        <nav class="word-preview-pagination" aria-label="单词列表分页">
+          <button type="button" class="page-direction" [disabled]="wordPreviewPage() === 1 || wordPreviewLoading()" (click)="openWordPreviewPage(wordPreviewPage() - 1)"><lucide-icon [img]="icons.ChevronLeft" [size]="17" />上一页</button>
+          <div class="page-numbers">
+            @for (page of wordPreviewPages(); track page) {
+              <button type="button" [class.active]="page === wordPreviewPage()" [attr.aria-current]="page === wordPreviewPage() ? 'page' : null" [attr.aria-label]="'第 ' + page + ' 页'" [disabled]="wordPreviewLoading()" (click)="openWordPreviewPage(page)">{{ page }}</button>
+            }
+          </div>
+          <button type="button" class="page-direction" [disabled]="wordPreviewPage() === wordPreviewPageCount() || wordPreviewLoading()" (click)="openWordPreviewPage(wordPreviewPage() + 1)">下一页<lucide-icon [img]="icons.ChevronRight" [size]="17" /></button>
+          <span>{{ wordPreviewPage() }} / {{ wordPreviewPageCount() }} 页</span>
+        </nav>
+      }
       <p class="readonly-word-note">学生可查看老师分配的单词,点读、跟读和学习记录由陪练老师在上课时操作</p>
     </section>
   } @else if (isSelectionPage) {

+ 62 - 0
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.scss

@@ -7,6 +7,68 @@ button {
   font: inherit;
 }
 
+.readonly-word-list {
+  transition: opacity 160ms ease;
+}
+
+.readonly-word-list.is-updating {
+  opacity: .48;
+  pointer-events: none;
+}
+
+.word-preview-pagination {
+  display: grid;
+  align-items: center;
+  padding: 15px 16px;
+  background: white;
+  border-top: 1px solid var(--line);
+  grid-template-columns: auto minmax(0, 1fr) auto;
+  gap: 10px;
+}
+
+.word-preview-pagination button {
+  display: inline-flex;
+  min-width: 34px;
+  height: 34px;
+  align-items: center;
+  justify-content: center;
+  padding: 0 9px;
+  color: var(--ink-soft);
+  background: var(--surface-muted);
+  border: 1px solid var(--line);
+  border-radius: 9px;
+  font-size: 12px;
+  font-weight: 700;
+  gap: 3px;
+}
+
+.word-preview-pagination button:hover:not(:disabled),
+.word-preview-pagination button.active {
+  color: white;
+  background: var(--brand-strong);
+  border-color: var(--brand-strong);
+}
+
+.word-preview-pagination button:disabled {
+  cursor: not-allowed;
+  opacity: .45;
+}
+
+.page-numbers {
+  display: flex;
+  min-width: 0;
+  align-items: center;
+  justify-content: center;
+  gap: 5px;
+}
+
+.word-preview-pagination > span {
+  grid-column: 1 / -1;
+  color: var(--ink-soft);
+  font-size: 11px;
+  text-align: center;
+}
+
 .node-intro {
   display: flex;
   align-items: center;

+ 60 - 12
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -103,6 +103,9 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly catalogCategoryIndex = signal(0);
   readonly selectedNodes = signal<Set<string>>(new Set());
   readonly words = signal<Word[]>([]);
+  readonly wordPreviewPage = signal(1);
+  readonly wordPreviewTotal = signal(0);
+  readonly wordPreviewLoading = signal(false);
   readonly selected = signal<Set<string>>(new Set());
   readonly selectionRevealed = signal<Set<string>>(new Set());
   readonly selectionAssessment = signal<Record<string, 'known' | 'unknown'>>({});
@@ -183,6 +186,17 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   readonly current = computed(() => this.words()[this.currentIndex()] ?? null);
   readonly currentQuestion = computed(() => this.questions()[this.currentIndex()] ?? null);
+  readonly wordPreviewPageCount = computed(() => Math.max(1, Math.ceil(this.wordPreviewTotal() / this.wordPreviewPageSize)));
+  readonly wordPreviewPages = computed(() => {
+    const pageCount = this.wordPreviewPageCount();
+    if (pageCount <= 3) return Array.from({ length: pageCount }, (_, index) => index + 1);
+    const start = Math.min(Math.max(1, this.wordPreviewPage() - 1), pageCount - 2);
+    return Array.from({ length: 3 }, (_, index) => start + index);
+  });
+  readonly wordPreviewRangeStart = computed(() => this.wordPreviewTotal()
+    ? ((this.wordPreviewPage() - 1) * this.wordPreviewPageSize) + 1
+    : 0);
+  readonly wordPreviewRangeEnd = computed(() => Math.min(this.wordPreviewPage() * this.wordPreviewPageSize, this.wordPreviewTotal()));
   readonly memoryView = computed(() => buildAntiForgetView(this.calendarItems()));
   readonly memorySummary = computed(() => this.memoryView().summary);
   readonly memoryCalendar = computed(() => buildAntiForgetCalendar(
@@ -214,6 +228,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   private readonly api = inject(ApiService);
   private readonly session = inject(SessionService);
   private readonly platform = inject(PlatformService);
+  private readonly wordPreviewPageSize = 30;
   private examTimer: ReturnType<typeof setInterval> | null = null;
   private antiStartedAt = Date.now();
 
@@ -368,6 +383,15 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.toggleNode(node);
   }
 
+  openWordPreviewPage(page: number): void {
+    const target = Math.min(Math.max(1, page), this.wordPreviewPageCount());
+    if (target === this.wordPreviewPage() || this.wordPreviewLoading()) return;
+    const previous = this.wordPreviewPage();
+    this.wordPreviewPage.set(target);
+    this.wordPreviewLoading.set(true);
+    this.loadReadOnlyWords(true, previous);
+  }
+
   selectCatalogCategory(index: number): void {
     if (index === this.catalogCategoryIndex()) return;
     this.catalogCategoryIndex.set(index);
@@ -1012,18 +1036,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   private loadStageWords(): void {
     const nid = this.query('nid');
     if (nid && this.isReadOnlyWordView) {
-      const learner = this.session.student() ?? this.session.user();
-      this.api.get<unknown>('e_ck_list', {
-        checkwords: 2,
-        nids: nid,
-        modelId: 52,
-        cpage: 1,
-        psize: 500,
-        uid: learner?.userId,
-      }).pipe(finalize(() => this.loading.set(false))).subscribe({
-        next: ({ result }) => this.initializeStage(this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word)),
-        error: (error: ApiError) => this.error.set(error.message),
-      });
+      this.loadReadOnlyWords();
       return;
     }
     if (nid) {
@@ -1042,6 +1055,38 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.initializeStage(stored);
   }
 
+  private loadReadOnlyWords(pageChange = false, previousPage = 1): void {
+    const learner = this.session.student() ?? this.session.user();
+    this.api.get<unknown>('e_ck_list', {
+      checkwords: 2,
+      nids: this.query('nid'),
+      modelId: 52,
+      cpage: this.wordPreviewPage(),
+      psize: this.wordPreviewPageSize,
+      uid: learner?.userId,
+    }).pipe(finalize(() => {
+      if (pageChange) this.wordPreviewLoading.set(false);
+      else this.loading.set(false);
+    })).subscribe({
+      next: ({ result, page }) => {
+        const words = this.findList(result).map((item) => this.toWord(item)).filter((word) => word.word);
+        this.words.set(words);
+        this.wordPreviewTotal.set(Math.max(words.length, Number(page?.itemCount ?? 0)));
+        if (pageChange) {
+          setTimeout(() => document.getElementById('readonly-word-title')?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
+        }
+      },
+      error: (error: ApiError) => {
+        if (pageChange) {
+          this.wordPreviewPage.set(previousPage);
+          this.platform.toast('该页单词加载失败,请重试', 'error');
+          return;
+        }
+        this.error.set(error.message);
+      },
+    });
+  }
+
   private initializeStage(source: Word[]): void {
     const words = this.isStudyListPage ? source : this.shuffle(source);
     this.words.set(words);
@@ -1613,6 +1658,9 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.nodes.set([]);
     this.selectedNodes.set(new Set());
     this.words.set([]);
+    this.wordPreviewPage.set(1);
+    this.wordPreviewTotal.set(0);
+    this.wordPreviewLoading.set(false);
     this.selected.set(new Set());
     this.selectionRevealed.set(new Set());
     this.selectionAssessment.set({});