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

style(admin): emphasize schedule data loading

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

+ 13 - 2
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.html

@@ -1,7 +1,7 @@
 <header class="page-header">
   <div><p>SCHEDULING CENTER</p><h1>排课中心</h1><span>按陪练或时间快速排课,并在提交前发现冲突</span></div>
   <div class="header-actions">
-    <button type="button" (click)="load()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="loading()" />刷新</button>
+    <button type="button" (click)="load()" [disabled]="loading()"><lucide-icon [img]="loading()?icons.LoaderCircle:icons.RefreshCw" [size]="17" [class.spin]="loading()" />{{loading()?'正在加载…':'刷新'}}</button>
     <button class="primary" type="button" (click)="openCreate()"><lucide-icon [img]="icons.Plus" [size]="17" />新增排课</button>
   </div>
 </header>
@@ -19,6 +19,17 @@
   @if(data()?.dataDateTo){<button class="latest-data" type="button" (click)="showLatest()">最近有数据:{{data()?.dataDateTo}}</button>}
 </section>
 
+@if(loading()){
+  <section class="schedule-loading" role="status" aria-live="polite" aria-atomic="true" data-testid="schedule-loading">
+    <span class="loading-icon" aria-hidden="true"><lucide-icon [img]="icons.LoaderCircle" [size]="27" /></span>
+    <div>
+      <strong>{{data()?'正在更新排课数据':'正在加载排课数据'}}</strong>
+      <span>正在读取所选日期、陪练和预约状态,数据较多时请稍候。</span>
+    </div>
+    <span class="loading-track" aria-hidden="true"><i></i></span>
+  </section>
+}
+
 <section class="schedule-filterbar" aria-label="排课记录筛选">
   <label class="schedule-search"><span>搜索排课</span><span class="filter-control"><lucide-icon [img]="icons.Search" [size]="16"/><input type="search" [ngModel]="scheduleSearchInput()" (ngModelChange)="scheduleSearchInput.set($event)" (keydown.enter)="applyScheduleFilters()" placeholder="学员、陪练、课程或业务ID"/></span></label>
   <label class="status-filter"><span>业务状态</span><select [ngModel]="statusFilterInput()" (ngModelChange)="statusFilterInput.set($event)"><option value="all">全部状态</option><option value="upcoming">待开始</option><option value="live">上课中</option><option value="coach-ended">陪练已结束</option><option value="confirmed">学员已确认</option><option value="completed">已完课</option><option value="archived">已归档</option><option value="overdue">逾期未完成</option><option value="cancelled">已取消</option><option value="unassigned">待分配 / 账号异常</option></select></label>
@@ -54,7 +65,7 @@
   <article class="summary-card"><span>最后刷新</span><strong>{{data()?.refreshedAt|date:'HH:mm'}}</strong><small>{{ data()?.sync?.label || '实时排课接口读取' }}</small></article>
 </section>
 
-<section class="content-card">
+<section class="content-card" [class.is-loading]="loading()" [attr.aria-busy]="loading()">
   <header class="schedule-content-header">
     <div><strong>{{view()==='list'?'预约明细':'陪练排课泳道'}}</strong><span>{{loading()?'加载中…':'点击课程可改期或换老师'}}</span></div>
     <div class="status-legend" aria-label="排课状态图例">

+ 98 - 0
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.scss

@@ -96,6 +96,74 @@ textarea:focus-visible {
   white-space: nowrap;
 }
 
+.schedule-loading {
+  position: relative;
+  display: grid;
+  grid-template-columns: 48px minmax(0, 1fr);
+  align-items: center;
+  gap: 13px;
+  min-height: 68px;
+  margin: -4px 0 18px;
+  padding: 12px 18px 15px;
+  overflow: hidden;
+  border: 1px solid #adddcc;
+  border-radius: 16px;
+  color: #175c49;
+  background: linear-gradient(100deg, #eaf8f2 0%, #f7fcfa 58%, #eef9f5 100%);
+  box-shadow: 0 10px 24px rgba(23, 108, 85, .1);
+}
+
+.schedule-loading .loading-icon {
+  display: grid;
+  width: 44px;
+  height: 44px;
+  place-items: center;
+  border-radius: 14px;
+  color: #fff;
+  background: #1b7e62;
+  box-shadow: 0 7px 16px rgba(23, 108, 85, .22);
+}
+
+.schedule-loading .loading-icon lucide-icon {
+  animation: schedule-loading-spin .8s linear infinite;
+}
+
+.schedule-loading div {
+  display: grid;
+  gap: 4px;
+}
+
+.schedule-loading strong {
+  font-size: 14px;
+}
+
+.schedule-loading div span {
+  color: #5d786f;
+  font-size: 12px;
+}
+
+.loading-track {
+  position: absolute;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  height: 4px;
+  overflow: hidden;
+  background: rgba(27, 126, 98, .11);
+}
+
+.loading-track i {
+  display: block;
+  width: 36%;
+  height: 100%;
+  border-radius: 999px;
+  background: linear-gradient(90deg, transparent, #27a67f, transparent);
+  animation: schedule-loading-track 1.25s ease-in-out infinite;
+}
+
+@keyframes schedule-loading-spin { to { transform: rotate(360deg); } }
+@keyframes schedule-loading-track { from { transform: translateX(-105%); } to { transform: translateX(285%); } }
+
 .schedule-filterbar {
   display: flex;
   align-items: flex-end;
@@ -419,6 +487,11 @@ textarea:focus-visible {
   gap: 12px 20px;
 }
 
+.content-card.is-loading .table-scroll {
+  opacity: .46;
+  transition: opacity .18s ease;
+}
+
 .schedule-content-header > div:first-child {
   display: grid;
   gap: 4px;
@@ -999,6 +1072,26 @@ textarea:focus-visible {
     font-size: 12px;
   }
 
+  .schedule-loading {
+    grid-template-columns: 42px minmax(0, 1fr);
+    gap: 11px;
+    min-height: 62px;
+    margin-bottom: 14px;
+    padding: 11px 13px 14px;
+    border-radius: 15px;
+  }
+
+  .schedule-loading .loading-icon {
+    width: 40px;
+    height: 40px;
+    border-radius: 12px;
+  }
+
+  .schedule-loading div span {
+    font-size: 11px;
+    line-height: 1.4;
+  }
+
   .schedule-filterbar {
     display: grid;
     grid-template-columns: minmax(0, 1fr) minmax(132px, .62fr);
@@ -1166,3 +1259,8 @@ textarea:focus-visible {
 .search-control .selected-mark { right: 42px; }
 .search-control input { padding-right: 70px; }
 .xs-anchored-menu { border-radius: 12px; padding: 5px; box-shadow: 0 10px 28px rgb(20 53 45 / 18%); overscroll-behavior: contain; }
+
+@media (prefers-reduced-motion: reduce) {
+  .schedule-loading .loading-icon lucide-icon,
+  .loading-track i { animation-duration: 2.2s; }
+}

+ 35 - 1
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.spec.ts

@@ -1,5 +1,8 @@
-import { matchesScheduleSearch, scheduleCoachBusy, scheduleCoachesWithAppointments, scheduleCourseAvailable, schedulePreflightCandidates, scheduleRowActions } from './operations-schedule.component';
+import { TestBed } from '@angular/core/testing';
+import { ActivatedRoute, convertToParamMap } from '@angular/router';
+import { matchesScheduleSearch, OperationsScheduleComponent, scheduleCoachBusy, scheduleCoachesWithAppointments, scheduleCourseAvailable, schedulePreflightCandidates, scheduleRowActions } from './operations-schedule.component';
 import { ScheduleAppointment, ScheduleCalendarData } from '../admin.models';
+import { OperationsService } from '../operations.service';
 import { scheduleMatchesStatus, scheduleStatusLabel, scheduleStatusTone } from './operations-schedule-status';
 
 describe('排课搜索匹配', () => {
@@ -142,3 +145,34 @@ describe('批量排课逐项预检', () => {
     expect(results[1]).toEqual(jasmine.objectContaining({ index: 1, available: true, reasons: [] }));
   });
 });
+
+describe('排课数据加载提示', () => {
+  it('读取排课数据期间显示明显提示,完成后自动收起', async () => {
+    let finish!: (value: ScheduleCalendarData) => void;
+    const response = new Promise<ScheduleCalendarData>(resolve => { finish = resolve; });
+    await TestBed.configureTestingModule({
+      imports: [OperationsScheduleComponent],
+      providers: [
+        { provide: OperationsService, useValue: { schedule: jasmine.createSpy().and.returnValue(response) } },
+        { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({}) } } },
+      ],
+    }).compileComponents();
+
+    const fixture = TestBed.createComponent(OperationsScheduleComponent);
+    fixture.detectChanges();
+    const loading = fixture.nativeElement.querySelector('[data-testid="schedule-loading"]') as HTMLElement;
+    expect(loading?.textContent).toContain('正在加载排课数据');
+    expect(fixture.nativeElement.querySelector('.content-card')?.getAttribute('aria-busy')).toBe('true');
+
+    finish({
+      appointments: [], members: [], courses: [], coaches: [], totalAppointments: 0,
+      unassigned: [], dateFrom: '2026-09-10', dateTo: '2026-09-10',
+      dataDateFrom: '', dataDateTo: '', refreshedAt: '', liveSourceAvailable: true,
+      liveSourceMessage: '', liveSourceCount: 0,
+    });
+    await fixture.whenStable();
+    fixture.detectChanges();
+    expect(fixture.nativeElement.querySelector('[data-testid="schedule-loading"]')).toBeNull();
+    expect(fixture.nativeElement.querySelector('.content-card')?.getAttribute('aria-busy')).toBe('false');
+  });
+});