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(); @Output() changed=new EventEmitter(); private readonly operations=inject(OperationsService); readonly rows=signal([]);readonly selected=signal(new Map()); 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{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{ 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{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{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{ 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();const first=await this.operations.list('appointments',1,100,this.query()),total=first.total; const add=(items:Record[])=>{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{ 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({...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();} }