Explorar el Código

feat: 添加陪练排课日历筛选

彭峰 hace 2 meses
padre
commit
c694d789af

+ 61 - 3
src/app/features/course-center/course-center-page.component.html

@@ -18,8 +18,66 @@
     }
 
     <section class="schedule-band">
-      <header><lucide-icon [img]="isCoach ? icons.GraduationCap : icons.CalendarDays" [size]="21" /><h2>{{ isHistory ? '销课记录' : isCoach ? '陪练排课' : '伴学列表' }}</h2><span>{{ schedules().length }} 节</span></header>
-      @if (!isCoach && schedules().length) {
+      <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>
+      @if (hasScheduleCalendar && schedules().length) {
+        <div class="schedule-tools">
+          <div class="schedule-toolbar">
+            <nav class="range-tabs" aria-label="课表日期范围">
+              <button type="button" [class.active]="scheduleRange() === 'day' && selectedDate() === today()" (click)="showToday()">今天</button>
+              <button type="button" [class.active]="scheduleRange() === 'day' && selectedDate() === tomorrow()" (click)="showTomorrow()">明天</button>
+              <button type="button" [class.active]="scheduleRange() === 'week'" (click)="showWeek()">本周</button>
+              <button type="button" [class.active]="scheduleRange() === 'all'" (click)="showAllSchedules()">全部</button>
+            </nav>
+            @if (isCoach && studentOptions().length) {
+              <label class="student-filter">
+                <span>学员</span>
+                <select [value]="selectedStudent()" (change)="setStudentFilter($event)">
+                  <option value="all">全部学员({{ schedules().length }})</option>
+                  @for (student of studentOptions(); track student.id) {
+                    <option [value]="student.id">{{ student.name }}({{ student.count }})</option>
+                  }
+                </select>
+              </label>
+            }
+          </div>
+
+          <nav class="schedule-filters" [attr.aria-label]="isCoach ? '陪练排课状态筛选' : '伴学记录筛选'">
+            <button type="button" [class.active]="scheduleFilter() === 'all'" (click)="setScheduleFilter('all')">全部 <span>{{ scopedSchedules().length }}</span></button>
+            <button type="button" [class.active]="scheduleFilter() === 'pending'" (click)="setScheduleFilter('pending')">待完成 <span>{{ pendingCount() }}</span></button>
+            <button type="button" [class.active]="scheduleFilter() === 'finished'" (click)="setScheduleFilter('finished')">已结束 <span>{{ finishedCount() }}</span></button>
+          </nav>
+
+          <div class="calendar-panel">
+            <div class="calendar-heading">
+              <button type="button" (click)="shiftCalendarMonth(-1)" aria-label="上个月">‹</button>
+              <strong>{{ calendarLabel() }}</strong>
+              <button type="button" (click)="shiftCalendarMonth(1)" aria-label="下个月">›</button>
+            </div>
+            <div class="calendar-weekdays" aria-hidden="true">
+              @for (week of weekLabels; track week) { <span>{{ week }}</span> }
+            </div>
+            <div class="calendar-grid">
+              @for (day of calendarDays(); track day.date) {
+                <button
+                  type="button"
+                  class="calendar-day"
+                  [class.outside]="!day.inMonth"
+                  [class.today]="day.isToday"
+                  [class.selected]="scheduleRange() === 'day' && selectedDate() === day.date"
+                  [class.has-course]="day.count > 0"
+                  (click)="selectCalendarDay(day)"
+                  [attr.aria-label]="day.date + ',' + day.count + ' 节课'"
+                >
+                  <span>{{ day.day }}</span>
+                  @if (day.count > 0) { <em>{{ day.count }}</em> }
+                </button>
+              }
+            </div>
+          </div>
+
+          <p class="schedule-summary">{{ scheduleRangeLabel() }} · {{ visibleSchedules().length }} 节课</p>
+        </div>
+      } @else if (!isCoach && schedules().length) {
         <nav class="schedule-filters" aria-label="伴学记录筛选">
           <button type="button" [class.active]="scheduleFilter() === 'all'" (click)="setScheduleFilter('all')">全部 <span>{{ schedules().length }}</span></button>
           <button type="button" [class.active]="scheduleFilter() === 'pending'" (click)="setScheduleFilter('pending')">待完成 <span>{{ pendingCount() }}</span></button>
@@ -41,7 +99,7 @@
             }
           </div>
         }
-      } @else { <p class="band-empty">{{ schedules().length ? '该分类暂无伴学记录' : (isCoach ? '今天暂无排课' : '暂无伴学记录') }}</p> }
+      } @else { <p class="band-empty">{{ hasScheduleCalendar ? scheduleEmptyText() : (schedules().length ? '该分类暂无伴学记录' : (isCoach ? '暂无销课记录' : '暂无伴学记录')) }}</p> }
     </section>
 
     @if (error()) {

+ 31 - 0
src/app/features/course-center/course-center-page.component.scss

@@ -21,10 +21,36 @@ header span { color: var(--ink-soft); font-size: 12px; }
 .course-list small { color: var(--ink-soft); font-size: 11px; }
 .course-list button > lucide-icon { color: var(--ink-soft); }
 .course-list button:disabled { cursor: not-allowed; opacity: .58; }
+.schedule-tools { padding: 12px 14px 14px; background: linear-gradient(180deg, color-mix(in srgb, var(--brand-soft) 42%, white), white 72%); border-bottom: 1px solid var(--line); }
+.schedule-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) minmax(150px, 190px); align-items: center; gap: 10px; }
+.range-tabs { display: grid; grid-template-columns: repeat(4, 1fr); padding: 3px; background: rgb(255 255 255 / 82%); border: 1px solid var(--line); border-radius: 10px; gap: 3px; }
+.range-tabs button { min-height: 36px; padding: 6px 4px; color: var(--ink-soft); background: transparent; border: 0; border-radius: 8px; font-size: 12px; }
+.range-tabs button.active { color: white; background: var(--brand-strong); box-shadow: 0 4px 12px rgb(20 53 45 / 16%); font-weight: 700; }
+.student-filter { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
+.student-filter span { color: var(--ink-soft); font-size: 11px; }
+.student-filter select { width: 100%; height: 40px; padding: 0 29px 0 11px; color: var(--ink); background: white; border: 1px solid var(--line); border-radius: 10px; font-size: 13px; outline: 0; }
+.student-filter select:focus { border-color: var(--brand); box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 18%, transparent); }
 .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; }
 .schedule-filters button span { margin-left: 2px; color: inherit; font-size: 10px; }
 .schedule-filters button.active { color: var(--brand-strong); background: white; box-shadow: 0 1px 4px rgb(20 53 45 / 10%); font-weight: 650; }
+.calendar-panel { margin-top: 12px; padding: 11px; background: white; border: 1px solid var(--line); border-radius: 14px; box-shadow: 0 8px 18px rgb(20 53 45 / 6%); }
+.calendar-heading { display: grid; grid-template-columns: 38px minmax(0, 1fr) 38px; align-items: center; margin-bottom: 8px; text-align: center; }
+.calendar-heading strong { color: var(--ink); font-size: 15px; }
+.calendar-heading button { display: grid; width: 34px; height: 34px; padding: 0; color: var(--brand-strong); background: var(--brand-soft); border: 0; border-radius: 50%; font-size: 24px; line-height: 1; place-items: center; }
+.calendar-heading button:active, .calendar-day:active, .range-tabs button:active, .schedule-filters button:active { transform: translateY(1px); }
+.calendar-weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 5px; }
+.calendar-weekdays { margin-bottom: 6px; color: var(--ink-soft); font-size: 11px; text-align: center; }
+.calendar-day { position: relative; display: grid; min-width: 0; min-height: 42px; padding: 5px 2px 4px; color: var(--ink); background: var(--surface-muted); border: 1px solid transparent; border-radius: 11px; font-size: 13px; place-items: center; }
+.calendar-day span { line-height: 1; }
+.calendar-day em { display: grid; min-width: 17px; height: 17px; margin-top: 3px; padding: 0 4px; color: white; background: var(--brand-strong); border-radius: 999px; font-size: 10px; font-style: normal; line-height: 17px; }
+.calendar-day.outside { color: color-mix(in srgb, var(--ink-soft) 55%, white); background: rgb(247 249 246 / 72%); }
+.calendar-day.today { border-color: var(--brand); }
+.calendar-day.selected { color: white; background: var(--brand-strong); box-shadow: 0 5px 14px rgb(20 53 45 / 18%); }
+.calendar-day.selected em { color: var(--brand-strong); background: white; }
+.calendar-day.has-course:not(.selected) { background: color-mix(in srgb, var(--brand-soft) 55%, white); }
+.schedule-summary { margin: 10px 2px 0; color: var(--ink-soft); font-size: 12px; text-align: center; }
 .schedule-band h3 { margin: 0; padding: 10px 16px 7px; color: var(--ink-soft); background: var(--surface-muted); font-size: 12px; letter-spacing: 0; }
 .schedule-list article { display: grid; min-height: 76px; grid-template-columns: 64px minmax(0, 1fr) auto auto auto; align-items: center; padding: 10px 12px 10px 16px; border-bottom: 1px solid var(--line); cursor: pointer; gap: 9px; }
 .schedule-list article:last-child { border-bottom: 0; }
@@ -45,6 +71,7 @@ header span { color: var(--ink-soft); font-size: 12px; }
 .spin { animation: spin .8s linear infinite; }
 @keyframes spin { to { transform: rotate(360deg); } }
 @media (max-width: 420px) {
+  .schedule-toolbar { grid-template-columns: 1fr; }
   .schedule-list article { grid-template-columns: 54px minmax(0, 1fr) auto auto auto; gap: 7px; }
   .schedule-list article:not(:has(.report-button)) { grid-template-columns: 54px minmax(0, 1fr) auto auto; }
   .status { width: fit-content; }
@@ -53,5 +80,9 @@ header span { color: var(--ink-soft); font-size: 12px; }
   .library-entry { width: calc(100% - 20px); margin-inline: 10px; padding-inline: 13px; }
   .library-entry small { white-space: normal; }
   .course-band, .schedule-band, .center-state.error { margin-inline: 10px; }
+  .schedule-tools { padding-inline: 10px; }
+  .calendar-panel { padding-inline: 8px; }
+  .calendar-weekdays, .calendar-grid { gap: 4px; }
+  .calendar-day { min-height: 39px; border-radius: 9px; }
 }
 @media (min-width: 760px) { .center-page { max-width: 760px; margin: 0 auto; border-inline: 1px solid var(--line); } }

+ 204 - 16
src/app/features/course-center/course-center-page.component.ts

@@ -10,6 +10,21 @@ import { SessionService } from '../../core/session.service';
 
 type Item = Record<string, unknown>;
 type ScheduleFilter = 'all' | 'pending' | 'finished';
+type ScheduleRange = 'day' | 'week' | 'all';
+
+interface CalendarDay {
+  date: string;
+  day: number;
+  inMonth: boolean;
+  isToday: boolean;
+  count: number;
+}
+
+interface StudentOption {
+  id: string;
+  name: string;
+  count: number;
+}
 
 @Component({
   selector: 'app-course-center-page',
@@ -21,21 +36,33 @@ type ScheduleFilter = 'all' | 'pending' | 'finished';
 })
 export class CourseCenterPageComponent implements OnInit {
   readonly meta = inject(ActivatedRoute).snapshot.data['meta'] as PageMeta;
+  readonly isCoach = ['pages/home/shop', 'pages/member/ke_xiao'].includes(this.meta.path);
+  readonly isHistory = this.meta.path === 'pages/member/ke_xiao';
+  readonly hasScheduleCalendar = !this.isHistory;
+  readonly weekLabels = ['一', '二', '三', '四', '五', '六', '日'];
   readonly courses = signal<Item[]>([]);
   readonly schedules = signal<Item[]>([]);
   readonly scheduleFilter = signal<ScheduleFilter>('all');
+  readonly scheduleRange = signal<ScheduleRange>('day');
+  readonly selectedDate = signal(this.today());
+  readonly selectedStudent = signal('all');
+  readonly calendarMonth = signal(this.firstOfMonth(new Date()));
   readonly loading = signal(false);
   readonly error = signal('');
-  readonly visibleSchedules = computed(() => this.schedules().filter((item) => {
-    if (this.scheduleFilter() === 'finished') return this.isFinished(item);
-    if (this.scheduleFilter() === 'pending') return !this.isFinished(item);
-    return true;
-  }));
-  readonly pendingCount = computed(() => this.schedules().filter((item) => !this.isFinished(item)).length);
-  readonly finishedCount = computed(() => this.schedules().filter((item) => this.isFinished(item)).length);
+  readonly studentOptions = computed(() => this.buildStudentOptions());
+  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 pendingCount = computed(() => this.scopedSchedules().filter((item) => !this.isFinished(item)).length);
+  readonly finishedCount = computed(() => this.scopedSchedules().filter((item) => this.isFinished(item)).length);
+  readonly calendarDays = computed(() => this.buildCalendarDays());
+  readonly calendarLabel = computed(() => new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long' }).format(this.calendarMonth()));
+  readonly scheduleRangeLabel = computed(() => {
+    if (this.scheduleRange() === 'all') return '全部日期';
+    if (this.scheduleRange() === 'week') return `${this.selectedDate()} 所在周`;
+    return this.selectedDate();
+  });
   readonly icons = { AlertCircle, BarChart3, BookOpen, CalendarDays, ChevronRight, GraduationCap, LoaderCircle };
-  readonly isCoach = ['pages/home/shop', 'pages/member/ke_xiao'].includes(this.meta.path);
-  readonly isHistory = this.meta.path === 'pages/member/ke_xiao';
 
   private readonly api = inject(ApiService);
   private readonly router = inject(Router);
@@ -51,13 +78,16 @@ export class CourseCenterPageComponent implements OnInit {
     this.error.set('');
     const today = this.today();
     if (this.isCoach) {
-      const params: Record<string, unknown> = { uid, nid: 29, myfield2: `pl=${uid}`, psize: this.isHistory ? 13 : 200 };
+      const params: Record<string, unknown> = { uid, nid: 29, myfield2: `pl=${uid}`, psize: this.isHistory ? 13 : 500 };
       if (this.isHistory) Object.assign(params, { orders: 'B.yysj=DESC', dszt: 30, xiaokejilu: 1 });
-      else params['dateFilter'] = today;
+      else params['orders'] = 'B.yysj=ASC';
       this.api.get<unknown>('content_list', params)
         .pipe(finalize(() => this.loading.set(false)))
         .subscribe({
-          next: ({ result }) => this.schedules.set(this.asList(result)),
+          next: ({ result }) => {
+            this.schedules.set(this.asList(result));
+            if (!this.isHistory) this.selectDate(today);
+          },
           error: (error: ApiError) => this.error.set(error.message),
         });
       return;
@@ -71,6 +101,7 @@ export class CourseCenterPageComponent implements OnInit {
     }).pipe(finalize(() => this.loading.set(false))).subscribe(({ courses, schedules }) => {
       this.courses.set(this.asList(courses.result));
       this.schedules.set(this.asList(schedules.result));
+      this.selectDate(today);
       const failures = [courses.retmsg, schedules.retmsg].filter(Boolean);
       if (failures.length) this.error.set(`部分数据加载失败:${failures.join(';')}`);
     });
@@ -78,8 +109,8 @@ export class CourseCenterPageComponent implements OnInit {
 
   groups(): Array<{ date: string; items: Item[] }> {
     const grouped = new Map<string, Item[]>();
-    for (const item of this.visibleSchedules()) {
-      const date = String(item['bxrq'] ?? '未指定日期').slice(0, 10);
+    for (const item of this.sortedVisibleSchedules()) {
+      const date = this.itemDate(item) || '未指定日期';
       grouped.set(date, [...(grouped.get(date) ?? []), item]);
     }
     return Array.from(grouped, ([date, items]) => ({ date, items }));
@@ -125,6 +156,37 @@ export class CourseCenterPageComponent implements OnInit {
 
   setScheduleFilter(filter: ScheduleFilter): void { this.scheduleFilter.set(filter); }
 
+  setStudentFilter(event: Event): void {
+    const value = event.target instanceof HTMLSelectElement ? event.target.value : 'all';
+    this.selectedStudent.set(value || 'all');
+  }
+
+  shiftCalendarMonth(amount: number): void {
+    const month = this.calendarMonth();
+    this.calendarMonth.set(new Date(month.getFullYear(), month.getMonth() + amount, 1, 12));
+  }
+
+  selectCalendarDay(day: CalendarDay): void { this.selectDate(day.date); }
+
+  showToday(): void { this.selectDate(this.today()); }
+
+  showTomorrow(): void { this.selectDate(this.tomorrow()); }
+
+  showWeek(): void {
+    this.scheduleRange.set('week');
+    this.ensureSelectedDate();
+  }
+
+  showAllSchedules(): void { this.scheduleRange.set('all'); }
+
+  scheduleEmptyText(): string {
+    const target = this.isCoach ? '排课' : '伴学记录';
+    if (!this.schedules().length) return this.isCoach ? '暂无排课' : '暂无伴学记录';
+    if (this.scheduleRange() === 'all') return `当前筛选下暂无${target}`;
+    if (this.scheduleRange() === 'week') return `本周暂无${target}`;
+    return `${this.selectedDate()} 暂无${target}`;
+  }
+
   open(item: Item): void {
     if (!this.isCoach) {
       void this.router.navigate(['/pages/shop/stu_get_order'], { queryParams: { id: this.scheduleId(item) } });
@@ -162,6 +224,110 @@ export class CourseCenterPageComponent implements OnInit {
     });
   }
 
+  private buildStudentOptions(): StudentOption[] {
+    if (!this.isCoach || this.isHistory) return [];
+    const options = new Map<string, StudentOption>();
+    for (const item of this.schedules()) {
+      const id = this.studentId(item);
+      const name = this.studentName(item);
+      const current = options.get(id);
+      if (current) current.count += 1;
+      else options.set(id, { id, name, count: 1 });
+    }
+    return [...options.values()].sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'));
+  }
+
+  private buildCalendarDays(): CalendarDay[] {
+    const month = this.calendarMonth();
+    const year = month.getFullYear();
+    const monthIndex = month.getMonth();
+    const firstDay = new Date(year, monthIndex, 1, 12);
+    const offset = (firstDay.getDay() + 6) % 7;
+    const start = new Date(year, monthIndex, 1 - offset, 12);
+    const today = this.today();
+    const counts = new Map<string, number>();
+    for (const item of this.filteredSchedulePool()) {
+      const date = this.itemDate(item);
+      if (!date) continue;
+      counts.set(date, (counts.get(date) ?? 0) + 1);
+    }
+    return Array.from({ length: 42 }, (_, index) => {
+      const date = new Date(start);
+      date.setDate(start.getDate() + index);
+      const key = this.dateKey(date);
+      const count = counts.get(key);
+      return {
+        date: key,
+        day: date.getDate(),
+        inMonth: date.getMonth() === monthIndex,
+        isToday: key === today,
+        count: count ?? 0,
+      };
+    });
+  }
+
+  private matchesStudent(item: Item): boolean {
+    const selected = this.selectedStudent();
+    return selected === 'all' || this.studentId(item) === selected;
+  }
+
+  private matchesStatus(item: Item): boolean {
+    if (this.scheduleFilter() === 'finished') return this.isFinished(item);
+    if (this.scheduleFilter() === 'pending') return !this.isFinished(item);
+    return true;
+  }
+
+  private matchesRange(item: Item): boolean {
+    if (!this.hasScheduleCalendar || this.scheduleRange() === 'all') return true;
+    const date = this.itemDate(item);
+    if (!date) return false;
+    if (this.scheduleRange() === 'week') {
+      const [start, end] = this.weekRange(this.selectedDate());
+      return date >= start && date <= end;
+    }
+    return date === this.selectedDate();
+  }
+
+  private sortedVisibleSchedules(): Item[] {
+    const list = [...this.visibleSchedules()];
+    if (this.isHistory) return list;
+    return list.sort((left, right) => `${this.itemDate(left)} ${this.scheduleTime(left)}`.localeCompare(`${this.itemDate(right)} ${this.scheduleTime(right)}`));
+  }
+
+  private selectDate(date: string): void {
+    this.selectedDate.set(date);
+    this.scheduleRange.set('day');
+    const value = this.parseDate(date);
+    this.calendarMonth.set(new Date(value.getFullYear(), value.getMonth(), 1, 12));
+  }
+
+  private ensureSelectedDate(): void {
+    if (!this.selectedDate()) this.selectedDate.set(this.today());
+  }
+
+  private itemDate(item: Item): string {
+    return this.dateKey(item['bxrq'] ?? item['yydate'] ?? item['yysj'] ?? item['PubAddTime']);
+  }
+
+  private studentId(item: Item): string {
+    return String(item['szyh'] ?? item['UserID'] ?? item['userId'] ?? this.studentName(item));
+  }
+
+  private studentName(item: Item): string {
+    const explicit = item['honeyname'] ?? item['HoneyName'] ?? item['honeyName'] ?? item['xymz_t'] ?? item['studentName'];
+    if (explicit) return String(explicit);
+    const title = this.scheduleTitle(item);
+    return title.includes('(') ? title.slice(0, title.indexOf('(')) : title || '未命名学员';
+  }
+
+  private weekRange(date: string): [string, string] {
+    const start = this.parseDate(date);
+    start.setDate(start.getDate() - ((start.getDay() + 6) % 7));
+    const end = new Date(start);
+    end.setDate(start.getDate() + 6);
+    return [this.dateKey(start), this.dateKey(end)];
+  }
+
   private asList(value: unknown): Item[] {
     if (Array.isArray(value)) return value.filter((item): item is Item => Boolean(item) && typeof item === 'object');
     if (value && typeof value === 'object') {
@@ -170,8 +336,30 @@ export class CourseCenterPageComponent implements OnInit {
     return [];
   }
 
-  private today(): string {
+  today(): string {
     const now = new Date();
-    return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
+    return this.dateKey(now);
+  }
+
+  tomorrow(): string {
+    const date = this.parseDate(this.today());
+    date.setDate(date.getDate() + 1);
+    return this.dateKey(date);
+  }
+
+  private firstOfMonth(date: Date): Date { return new Date(date.getFullYear(), date.getMonth(), 1, 12); }
+
+  private parseDate(value: string): Date {
+    const [year, month, day] = value.split('-').map(Number);
+    return new Date(year || new Date().getFullYear(), (month || 1) - 1, day || 1, 12);
+  }
+
+  private dateKey(value: unknown): string {
+    const raw = String(value ?? '');
+    const matched = raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
+    if (matched) return `${matched[1]}-${matched[2].padStart(2, '0')}-${matched[3].padStart(2, '0')}`;
+    const date = value instanceof Date ? value : new Date(raw);
+    if (Number.isNaN(date.getTime())) return '';
+    return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
   }
 }