Răsfoiți Sursa

feat(admin): color-code schedule statuses

彭峰 2 luni în urmă
părinte
comite
b255df3b25

+ 14 - 4
src/app/admin/pages/operations-schedule.component.html

@@ -31,17 +31,27 @@
 </section>
 
 <section class="content-card">
-  <header><strong>{{view()==='list'?'预约明细':'陪练排课泳道'}}</strong><span>{{loading()?'加载中…':'点击课程可改期或换老师'}}</span></header>
+  <header class="schedule-content-header">
+    <div><strong>{{view()==='list'?'预约明细':'陪练排课泳道'}}</strong><span>{{loading()?'加载中…':'点击课程可改期或换老师'}}</span></div>
+    <div class="status-legend" aria-label="排课状态图例">
+      <span class="status-upcoming"><i></i>待开始</span>
+      <span class="status-live"><i></i>上课中</span>
+      <span class="status-completed"><i></i>已完成</span>
+      <span class="status-archived"><i></i>已归档</span>
+      <span class="status-overdue"><i></i>逾期未上课</span>
+      <span class="status-cancelled"><i></i>已取消</span>
+    </div>
+  </header>
   @if(view()==='list'){
     <div class="table-scroll"><table class="data-table"><thead><tr><th>时间</th><th>会员</th><th>陪练</th><th>课程</th><th>课型</th><th>状态</th><th>操作</th></tr></thead><tbody>
-      @for(item of data()?.appointments||[];track item.targetId){<tr><td>{{item.startsAt|date:'MM-dd HH:mm'}}–{{item.endsAt|date:'HH:mm'}}</td><td>{{item.studentName}}</td><td>{{item.coachName}}@if(item.coachAccountMissing){<small class="exception">历史陪练账号已失效</small>}</td><td>{{item.courseName}}</td><td>{{item.classTypeLabel}}</td><td><span class="badge" [class.muted]="item.cancelled">{{item.statusLabel}}</span></td><td><div class="row-actions"><button type="button" (click)="openEdit(item)" [disabled]="item.statusCode>0||item.cancelled">修改</button>@if(item.cancelled){<button type="button" (click)="action(item,'ops/appointments/restore')">恢复</button>}@else{<button type="button" (click)="action(item,'ops/appointments/cancel')">取消</button><button type="button" (click)="action(item,'ops/appointments/complete')">完成</button>}</div></td></tr>}
+      @for(item of data()?.appointments||[];track item.targetId){<tr><td>{{item.startsAt|date:'MM-dd HH:mm'}}–{{item.endsAt|date:'HH:mm'}}</td><td>{{item.studentName}}</td><td>{{item.coachName}}@if(item.coachAccountMissing){<small class="exception">历史陪练账号已失效</small>}</td><td>{{item.courseName}}</td><td>{{item.classTypeLabel}}</td><td><span class="badge status-badge" [ngClass]="statusTone(item)">{{statusDisplayLabel(item)}}</span></td><td><div class="row-actions"><button type="button" (click)="openEdit(item)" [disabled]="item.statusCode>0||item.cancelled">修改</button>@if(item.cancelled){<button type="button" (click)="action(item,'ops/appointments/restore')">恢复</button>}@else{<button type="button" (click)="action(item,'ops/appointments/cancel')">取消</button><button type="button" (click)="action(item,'ops/appointments/complete')">完成</button>}</div></td></tr>}
       @empty{<tr><td colspan="7"><div class="empty">当前范围暂无排课;历史数据范围为 {{data()?.dataDateFrom||'—'}} 至 {{data()?.dataDateTo||'—'}}</div></td></tr>}
     </tbody></table></div>
   } @else {
     <div class="table-scroll"><div class="calendar">
-      @for(coach of data()?.coaches||[];track coach.userId){<div class="calendar-row"><div class="coach-label"><strong>{{coach.displayName}}</strong><span>今日 {{coach.todayAppointments}} 节</span>@if(!coach.availabilityConfigured){<span class="exception">未设置可用时间</span>}</div><div class="appointment-strip">@for(item of data()?.appointments||[];track item.targetId){@if(item.coachId===coach.userId){<button type="button" class="appointment" [class.cancelled]="item.cancelled" (click)="openEdit(item)"><strong>{{item.startsAt|date:'MM-dd HH:mm'}} {{item.studentName}}</strong><span>{{item.courseName}}</span><small>{{item.classTypeLabel}} · {{item.statusLabel}}</small></button>}}</div></div>}
+      @for(coach of data()?.coaches||[];track coach.userId){<div class="calendar-row"><div class="coach-label"><strong>{{coach.displayName}}</strong><span>今日 {{coach.todayAppointments}} 节</span>@if(!coach.availabilityConfigured){<span class="exception">未设置可用时间</span>}</div><div class="appointment-strip">@for(item of data()?.appointments||[];track item.targetId){@if(item.coachId===coach.userId){<button type="button" class="appointment" [ngClass]="statusTone(item)" (click)="openEdit(item)"><strong>{{item.startsAt|date:'MM-dd HH:mm'}} {{item.studentName}}</strong><span>{{item.courseName}}</span><small>{{item.classTypeLabel}} · {{statusDisplayLabel(item)}}</small></button>}}</div></div>}
       @empty{<div class="empty">暂无陪练资料</div>}
-      @if(unassignedCount()){<div class="calendar-row"><div class="coach-label"><strong>待分配 / 历史账号</strong><span>{{unassignedCount()}} 节</span></div><div class="appointment-strip">@for(item of data()?.unassigned||[];track item.targetId){<button type="button" class="appointment" [class.cancelled]="item.cancelled" (click)="openEdit(item)"><strong>{{item.startsAt|date:'MM-dd HH:mm'}} {{item.studentName}}</strong><span>{{item.coachAccountMissing?item.coachName:'未分配陪练'}}</span><small>{{item.classTypeLabel}} · {{item.statusLabel}}</small></button>}</div></div>}
+      @if(unassignedCount()){<div class="calendar-row"><div class="coach-label"><strong>待分配 / 历史账号</strong><span>{{unassignedCount()}} 节</span></div><div class="appointment-strip">@for(item of data()?.unassigned||[];track item.targetId){<button type="button" class="appointment" [ngClass]="statusTone(item)" (click)="openEdit(item)"><strong>{{item.startsAt|date:'MM-dd HH:mm'}} {{item.studentName}}</strong><span>{{item.coachAccountMissing?item.coachName:'未分配陪练'}}</span><small>{{item.classTypeLabel}} · {{statusDisplayLabel(item)}}</small></button>}</div></div>}
     </div></div>
   }
 </section>

+ 107 - 0
src/app/admin/pages/operations-schedule.component.scss

@@ -164,6 +164,113 @@ textarea:focus-visible {
   text-align: left;
 }
 
+.schedule-content-header {
+  flex-wrap: wrap;
+  gap: 12px 20px;
+}
+
+.schedule-content-header > div:first-child {
+  display: grid;
+  gap: 4px;
+}
+
+.schedule-content-header > div:first-child span {
+  color: var(--schedule-text-muted);
+  font-size: 12px;
+}
+
+.status-legend {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+  gap: 7px 12px;
+}
+
+.status-legend > span {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  color: #5f6f69;
+  font-size: 11px;
+  font-weight: 700;
+  white-space: nowrap;
+}
+
+.status-legend i {
+  width: 9px;
+  height: 9px;
+  border-radius: 999px;
+  background: var(--status-color);
+  box-shadow: 0 0 0 3px var(--status-bg);
+}
+
+.status-upcoming {
+  --status-color: #2563eb;
+  --status-bg: #eff6ff;
+  --status-border: #bfdbfe;
+}
+
+.status-live {
+  --status-color: #15803d;
+  --status-bg: #ecfdf3;
+  --status-border: #bbf7d0;
+}
+
+.status-completed {
+  --status-color: #7c3aed;
+  --status-bg: #f5f3ff;
+  --status-border: #ddd6fe;
+}
+
+.status-archived {
+  --status-color: #64748b;
+  --status-bg: #f8fafc;
+  --status-border: #dbe3ec;
+}
+
+.status-overdue {
+  --status-color: #dc2626;
+  --status-bg: #fff1f2;
+  --status-border: #fecdd3;
+}
+
+.status-cancelled {
+  --status-color: #c2410c;
+  --status-bg: #fff7ed;
+  --status-border: #fed7aa;
+}
+
+.status-neutral {
+  --status-color: #64748b;
+  --status-bg: #f8fafc;
+  --status-border: #e2e8f0;
+}
+
+.appointment.status-upcoming,
+.appointment.status-live,
+.appointment.status-completed,
+.appointment.status-archived,
+.appointment.status-overdue,
+.appointment.status-cancelled,
+.appointment.status-neutral {
+  border-color: var(--status-border);
+  border-left-color: var(--status-color);
+  background: var(--status-bg);
+}
+
+.appointment.status-overdue strong,
+.appointment.status-overdue small,
+.appointment.status-cancelled strong,
+.appointment.status-cancelled small {
+  color: var(--status-color);
+}
+
+.status-badge {
+  border: 1px solid var(--status-border);
+  background: var(--status-bg);
+  color: var(--status-color);
+}
+
 .drawer {
   width: min(720px, 100%);
   padding: 28px 30px;

+ 19 - 1
src/app/admin/pages/operations-schedule.component.spec.ts

@@ -1,4 +1,4 @@
-import { matchesScheduleSearch } from './operations-schedule.component';
+import { matchesScheduleSearch, scheduleStatusLabel, scheduleStatusTone } from './operations-schedule.component';
 
 describe('排课搜索匹配', () => {
   it('支持学员姓名和课程名称的模糊匹配', () => {
@@ -16,3 +16,21 @@ describe('排课搜索匹配', () => {
     expect(matchesScheduleSearch('1009', ['自然拼读', 1008])).toBeFalse();
   });
 });
+
+describe('排课状态视觉分类', () => {
+  const now = Date.parse('2026-08-20T10:00:00+08:00');
+
+  it('将已经结束但仍待开始的预约识别为逾期未上课', () => {
+    const item = { cancelled: false, statusCode: 0, statusLabel: '待开始', endsAt: '2026-08-20T01:00:00.000Z' };
+    expect(scheduleStatusTone(item, now)).toBe('status-overdue');
+    expect(scheduleStatusLabel(item, now)).toBe('逾期未上课');
+  });
+
+  it('区分待开始、上课中、已完成、已归档和已取消', () => {
+    expect(scheduleStatusTone({ cancelled: false, statusCode: 0, endsAt: '2026-08-21T01:00:00.000Z' }, now)).toBe('status-upcoming');
+    expect(scheduleStatusTone({ cancelled: false, statusCode: 10, endsAt: '' }, now)).toBe('status-live');
+    expect(scheduleStatusTone({ cancelled: false, statusCode: 11, endsAt: '' }, now)).toBe('status-completed');
+    expect(scheduleStatusTone({ cancelled: false, statusCode: 30, endsAt: '' }, now)).toBe('status-archived');
+    expect(scheduleStatusTone({ cancelled: true, statusCode: 0, endsAt: '' }, now)).toBe('status-cancelled');
+  });
+});

+ 18 - 0
src/app/admin/pages/operations-schedule.component.ts

@@ -8,6 +8,22 @@ import { OperationsService } from '../operations.service';
 const localDateKey=(value:Date):string=>`${value.getFullYear()}-${String(value.getMonth()+1).padStart(2,'0')}-${String(value.getDate()).padStart(2,'0')}`;
 
 type SearchField='member'|'course'|'coach';
+export type ScheduleStatusTone='status-upcoming'|'status-live'|'status-completed'|'status-archived'|'status-overdue'|'status-cancelled'|'status-neutral';
+
+export function scheduleStatusTone(item:Pick<ScheduleAppointment,'cancelled'|'statusCode'|'endsAt'>,now=Date.now()):ScheduleStatusTone{
+  if(item.cancelled)return 'status-cancelled';
+  const endTime=Date.parse(item.endsAt);
+  if(item.statusCode===0&&Number.isFinite(endTime)&&endTime<now)return 'status-overdue';
+  if(item.statusCode===0)return 'status-upcoming';
+  if(item.statusCode===10)return 'status-live';
+  if(item.statusCode===11||item.statusCode===20)return 'status-completed';
+  if(item.statusCode>=30)return 'status-archived';
+  return 'status-neutral';
+}
+
+export function scheduleStatusLabel(item:Pick<ScheduleAppointment,'cancelled'|'statusCode'|'statusLabel'|'endsAt'>,now=Date.now()):string{
+  return scheduleStatusTone(item,now)==='status-overdue'?'逾期未上课':item.statusLabel;
+}
 
 export function matchesScheduleSearch(query:string,values:Array<string|number|undefined|null>):boolean{
   const normalizedQuery=query.trim().toLocaleLowerCase();
@@ -35,6 +51,8 @@ export class OperationsScheduleComponent implements OnInit {
   readonly filteredCoaches=computed(()=>(this.data()?.coaches||[]).filter(coach=>matchesScheduleSearch(this.coachSearch(),[coach.displayName,coach.mobile,coach.mobileMasked,coach.userId])).slice(0,12));
   private initialRange=true;
   previewConflictCount():number{return Number(this.preview()?.['conflictCount']||0);}
+  statusTone(item:ScheduleAppointment):ScheduleStatusTone{return scheduleStatusTone(item);}
+  statusDisplayLabel(item:ScheduleAppointment):string{return scheduleStatusLabel(item);}
   ngOnInit():void{void this.load();}
   range():{from:string;to:string}{const from=new Date(`${this.date()}T00:00:00`);const to=new Date(from);if(this.view()==='week')to.setDate(to.getDate()+6);return{from:localDateKey(from),to:localDateKey(to)};}
   async load():Promise<void>{this.loading.set(true);this.error.set('');try{const range=this.range();const result=await this.operations.schedule(range.from,range.to,this.coachFilter());if(this.initialRange&&!result.appointments.length&&result.dataDateTo&&result.dataDateTo!==this.date()){this.initialRange=false;this.date.set(result.dataDateTo);const latestRange=this.range();this.data.set(await this.operations.schedule(latestRange.from,latestRange.to,this.coachFilter()));this.message.set(`当前日期没有排课,已自动定位到最近有数据的 ${result.dataDateTo}`);}else{this.initialRange=false;this.data.set(result);}}catch(error){this.error.set(error instanceof Error?error.message:'排课数据加载失败');}finally{this.loading.set(false);}}