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

feat: filter lesson history by month and student

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

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

@@ -123,6 +123,13 @@ class LearningModulesApiService {
         { GeneralID: 902, bxrq: '2026-08-03 00:00:00', sdsd: '18:00', Title: '历史伴学', plxm: '李老师', dszt: 30 },
         { GeneralID: 903, bxrq: '2099-01-01 00:00:00', sdsd: '18:00', Title: '未来计划', plxm: '王老师', dszt: 0 },
       ];
+      const historySchedules = [
+        { GeneralID: 911, bxrq: '2026-07-14 00:00:00', sdsd: '22:00', Title: '谢晨晨(60分钟练习)', plxm: '张老师', dszt: 30 },
+        { GeneralID: 912, bxrq: '2026-07-14 00:00:00', sdsd: '17:00', Title: '谢谢(60分钟练习)', plxm: '张老师', dszt: 30 },
+        { GeneralID: 913, bxrq: '2026-03-09 00:00:00', sdsd: '10:00', Title: '花花(60分钟练习)', plxm: '张老师', dszt: 30 },
+        { GeneralID: 914, bxrq: '2026-01-18 00:00:00', sdsd: '16:30', Title: '小赵(60分钟练习)', plxm: '张老师', dszt: 30 },
+        { GeneralID: 915, bxrq: '2026-01-18 00:00:00', sdsd: '16:00', Title: '小黄(60分钟练习)', plxm: '张老师', dszt: 30 },
+      ];
       const rangeStart = String(params['rangeStart'] ?? '');
       const rangeEnd = String(params['rangeEnd'] ?? '');
       return this.result<T>({
@@ -130,9 +137,11 @@ class LearningModulesApiService {
           { GeneralID: 801, id: 701, kcid: 801, Title: '初中英语|七年级上册@同步词汇' },
           { GeneralID: 802, id: 702, Title2: '【初中英语】阅读训练', syjd: '{"811":"811@Unit 1@1"}' },
         ],
-        schedules: rangeStart && rangeEnd
-          ? schedules.filter((item) => item.bxrq.slice(0, 10) >= rangeStart && item.bxrq.slice(0, 10) <= rangeEnd)
-          : schedules,
+        schedules: params['history']
+          ? historySchedules
+          : rangeStart && rangeEnd
+            ? schedules.filter((item) => item.bxrq.slice(0, 10) >= rangeStart && item.bxrq.slice(0, 10) <= rangeEnd)
+            : schedules,
         refreshedAt: '2026-08-26T00:00:00.000Z',
       });
     }
@@ -483,6 +492,26 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelector('.home-header .header-action')?.getAttribute('href')).toBe('/pages/home/ucenter');
     expect(harness.routeNativeElement?.querySelector('.content-section')).toBeNull();
 
+    await harness.navigateByUrl('/pages/member/ke_xiao');
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    const monthFilter = harness.routeNativeElement?.querySelector('[aria-label="按月份筛选销课记录"]') as HTMLSelectElement;
+    expect(monthFilter.value).toBe('2026-07');
+    expect(monthFilter.options.length).toBe(4);
+    expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(2);
+    monthFilter.value = '2026-03';
+    monthFilter.dispatchEvent(new Event('change'));
+    const studentSearch = harness.routeNativeElement?.querySelector('[aria-label="按学生姓名筛选销课记录"]') as HTMLInputElement;
+    studentSearch.value = '花';
+    studentSearch.dispatchEvent(new Event('input'));
+    harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(1);
+    expect(harness.routeNativeElement?.querySelector('.schedule-list')?.textContent).toContain('花花');
+    expect(harness.routeNativeElement?.querySelector('.schedule-band > header')?.textContent).toContain('1 / 5');
+    (harness.routeNativeElement?.querySelector('.history-filter-panel footer button') as HTMLButtonElement).click();
+    harness.fixture.detectChanges();
+    expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(5);
+
     await harness.navigateByUrl('/pages/his/his');
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();

+ 29 - 2
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.html

@@ -12,7 +12,34 @@
     }
 
     <section class="schedule-band">
-      <header><lucide-icon [img]="isCoach ? icons.GraduationCap : icons.CalendarDays" [size]="21" /><h2>{{ isHistory ? '销课记录' : isCoach ? '陪练排课' : '伴学列表' }}</h2><span>{{ hasScheduleCalendar ? visibleSchedules().length + ' / ' + schedules().length : schedules().length }} 节</span></header>
+      <header><lucide-icon [img]="isCoach ? icons.GraduationCap : icons.CalendarDays" [size]="21" /><h2>{{ isHistory ? '销课记录' : isCoach ? '陪练排课' : '伴学列表' }}</h2><span>{{ isHistory || hasScheduleCalendar ? visibleSchedules().length + ' / ' + schedules().length : schedules().length }} 节</span></header>
+      @if (isHistory) {
+        <section class="history-filter-panel" aria-label="销课记录筛选">
+          <div class="history-filter-grid">
+            <label>
+              <span>月份</span>
+              <div class="history-filter-control month-control">
+                <lucide-icon [img]="icons.CalendarDays" [size]="18" />
+                <select [ngModel]="historyMonth()" (ngModelChange)="setHistoryMonth($event)" aria-label="按月份筛选销课记录">
+                  <option value="all">全部月份</option>
+                  @for (month of historyMonthOptions(); track month.value) { <option [value]="month.value">{{ month.label }}({{ month.count }})</option> }
+                </select>
+              </div>
+            </label>
+            <label>
+              <span>学生姓名</span>
+              <div class="history-filter-control name-control">
+                <lucide-icon [img]="icons.Search" [size]="18" />
+                <input type="search" [ngModel]="historyStudentQuery()" (ngModelChange)="setHistoryStudentQuery($event)" placeholder="输入姓名查找" autocomplete="off" aria-label="按学生姓名筛选销课记录" />
+              </div>
+            </label>
+          </div>
+          <footer>
+            <span>当前显示 <strong>{{ visibleSchedules().length }}</strong> 节,共 {{ schedules().length }} 节</span>
+            @if (historyFilterActive()) { <button type="button" (click)="clearHistoryFilters()"><lucide-icon [img]="icons.X" [size]="15" />清除筛选</button> }
+          </footer>
+        </section>
+      }
       @if (hasScheduleCalendar) {
         <div class="schedule-tools">
           <div class="schedule-toolbar">
@@ -95,7 +122,7 @@
             }
           </div>
         }
-      } @else { <p class="band-empty">{{ loading() ? '正在读取排课数据…' : (hasScheduleCalendar ? scheduleEmptyText() : (schedules().length ? '该分类暂无伴学记录' : (isCoach ? '暂无销课记录' : '暂无伴学记录'))) }}</p> }
+      } @else { <p class="band-empty">{{ loading() ? '正在读取排课数据…' : (isHistory && schedules().length && historyFilterActive() ? '没有符合筛选条件的销课记录' : (hasScheduleCalendar ? scheduleEmptyText() : (schedules().length ? '该分类暂无伴学记录' : (isCoach ? '暂无销课记录' : '暂无伴学记录')))) }}</p> }
     </section>
 
     @if (error()) {

+ 17 - 0
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.scss

@@ -27,6 +27,22 @@ header span { color: var(--ink-soft); font-size: 12px; }
 .student-filter span { color: var(--ink-soft); font-size: 11px; line-height: 13px; }
 .student-filter::after { position: absolute; right: 18px; bottom: 20px; width: 9px; height: 9px; border: solid var(--ink); border-width: 0 2px 2px 0; border-radius: 1px; content: ''; pointer-events: none; transform: rotate(45deg); }
 .student-filter select { width: 100%; height: 48px; padding: 0 42px 0 11px; color: var(--ink); appearance: none; background: white; border: 1px solid var(--line); border-radius: 10px; font-size: 13px; outline: 0; }
+.history-filter-panel { padding: 14px; background: linear-gradient(180deg, color-mix(in srgb, var(--brand-pale) 42%, white), white); border-bottom: 1px solid var(--line); }
+.history-filter-grid { display: grid; grid-template-columns: minmax(140px, .85fr) minmax(170px, 1.15fr); gap: 10px; }
+.history-filter-grid label { display: flex; min-width: 0; flex-direction: column; gap: 5px; }
+.history-filter-grid label > span { color: var(--ink-soft); font-size: 11px; line-height: 14px; }
+.history-filter-control { position: relative; display: flex; min-width: 0; align-items: center; }
+.history-filter-control > lucide-icon { position: absolute; left: 12px; z-index: 1; color: var(--brand-strong); pointer-events: none; }
+.history-filter-control select, .history-filter-control input { width: 100%; height: 46px; padding: 0 12px 0 39px; color: var(--ink); appearance: none; background: white; border: 1px solid var(--line); border-radius: 11px; box-shadow: 0 3px 10px rgb(20 53 45 / 4%); font: inherit; font-size: 13px; outline: 0; }
+.history-filter-control select:focus, .history-filter-control input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand-pale) 76%, transparent); }
+.history-filter-control input::placeholder { color: color-mix(in srgb, var(--ink-soft) 72%, white); }
+.history-filter-control input::-webkit-search-cancel-button { display: none; }
+.month-control::after { position: absolute; right: 15px; width: 8px; height: 8px; border: solid var(--ink-soft); border-width: 0 2px 2px 0; border-radius: 1px; content: ''; pointer-events: none; transform: translateY(-2px) rotate(45deg); }
+.month-control select { padding-right: 34px; }
+.history-filter-panel footer { display: flex; min-height: 25px; align-items: end; justify-content: space-between; margin-top: 8px; color: var(--ink-soft); font-size: 11px; gap: 8px; }
+.history-filter-panel footer strong { color: var(--brand-strong); font-size: 13px; }
+.history-filter-panel footer button { display: inline-flex; min-height: 28px; align-items: center; padding: 4px 8px; color: var(--brand-strong); background: transparent; border: 0; border-radius: 7px; font-size: 11px; gap: 3px; }
+.history-filter-panel footer button:active { background: var(--brand-pale); }
 .schedule-filters { display: grid; grid-template-columns: repeat(3, 1fr); margin: 12px 14px 8px; padding: 3px; background: var(--surface-muted); border: 1px solid var(--line); border-radius: 8px; gap: 3px; }
 .schedule-tools .schedule-filters { margin: 10px 0 0; background: rgb(255 255 255 / 78%); }
 .schedule-filters button { min-height: 36px; padding: 6px 4px; color: var(--ink-soft); background: transparent; border: 0; border-radius: 6px; font-size: 12px; }
@@ -84,6 +100,7 @@ header span { color: var(--ink-soft); font-size: 12px; }
 @media (max-width: 360px) {
   .course-band, .schedule-band, .center-state.error { margin-inline: 10px; }
   .schedule-tools { padding-inline: 10px; }
+  .history-filter-grid { grid-template-columns: 1fr; }
   .calendar-panel { padding-inline: 8px; }
   .calendar-weekdays, .calendar-grid { gap: 4px; }
   .calendar-day { min-height: 39px; border-radius: 9px; }

+ 51 - 3
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.ts

@@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
-import { AlertCircle, BarChart3, BookOpen, CalendarDays, ChevronRight, GraduationCap, LoaderCircle, LucideAngularModule } from 'lucide-angular';
+import { AlertCircle, BarChart3, BookOpen, CalendarDays, ChevronRight, GraduationCap, LoaderCircle, LucideAngularModule, Search, X } from 'lucide-angular';
 import { finalize } from 'rxjs';
 import { ApiError, ApiService } from '../../core/api.service';
 import { canStartTeacherLedLearning } from '../../core/learning-access';
@@ -31,6 +31,12 @@ interface StudentOption {
   count: number;
 }
 
+interface MonthOption {
+  value: string;
+  label: string;
+  count: number;
+}
+
 interface CompanionOverview {
   courses: Item[];
   schedules: Item[];
@@ -58,14 +64,18 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   readonly scheduleRange = signal<ScheduleRange>('day');
   readonly selectedDate = signal(this.today());
   readonly selectedStudent = signal('all');
+  readonly historyMonth = signal('all');
+  readonly historyStudentQuery = signal('');
   readonly calendarMonth = signal(this.firstOfMonth(new Date()));
   readonly now = signal(Date.now());
   readonly loading = signal(false);
   readonly error = signal('');
   readonly studentOptions = computed(() => this.buildStudentOptions());
+  readonly historyMonthOptions = computed(() => this.buildHistoryMonthOptions());
+  readonly historyFilterActive = computed(() => this.historyMonth() !== 'all' || Boolean(this.historyStudentQuery().trim()));
   readonly scopedSchedules = computed(() => this.schedules().filter((item) => this.matchesStudent(item)));
   readonly filteredSchedulePool = computed(() => this.scopedSchedules().filter((item) => this.matchesStatus(item)));
-  readonly visibleSchedules = computed(() => this.filteredSchedulePool().filter((item) => this.matchesRange(item)));
+  readonly visibleSchedules = computed(() => this.filteredSchedulePool().filter((item) => this.matchesRange(item) && this.matchesHistoryFilters(item)));
   readonly pendingCount = computed(() => this.scopedSchedules().filter((item) => this.isOverdueUnfinished(item)).length);
   readonly finishedCount = computed(() => this.scopedSchedules().filter((item) => this.isFinished(item)).length);
   readonly calendarDays = computed(() => this.buildCalendarDays());
@@ -75,7 +85,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
     if (this.scheduleRange() === 'week') return `${this.selectedDate()} 所在周`;
     return this.selectedDate();
   });
-  readonly icons = { AlertCircle, BarChart3, BookOpen, CalendarDays, ChevronRight, GraduationCap, LoaderCircle };
+  readonly icons = { AlertCircle, BarChart3, BookOpen, CalendarDays, ChevronRight, GraduationCap, LoaderCircle, Search, X };
 
   private readonly api = inject(ApiService);
   private readonly router = inject(Router);
@@ -87,6 +97,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   private loadedRange: { start: string; end: string } | null = null;
   private loadedAll = false;
   private initialScheduleLoaded = false;
+  private historyFilterInitialized = false;
 
   ngOnInit(): void {
     this.restoredScheduleState = this.restoreScheduleState();
@@ -117,6 +128,10 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
       next: ({ result }) => {
         this.courses.set(Array.isArray(result?.courses) ? result.courses : []);
         this.schedules.set(Array.isArray(result?.schedules) ? result.schedules : []);
+        if (this.isHistory && !this.historyFilterInitialized) {
+          this.historyMonth.set(this.historyMonthOptions()[0]?.value ?? 'all');
+          this.historyFilterInitialized = true;
+        }
         this.loadedRange = range;
         this.loadedAll = !range;
         if (!this.initialScheduleLoaded && !this.isHistory && !this.restoredScheduleState) this.selectDate(today);
@@ -213,6 +228,15 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
     this.syncScheduleState();
   }
 
+  setHistoryMonth(value: string): void { this.historyMonth.set(value || 'all'); }
+
+  setHistoryStudentQuery(value: string): void { this.historyStudentQuery.set(value); }
+
+  clearHistoryFilters(): void {
+    this.historyMonth.set('all');
+    this.historyStudentQuery.set('');
+  }
+
   shiftCalendarMonth(amount: number): void {
     const month = this.calendarMonth();
     this.calendarMonth.set(new Date(month.getFullYear(), month.getMonth() + amount, 1, 12));
@@ -303,6 +327,22 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
     return [...options.values()].sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'));
   }
 
+  private buildHistoryMonthOptions(): MonthOption[] {
+    if (!this.isHistory) return [];
+    const counts = new Map<string, number>();
+    for (const item of this.schedules()) {
+      const month = this.itemDate(item).slice(0, 7);
+      if (!/^\d{4}-\d{2}$/.test(month)) continue;
+      counts.set(month, (counts.get(month) ?? 0) + 1);
+    }
+    return [...counts.entries()]
+      .sort(([left], [right]) => right.localeCompare(left))
+      .map(([value, count]) => {
+        const [year, month] = value.split('-').map(Number);
+        return { value, count, label: `${year}年${month}月` };
+      });
+  }
+
   private assignedUnits(item: Item): Array<{ id: string; name: string }> {
     const raw = item['syjd'] ?? item['assignedUnits'];
     let source: unknown = raw;
@@ -387,6 +427,14 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
     return date === this.selectedDate();
   }
 
+  private matchesHistoryFilters(item: Item): boolean {
+    if (!this.isHistory) return true;
+    const selectedMonth = this.historyMonth();
+    if (selectedMonth !== 'all' && !this.itemDate(item).startsWith(`${selectedMonth}-`)) return false;
+    const query = this.historyStudentQuery().trim().toLocaleLowerCase('zh-CN');
+    return !query || this.studentName(item).toLocaleLowerCase('zh-CN').includes(query);
+  }
+
   private sortedVisibleSchedules(): Item[] {
     const list = [...this.visibleSchedules()];
     if (this.isHistory) return list;