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

feat(mobile): paginate learning history

彭峰 1 месяц назад
Родитель
Сommit
fe2f961f94

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

@@ -109,10 +109,17 @@ class LearningModulesApiService {
       return this.result<T>([{ szyh: 100, honeyname: '张雅茜' }]);
       return this.result<T>([{ szyh: 100, honeyname: '张雅茜' }]);
     }
     }
     if (action === 'app_learning_overview') {
     if (action === 'app_learning_overview') {
-      const records = [
-        { GeneralID: 201, CreateTime: '2026-08-01 09:30:00', dqrq: '20260801', con: '初中英语@Unit 1', xxqs: JSON.stringify([{ Title: 'apple' }]) },
-        { GeneralID: 202, CreateTime: '2026-08-04 16:20:00', dqrq: '20260804', con: '高中英语@Unit 2' },
-      ];
+      const records = Number(params['studentId']) === 777
+        ? Array.from({ length: 45 }, (_, index) => ({
+            GeneralID: 5000 + index,
+            CreateTime: `2026-08-${String(31 - (index % 20)).padStart(2, '0')} 09:30:00`,
+            dqrq: '20260801',
+            con: `分页学习记录 ${index + 1}`,
+          }))
+        : [
+            { GeneralID: 201, CreateTime: '2026-08-01 09:30:00', dqrq: '20260801', con: '初中英语@Unit 1', xxqs: JSON.stringify([{ Title: 'apple' }]) },
+            { GeneralID: 202, CreateTime: '2026-08-04 16:20:00', dqrq: '20260804', con: '高中英语@Unit 2' },
+          ];
       const selected = String(params['date'] ?? '');
       const selected = String(params['date'] ?? '');
       return this.result<T>({
       return this.result<T>({
         records: selected ? records.filter((item) => item.CreateTime.startsWith(selected)) : records,
         records: selected ? records.filter((item) => item.CreateTime.startsWith(selected)) : records,
@@ -683,4 +690,30 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelectorAll('.completed-column .word-refine-item').length).toBe(2);
     expect(harness.routeNativeElement?.querySelectorAll('.completed-column .word-refine-item').length).toBe(2);
     expect(harness.routeNativeElement?.querySelector('.pending-column')).toBeNull();
     expect(harness.routeNativeElement?.querySelector('.pending-column')).toBeNull();
   });
   });
+
+  it('shows learning history in pages of twenty records', async () => {
+    user.set({ userId: 777, userName: 'paged-student', honeyName: '分页学员', groupId: 1 });
+    session.groupId.set(1);
+    const harness = await RouterTestingHarness.create();
+
+    await harness.navigateByUrl('/pages/his/his');
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+
+    expect(harness.routeNativeElement?.querySelector('.record-actions')?.textContent).toContain('45 条');
+    expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(20);
+    expect(harness.routeNativeElement?.querySelector('.record-pagination')?.textContent).toContain('第 1 / 3 页');
+
+    const pageButtons = harness.routeNativeElement?.querySelectorAll('.record-pagination button') as NodeListOf<HTMLButtonElement>;
+    expect(pageButtons[0].disabled).toBeTrue();
+    pageButtons[1].click();
+    harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(20);
+    expect(harness.routeNativeElement?.querySelector('.record-pagination')?.textContent).toContain('第 2 / 3 页');
+
+    (harness.routeNativeElement?.querySelectorAll('.record-pagination button')[1] as HTMLButtonElement).click();
+    harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(5);
+    expect((harness.routeNativeElement?.querySelectorAll('.record-pagination button')[1] as HTMLButtonElement).disabled).toBeTrue();
+  });
 });
 });

+ 8 - 1
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.html

@@ -52,10 +52,17 @@
       </header>
       </header>
       @if (visibleItems().length) {
       @if (visibleItems().length) {
         <div class="record-list">
         <div class="record-list">
-          @for (item of visibleItems(); track item['GeneralID'] || $index) {
+          @for (item of paginatedItems(); track item['GeneralID'] || $index) {
             <article class="learning-item" [class.readonly]="!canStartPointReading" (click)="open(item)"><lucide-icon [img]="item['xxqs'] ? icons.BookOpen : icons.CalendarDays" [size]="21" /><div><time>{{ time(item) }}</time><strong>{{ title(item) }}</strong></div>@if (item['xxqs']) { <button type="button" class="reading-button" [disabled]="readingLoadingId() === recordId(item)" (click)="openReading(item, $event)">{{ readingLoadingId() === recordId(item) ? '加载中' : '阅读' }}</button> }<lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" /></article>
             <article class="learning-item" [class.readonly]="!canStartPointReading" (click)="open(item)"><lucide-icon [img]="item['xxqs'] ? icons.BookOpen : icons.CalendarDays" [size]="21" /><div><time>{{ time(item) }}</time><strong>{{ title(item) }}</strong></div>@if (item['xxqs']) { <button type="button" class="reading-button" [disabled]="readingLoadingId() === recordId(item)" (click)="openReading(item, $event)">{{ readingLoadingId() === recordId(item) ? '加载中' : '阅读' }}</button> }<lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" /></article>
           }
           }
         </div>
         </div>
+        @if (recordPageCount() > 1) {
+          <nav class="record-pagination" aria-label="学习记录分页">
+            <button type="button" [disabled]="recordPage() === 1" (click)="goToRecordPage(recordPage() - 1)"><lucide-icon [img]="icons.ChevronLeft" [size]="17" />上一页</button>
+            <span>第 {{ recordPage() }} / {{ recordPageCount() }} 页</span>
+            <button type="button" [disabled]="recordPage() === recordPageCount()" (click)="goToRecordPage(recordPage() + 1)">下一页<lucide-icon [img]="icons.ChevronRight" [size]="17" /></button>
+          </nav>
+        }
       }
       }
       @if (selectedSchedules().length) {
       @if (selectedSchedules().length) {
         <div class="schedule-plan-list" aria-label="当天学习计划">
         <div class="schedule-plan-list" aria-label="当天学习计划">

+ 5 - 0
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.scss

@@ -64,6 +64,11 @@
 .record-actions { display: flex; flex-shrink: 0; align-items: center; gap: 8px; }
 .record-actions { display: flex; flex-shrink: 0; align-items: center; gap: 8px; }
 .record-actions > button, .record-empty button { min-height: 34px; padding: 0 11px; color: var(--brand-strong); background: var(--brand-pale); border: 1px solid color-mix(in srgb, var(--brand) 32%, var(--line)); border-radius: var(--radius-sm); font-size: 11px; font-weight: 650; }
 .record-actions > button, .record-empty button { min-height: 34px; padding: 0 11px; color: var(--brand-strong); background: var(--brand-pale); border: 1px solid color-mix(in srgb, var(--brand) 32%, var(--line)); border-radius: var(--radius-sm); font-size: 11px; font-weight: 650; }
 .record-list { background: white; }
 .record-list { background: white; }
+.record-pagination { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; padding: 14px 16px; background: var(--surface-muted); border-top: 1px solid var(--line); gap: 12px; }
+.record-pagination > span { color: var(--ink-soft); font-size: 12px; font-weight: 700; white-space: nowrap; }
+.record-pagination > button { display: inline-flex; min-height: 38px; align-items: center; justify-content: center; padding: 0 12px; color: var(--brand-strong); background: white; border: 1px solid color-mix(in srgb, var(--brand) 28%, var(--line)); border-radius: var(--radius-sm); font-size: 12px; font-weight: 750; gap: 4px; }
+.record-pagination > button:last-child { justify-self: end; }
+.record-pagination > button:disabled { color: var(--ink-faint); background: color-mix(in srgb, white 72%, var(--surface-muted)); border-color: var(--line); cursor: not-allowed; }
 .learning-record-card .learning-item:last-child { border-bottom: 0; }
 .learning-record-card .learning-item:last-child { border-bottom: 0; }
 .schedule-plan-list { background: #fbfdfc; border-top: 1px solid var(--line); }
 .schedule-plan-list { background: #fbfdfc; border-top: 1px solid var(--line); }
 .schedule-plan-list > p { margin: 0; padding: 10px 14px; color: var(--ink-soft); background: var(--brand-wash); border-bottom: 1px solid var(--line); font-size: 12px; }
 .schedule-plan-list > p { margin: 0; padding: 10px 14px; color: var(--ink-soft); background: var(--brand-wash); border-bottom: 1px solid var(--line); font-size: 12px; }

+ 18 - 1
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.ts

@@ -60,6 +60,8 @@ export class StudyRecordsPageComponent implements OnInit {
   readonly scheduleDateCounts = signal<Record<string, { planned: number; missed: number }>>({});
   readonly scheduleDateCounts = signal<Record<string, { planned: number; missed: number }>>({});
   readonly overviewReadingRecord = signal<Item | null>(null);
   readonly overviewReadingRecord = signal<Item | null>(null);
   readonly readingLoadingId = signal('');
   readonly readingLoadingId = signal('');
+  readonly recordPage = signal(1);
+  readonly recordPageSize = 20;
   readonly datePickerMonthLabel = computed(() => new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long' }).format(this.datePickerMonth()));
   readonly datePickerMonthLabel = computed(() => new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long' }).format(this.datePickerMonth()));
   readonly datePickerDays = computed<DatePickerDay[]>(() => {
   readonly datePickerDays = computed<DatePickerDay[]>(() => {
     const month = this.datePickerMonth();
     const month = this.datePickerMonth();
@@ -91,6 +93,11 @@ export class StudyRecordsPageComponent implements OnInit {
     if (!selectedDate) return this.items();
     if (!selectedDate) return this.items();
     return this.items().filter((item) => this.dateKey(item['CreateTime'] ?? item['createTime'] ?? item['dqrq'] ?? item['date']) === selectedDate);
     return this.items().filter((item) => this.dateKey(item['CreateTime'] ?? item['createTime'] ?? item['dqrq'] ?? item['date']) === selectedDate);
   });
   });
+  readonly recordPageCount = computed(() => Math.max(1, Math.ceil(this.visibleItems().length / this.recordPageSize)));
+  readonly paginatedItems = computed(() => {
+    const start = (this.recordPage() - 1) * this.recordPageSize;
+    return this.visibleItems().slice(start, start + this.recordPageSize);
+  });
   readonly latestReadingRecord = computed(() => this.overviewReadingRecord() ?? this.items().find((item) => extractReadingWords(item['xxqs']).length > 0));
   readonly latestReadingRecord = computed(() => this.overviewReadingRecord() ?? this.items().find((item) => extractReadingWords(item['xxqs']).length > 0));
   readonly latestReadingWordCount = computed(() => {
   readonly latestReadingWordCount = computed(() => {
     const item = this.latestReadingRecord();
     const item = this.latestReadingRecord();
@@ -157,6 +164,7 @@ export class StudyRecordsPageComponent implements OnInit {
     this.studentId = Number(this.studentId || 0);
     this.studentId = Number(this.studentId || 0);
     this.syncSelectedStudentContext();
     this.syncSelectedStudentContext();
     this.selectedDate = this.dateKey(this.selectedDate);
     this.selectedDate = this.dateKey(this.selectedDate);
+    this.recordPage.set(1);
     this.syncDatePickerMonth();
     this.syncDatePickerMonth();
     this.syncHistoryState();
     this.syncHistoryState();
     this.load();
     this.load();
@@ -164,6 +172,7 @@ export class StudyRecordsPageComponent implements OnInit {
 
 
   showAllRecords(): void {
   showAllRecords(): void {
     this.selectedDate = '';
     this.selectedDate = '';
+    this.recordPage.set(1);
     this.syncHistoryState();
     this.syncHistoryState();
     this.load();
     this.load();
   }
   }
@@ -183,6 +192,7 @@ export class StudyRecordsPageComponent implements OnInit {
 
 
   selectDate(date: string): void {
   selectDate(date: string): void {
     this.selectedDate = date;
     this.selectedDate = date;
+    this.recordPage.set(1);
     this.closeDatePicker();
     this.closeDatePicker();
     this.syncHistoryState();
     this.syncHistoryState();
     this.load();
     this.load();
@@ -196,6 +206,7 @@ export class StudyRecordsPageComponent implements OnInit {
 
 
   clearDate(): void {
   clearDate(): void {
     this.selectedDate = '';
     this.selectedDate = '';
+    this.recordPage.set(1);
     this.closeDatePicker();
     this.closeDatePicker();
     this.syncHistoryState();
     this.syncHistoryState();
     this.load();
     this.load();
@@ -203,6 +214,10 @@ export class StudyRecordsPageComponent implements OnInit {
 
 
   toggleCoursesExpanded(): void { this.coursesExpanded.update((expanded) => !expanded); }
   toggleCoursesExpanded(): void { this.coursesExpanded.update((expanded) => !expanded); }
 
 
+  goToRecordPage(page: number): void {
+    this.recordPage.set(Math.min(this.recordPageCount(), Math.max(1, page)));
+  }
+
   selectedDateLabel(): string {
   selectedDateLabel(): string {
     if (!this.selectedDate) return '全部日期';
     if (!this.selectedDate) return '全部日期';
     const [year, month, day] = this.selectedDate.split('-').map(Number);
     const [year, month, day] = this.selectedDate.split('-').map(Number);
@@ -436,7 +451,9 @@ export class StudyRecordsPageComponent implements OnInit {
     }).pipe(finalize(() => this.loading.set(false))).subscribe({
     }).pipe(finalize(() => this.loading.set(false))).subscribe({
       next: ({ result }) => {
       next: ({ result }) => {
         const overview = result ?? {} as LearningOverview;
         const overview = result ?? {} as LearningOverview;
-        this.items.set(Array.isArray(overview.records) ? overview.records : []);
+        const records = Array.isArray(overview.records) ? overview.records : [];
+        this.items.set(records);
+        this.recordPage.update((page) => Math.min(page, Math.max(1, Math.ceil(records.length / this.recordPageSize))));
         this.overviewReadingRecord.set(overview.latestReadingRecord ?? null);
         this.overviewReadingRecord.set(overview.latestReadingRecord ?? null);
         this.learningSchedules.set(Array.isArray(overview.schedules) ? overview.schedules : []);
         this.learningSchedules.set(Array.isArray(overview.schedules) ? overview.schedules : []);
         this.recordDateCounts.set(overview.recordDateCounts ?? {});
         this.recordDateCounts.set(overview.recordDateCounts ?? {});