schedule-bulk-manager.component.ts 6.9 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
  1. import { CommonModule } from '@angular/common';
  2. import { ChangeDetectionStrategy, Component, EventEmitter, OnInit, Output, computed, inject, signal } from '@angular/core';
  3. import { FormsModule } from '@angular/forms';
  4. import { ScheduleAppointment } from '../admin.models';
  5. import { OperationsService } from '../operations.service';
  6. /** Cross-date search and explicit selection; each cancellation retains the normal audit and credit workflow. */
  7. @Component({selector:'app-schedule-bulk-manager',standalone:true,imports:[CommonModule,FormsModule],templateUrl:'./schedule-bulk-manager.component.html',styleUrls:['./schedule-bulk-manager.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
  8. export class ScheduleBulkManagerComponent implements OnInit {
  9. @Output() closed=new EventEmitter<void>();
  10. @Output() changed=new EventEmitter<void>();
  11. private readonly operations=inject(OperationsService);
  12. readonly rows=signal<ScheduleAppointment[]>([]);readonly selected=signal(new Map<string,ScheduleAppointment>());
  13. readonly page=signal(1);readonly pageSize=signal(25);readonly total=signal(0);readonly query=signal('');
  14. readonly loading=signal(false);readonly selecting=signal(false);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');
  15. readonly progress=signal({done:0,total:0});readonly pageCount=computed(()=>Math.max(1,Math.ceil(this.total()/this.pageSize())));
  16. readonly selectablePage=computed(()=>this.rows().filter(row=>this.canSelect(row)));
  17. readonly pageSelected=computed(()=>this.selectablePage().length>0&&this.selectablePage().every(row=>this.selected().has(row.targetId)));
  18. searchInput='';reason='批量调整排课';private request=0;
  19. ngOnInit():void{void this.loadPage();}
  20. canSelect(row:ScheduleAppointment):boolean{return Boolean(row.targetId&&row.updatedAt)&&!row.readOnly&&!row.statusUnverified&&!row.cancelled&&Number(row.statusCode)===0;}
  21. async searchAppointments():Promise<void>{if(this.loading()||this.selecting()||this.saving())return;this.query.set(this.searchInput.trim());this.page.set(1);this.selected.set(new Map());this.message.set('');await this.loadPage();}
  22. async loadPage():Promise<void>{
  23. const id=++this.request;this.loading.set(true);this.error.set('');
  24. try{const result=await this.operations.list('appointments',this.page(),this.pageSize(),this.query());if(id!==this.request)return;
  25. const last=Math.max(1,Math.ceil(result.total/this.pageSize()));if(this.page()>last){this.page.set(last);await this.loadPage();return;}
  26. this.rows.set(result.items as unknown as ScheduleAppointment[]);this.total.set(result.total);
  27. }catch(e){if(id===this.request)this.error.set(e instanceof Error?e.message:'预约查询失败,请重试');}
  28. finally{if(id===this.request)this.loading.set(false);}
  29. }
  30. async changePage(page:number):Promise<void>{if(this.loading()||this.selecting()||this.saving()||page<1||page>this.pageCount())return;this.page.set(page);await this.loadPage();}
  31. async changePageSize(size:number):Promise<void>{if(this.loading()||this.selecting()||this.saving())return;this.pageSize.set(Number(size));this.page.set(1);await this.loadPage();}
  32. toggleRow(row:ScheduleAppointment):void{if(this.loading()||this.selecting()||this.saving()||!this.canSelect(row))return;this.message.set('');this.selected.update(current=>{const next=new Map(current);if(next.has(row.targetId))next.delete(row.targetId);else next.set(row.targetId,row);return next;});}
  33. togglePage():void{if(this.loading()||this.selecting()||this.saving())return;this.message.set('');const clear=this.pageSelected();this.selected.update(current=>{const next=new Map(current);for(const row of this.selectablePage())if(clear)next.delete(row.targetId);else next.set(row.targetId,row);return next;});}
  34. async selectAllMatching():Promise<void>{
  35. if(this.loading()||this.selecting()||this.saving())return;
  36. if(!this.query()){this.error.set('请先按学员或老师姓名搜索,再全选全部结果');return;}
  37. this.selecting.set(true);this.error.set('');this.message.set('');
  38. try{const found=new Map<string,ScheduleAppointment>();const first=await this.operations.list('appointments',1,100,this.query()),total=first.total;
  39. const add=(items:Record<string,unknown>[])=>{for(const raw of items){const row=raw as ScheduleAppointment;if(row.targetId)found.set(row.targetId,row);}};
  40. add(first.items);for(let page=2;page<=Math.ceil(total/100);page++){const result=await this.operations.list('appointments',page,100,this.query());if(result.total!==total)throw Error('查询结果已变化,请重新搜索后再全选');add(result.items);}
  41. if(found.size!==total)throw Error('查询结果已变化,请重新搜索后再全选');
  42. this.selected.set(new Map([...found].filter(([,row])=>this.canSelect(row))));this.message.set(`已选择 ${this.selected().size} 条未开始的预约`);
  43. }catch(e){this.error.set(e instanceof Error?e.message:'全选失败,请重新搜索');}finally{this.selecting.set(false);}
  44. }
  45. clearSelection():void{if(!this.saving()&&!this.selecting()){this.selected.set(new Map());this.message.set('');}}
  46. async cancelSelected():Promise<void>{
  47. if(this.loading()||this.selecting()||this.saving())return;const items=[...this.selected().values()].filter(row=>this.canSelect(row));const reason=this.reason.trim();
  48. if(!items.length){this.error.set('请先勾选未开始的预约');return;}if(!reason){this.error.set('请填写取消原因');return;}
  49. if(!window.confirm(`确认取消所选的 ${items.length} 条未开始预约?此操作会释放对应课时预留。`))return;
  50. this.saving.set(true);this.error.set('');this.message.set('');this.progress.set({done:0,total:items.length});let index=0;const succeeded:string[]=[],failed:{row:ScheduleAppointment;message:string}[]=[];
  51. const worker=async()=>{while(index<items.length){const row=items[index++];try{await this.operations.mutate('ops/appointments/cancel',row.targetId,reason,{onlyUnstarted:true},row.updatedAt);succeeded.push(row.targetId);}catch(e){failed.push({row,message:e instanceof Error?e.message:'结果待核对'});}finally{this.progress.update(p=>({...p,done:p.done+1}));}}};
  52. try{await Promise.all(Array.from({length:Math.min(4,items.length)},()=>worker()));this.selected.update(current=>{const next=new Map(current);for(const id of succeeded)next.delete(id);return next;});
  53. const completed=new Set(succeeded);this.rows.update(rows=>rows.map(row=>completed.has(row.targetId)?{...row,cancelled:true,statusLabel:'已取消'}:row));
  54. this.message.set(`已取消 ${succeeded.length} 条${failed.length?`,${failed.length} 条未成功或待核对`:''}`);if(failed.length)this.error.set(failed.slice(0,5).map(x=>`${x.row.studentName} ${x.row.startsAt?.slice(0,16)||''}:${x.message}`).join(';')+(failed.length>5?';其余请刷新核对':''));
  55. if(succeeded.length)this.changed.emit();
  56. }finally{this.saving.set(false);}
  57. // Keep failure details visible; refresh on the user's next search to avoid hiding uncertain outcomes.
  58. }
  59. close():void{if(!this.saving()&&!this.selecting())this.closed.emit();}
  60. }