| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061 |
- import { CommonModule } from '@angular/common';
- import { ChangeDetectionStrategy, Component, EventEmitter, OnInit, Output, computed, inject, signal } from '@angular/core';
- import { FormsModule } from '@angular/forms';
- import { ScheduleAppointment } from '../admin.models';
- import { OperationsService } from '../operations.service';
- /** Cross-date search and explicit selection; each cancellation retains the normal audit and credit workflow. */
- @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})
- export class ScheduleBulkManagerComponent implements OnInit {
- @Output() closed=new EventEmitter<void>();
- @Output() changed=new EventEmitter<void>();
- private readonly operations=inject(OperationsService);
- readonly rows=signal<ScheduleAppointment[]>([]);readonly selected=signal(new Map<string,ScheduleAppointment>());
- readonly page=signal(1);readonly pageSize=signal(25);readonly total=signal(0);readonly query=signal('');
- readonly loading=signal(false);readonly selecting=signal(false);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');
- readonly progress=signal({done:0,total:0});readonly pageCount=computed(()=>Math.max(1,Math.ceil(this.total()/this.pageSize())));
- readonly selectablePage=computed(()=>this.rows().filter(row=>this.canSelect(row)));
- readonly pageSelected=computed(()=>this.selectablePage().length>0&&this.selectablePage().every(row=>this.selected().has(row.targetId)));
- searchInput='';reason='批量调整排课';private request=0;
- ngOnInit():void{void this.loadPage();}
- canSelect(row:ScheduleAppointment):boolean{return Boolean(row.targetId&&row.updatedAt)&&!row.readOnly&&!row.statusUnverified&&!row.cancelled&&Number(row.statusCode)===0;}
- 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();}
- async loadPage():Promise<void>{
- const id=++this.request;this.loading.set(true);this.error.set('');
- try{const result=await this.operations.list('appointments',this.page(),this.pageSize(),this.query());if(id!==this.request)return;
- const last=Math.max(1,Math.ceil(result.total/this.pageSize()));if(this.page()>last){this.page.set(last);await this.loadPage();return;}
- this.rows.set(result.items as unknown as ScheduleAppointment[]);this.total.set(result.total);
- }catch(e){if(id===this.request)this.error.set(e instanceof Error?e.message:'预约查询失败,请重试');}
- finally{if(id===this.request)this.loading.set(false);}
- }
- 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();}
- 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();}
- 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;});}
- 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;});}
- async selectAllMatching():Promise<void>{
- if(this.loading()||this.selecting()||this.saving())return;
- if(!this.query()){this.error.set('请先按学员或老师姓名搜索,再全选全部结果');return;}
- this.selecting.set(true);this.error.set('');this.message.set('');
- try{const found=new Map<string,ScheduleAppointment>();const first=await this.operations.list('appointments',1,100,this.query()),total=first.total;
- const add=(items:Record<string,unknown>[])=>{for(const raw of items){const row=raw as ScheduleAppointment;if(row.targetId)found.set(row.targetId,row);}};
- 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);}
- if(found.size!==total)throw Error('查询结果已变化,请重新搜索后再全选');
- this.selected.set(new Map([...found].filter(([,row])=>this.canSelect(row))));this.message.set(`已选择 ${this.selected().size} 条未开始的预约`);
- }catch(e){this.error.set(e instanceof Error?e.message:'全选失败,请重新搜索');}finally{this.selecting.set(false);}
- }
- clearSelection():void{if(!this.saving()&&!this.selecting()){this.selected.set(new Map());this.message.set('');}}
- async cancelSelected():Promise<void>{
- if(this.loading()||this.selecting()||this.saving())return;const items=[...this.selected().values()].filter(row=>this.canSelect(row));const reason=this.reason.trim();
- if(!items.length){this.error.set('请先勾选未开始的预约');return;}if(!reason){this.error.set('请填写取消原因');return;}
- if(!window.confirm(`确认取消所选的 ${items.length} 条未开始预约?此操作会释放对应课时预留。`))return;
- 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}[]=[];
- 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}));}}};
- 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;});
- const completed=new Set(succeeded);this.rows.update(rows=>rows.map(row=>completed.has(row.targetId)?{...row,cancelled:true,statusLabel:'已取消'}:row));
- 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?';其余请刷新核对':''));
- if(succeeded.length)this.changed.emit();
- }finally{this.saving.set(false);}
- // Keep failure details visible; refresh on the user's next search to avoid hiding uncertain outcomes.
- }
- close():void{if(!this.saving()&&!this.selecting())this.closed.emit();}
- }
|