Browse Source

feat(admin): filter cross-date appointments by date range

彭峰 1 hour ago
parent
commit
2cf237ebac

+ 12 - 6
projects/xiaoshu-admin/src/app/pages/schedule-bulk-manager.component.html

@@ -1,14 +1,20 @@
 <div class="bulk-backdrop" (click)="close()"></div>
 <section class="bulk-dialog" role="dialog" aria-modal="true" aria-labelledby="bulk-title">
  <header><div><h2 id="bulk-title">跨日期预约管理</h2><p>按学员或老师姓名查询,勾选后批量取消未开始的预约</p></div><button type="button" class="close" aria-label="关闭" (click)="close()" [disabled]="saving()||selecting()">×</button></header>
- <form class="search" (ngSubmit)="searchAppointments()"><label><span>学员、老师或课程名称</span><input name="search" [(ngModel)]="searchInput" placeholder="例如:杨珍、灵灵" /></label><button type="submit" [disabled]="loading()||selecting()||saving()">查询</button></form>
- <p class="hint">显示全部日期。仅未开始的预约可批量取消;已开始、已结束及只读预约请逐项核对。取消会释放课时预留,旧系统原预约需在旧系统另行核对。</p>
+ <form class="search" (ngSubmit)="searchAppointments()"><fieldset [disabled]="loading()||selecting()||saving()">
+  <label><span>学员、老师或课程名称</span><input name="search" [(ngModel)]="searchInput" placeholder="例如:杨珍、灵灵" /></label>
+  <app-operations-date-filter label="预约开始日期" [value]="dateFromInput" (valueChange)="dateFromInput=$event" />
+  <app-operations-date-filter label="预约结束日期" [value]="dateToInput" (valueChange)="dateToInput=$event" />
+  <button type="submit">查询</button><button class="reset" type="button" (click)="resetFilters()">重置</button>
+ </fieldset></form>
+ <p class="hint">@if(dateFrom()||dateTo()){当前预约日期:{{dateFrom()||'不限'}} 至 {{dateTo()||'不限'}}(北京时间,含首尾日期)。}@else{显示全部预约日期。}仅未开始的预约可批量取消;已开始、已结束及只读预约请逐项核对。取消会释放课时预留,旧系统原预约需在旧系统另行核对。</p>
+ @if(hasPendingFilters()){<p class="notice" role="status">筛选条件已修改,请点击查询后再选择预约。</p>}
  @if(message()){<p class="notice" role="status">{{message()}}</p>}@if(error()){<p class="notice error" role="alert">{{error()}}</p>}
- <div class="controls"><div><strong>共 {{total()}} 条</strong><span>已选 {{selected().size}} 条</span></div><div><button type="button" (click)="selectAllMatching()" [disabled]="loading()||selecting()||saving()">{{selecting()?'正在选择…':'全选全部查询结果'}}</button><button type="button" (click)="clearSelection()" [disabled]="!selected().size||selecting()||saving()">清空选择</button></div></div>
- <div class="table-wrap" [attr.aria-busy]="loading()"><table><thead><tr><th><input type="checkbox" aria-label="选择本页全部未开始预约" [checked]="pageSelected()" (change)="togglePage()" [disabled]="!selectablePage().length||loading()||selecting()||saving()" /></th><th>预约时间</th><th>学员</th><th>老师</th><th>课程</th><th>状态</th></tr></thead><tbody>
- @for(row of rows();track row.targetId){<tr><td><input type="checkbox" [attr.aria-label]="'选择 '+row.studentName+' 的预约'" [checked]="selected().has(row.targetId)" [disabled]="!canSelect(row)||loading()||selecting()||saving()" (change)="toggleRow(row)" /></td><td>{{row.startsAt|date:'yyyy-MM-dd HH:mm':'+0800'}}</td><td>{{row.studentName}}</td><td>{{row.coachName}}</td><td>{{row.courseName}}</td><td>{{row.statusLabel || (row.cancelled?'已取消':row.statusCode===0?'未开始':'不可批量取消')}}</td></tr>}
+ <div class="controls"><div><strong>共 {{total()}} 条</strong><span>已选 {{selected().size}} 条</span></div><div><button type="button" (click)="selectAllMatching()" [disabled]="loading()||selecting()||saving()||hasPendingFilters()||!resultsReady()">{{selecting()?'正在选择…':'全选全部查询结果'}}</button><button type="button" (click)="clearSelection()" [disabled]="!selected().size||selecting()||saving()">清空选择</button></div></div>
+ <div class="table-wrap" [attr.aria-busy]="loading()"><table><thead><tr><th><input type="checkbox" aria-label="选择本页全部未开始预约" [checked]="pageSelected()" (change)="togglePage()" [disabled]="!selectablePage().length||loading()||selecting()||saving()||hasPendingFilters()||!resultsReady()" /></th><th>预约时间</th><th>学员</th><th>老师</th><th>课程</th><th>状态</th></tr></thead><tbody>
+ @for(row of rows();track row.targetId){<tr><td><input type="checkbox" [attr.aria-label]="'选择 '+row.studentName+' 的预约'" [checked]="selected().has(row.targetId)" [disabled]="!canSelect(row)||loading()||selecting()||saving()||hasPendingFilters()||!resultsReady()" (change)="toggleRow(row)" /></td><td>{{row.startsAt|date:'yyyy-MM-dd HH:mm':'+0800'}}</td><td>{{row.studentName}}</td><td>{{row.coachName}}</td><td>{{row.courseName}}</td><td>{{row.statusLabel || (row.cancelled?'已取消':row.statusCode===0?'未开始':'不可批量取消')}}</td></tr>}
  @empty{<tr><td colspan="6" class="empty">{{loading()?'正在加载…':'没有找到预约'}}</td></tr>}
  </tbody></table></div>
  <div class="pagination"><label>每页 <select [ngModel]="pageSize()" (ngModelChange)="changePageSize(+$event)" [disabled]="loading()||selecting()||saving()"><option [ngValue]="10">10</option><option [ngValue]="25">25</option><option [ngValue]="50">50</option><option [ngValue]="100">100</option></select> 条</label><div><button type="button" (click)="changePage(page()-1)" [disabled]="page()<=1||loading()||selecting()||saving()">上一页</button><span>第 {{page()}} / {{pageCount()}} 页</span><button type="button" (click)="changePage(page()+1)" [disabled]="page()>=pageCount()||loading()||selecting()||saving()">下一页</button></div></div>
- <footer><label>取消原因 <input [(ngModel)]="reason" [disabled]="saving()" maxlength="200" /></label><button class="danger" type="button" (click)="cancelSelected()" [disabled]="!selected().size||loading()||selecting()||saving()">{{saving()?'正在处理 '+progress().done+' / '+progress().total:'批量取消所选预约'}}</button></footer>
+ <footer><label>取消原因 <input [(ngModel)]="reason" [disabled]="saving()" maxlength="200" /></label><button class="danger" type="button" (click)="cancelSelected()" [disabled]="!selected().size||loading()||selecting()||saving()||hasPendingFilters()||!resultsReady()">{{saving()?'正在处理 '+progress().done+' / '+progress().total:'批量取消所选预约'}}</button></footer>
 </section>

+ 5 - 1
projects/xiaoshu-admin/src/app/pages/schedule-bulk-manager.component.scss

@@ -3,7 +3,11 @@
 .bulk-dialog{position:relative;display:flex;flex-direction:column;gap:14px;width:min(1100px,100%);max-height:calc(100vh - 40px);padding:22px;background:#fff;border-radius:16px;box-shadow:0 18px 60px #173c3040;overflow:auto}
 header,.controls,.pagination,footer{display:flex;align-items:center;justify-content:space-between;gap:12px}h2,p{margin:0}header p,.hint{color:#62766e;font-size:13px;line-height:1.5}header h2{font-size:21px;margin-bottom:5px}.close{font-size:25px;line-height:1;padding:0 8px}
 button,input,select{font:inherit}button{background:#fff;border:1px solid #cdded7;border-radius:8px;color:#176c57;padding:9px 13px;cursor:pointer}button:disabled{opacity:.5;cursor:not-allowed}input:not([type=checkbox]),select{border:1px solid #cdded7;border-radius:8px;padding:9px 11px;min-width:0}input[type=checkbox]{width:17px;height:17px;accent-color:#19765d;cursor:pointer}
-.search{display:flex;gap:10px;align-items:end}.search label{display:grid;gap:5px;flex:1;font-size:13px}.search button{background:#176c57;color:#fff}.controls>div,.pagination>div{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.controls span{font-size:13px;color:#60756d}.notice{padding:10px;border-radius:8px;background:#eaf6f0;color:#165e49;font-size:13px}.notice.error{background:#fff0ee;color:#a32826}
+.search fieldset{display:grid;grid-template-columns:minmax(180px,1fr) repeat(2,minmax(180px,210px)) auto auto;gap:10px;align-items:end;min-width:0;margin:0;padding:0;border:0}.search label{display:grid;gap:5px;flex:1;font-size:13px}.search button{background:#176c57;color:#fff}.controls>div,.pagination>div{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.controls span{font-size:13px;color:#60756d}.notice{padding:10px;border-radius:8px;background:#eaf6f0;color:#165e49;font-size:13px}.notice.error{background:#fff0ee;color:#a32826}
 .table-wrap{overflow:auto;border:1px solid #dce8e3;border-radius:9px;min-height:160px}table{border-collapse:collapse;width:100%;min-width:700px;text-align:left;font-size:13px}th,td{padding:11px 10px;border-bottom:1px solid #e4ece8}th{background:#f1f7f4;color:#52685f}tbody tr:last-child td{border-bottom:0}.empty{text-align:center;color:#71847b;padding:35px}.pagination{font-size:13px}.pagination label,.pagination div{display:flex;align-items:center;gap:8px}footer{padding-top:8px;border-top:1px solid #e0e9e4}footer label{display:flex;align-items:center;gap:8px;font-size:13px;flex:1}footer input{flex:1;max-width:360px}.danger{background:#b63e35;color:white;border-color:#b63e35}
 .pagination label{white-space:nowrap;flex:0 0 auto}.pagination select{width:75px;flex:none}
 @media(max-width:640px){:host{padding:8px}.bulk-dialog{padding:15px;max-height:calc(100vh - 16px)}.controls,.pagination,footer{align-items:stretch;flex-direction:column}footer label{flex-wrap:wrap}footer input{max-width:none}.search{align-items:stretch}.search button{align-self:end}}
+
+.search .reset{background:#fff;color:#176c57}.search input:not([type=checkbox]){box-sizing:border-box;width:100%;min-height:40px}
+@media(max-width:900px){.search fieldset{grid-template-columns:1fr 1fr}.search label{grid-column:1/-1}.search button{width:100%}}
+@media(max-width:420px){.search fieldset{grid-template-columns:minmax(0,1fr)}.search label{grid-column:auto}}

+ 42 - 1
projects/xiaoshu-admin/src/app/pages/schedule-bulk-manager.component.spec.ts

@@ -8,7 +8,7 @@ describe('跨日期预约批量管理',()=>{
  beforeEach(()=>{ops=jasmine.createSpyObj<OperationsService>('OperationsService',['list','mutate']);ops.list.and.callFake(async(_kind,page,size,search)=>({page,pageSize:size,total:rows.length,items:rows.slice((page-1)*size,page*size),refreshedAt:''}));TestBed.configureTestingModule({imports:[ScheduleBulkManagerComponent],providers:[{provide:OperationsService,useValue:ops}]});});
  it('按姓名跨日期查询,全选所有匹配页,排除已取消和训练中课程,并可取消单项',async()=>{
   const c=TestBed.createComponent(ScheduleBulkManagerComponent).componentInstance;c.searchInput='杨珍';await c.searchAppointments();
-  expect(ops.list).toHaveBeenCalledWith('appointments',1,25,'杨珍');await c.selectAllMatching();expect(c.selected().size).toBe(199);expect(c.selected().has('a199')).toBeFalse();expect(c.selected().has('a200')).toBeFalse();
+  expect(ops.list).toHaveBeenCalledWith('appointments',1,25,'杨珍',{dateFrom:'',dateTo:''});await c.selectAllMatching();expect(c.selected().size).toBe(199);expect(c.selected().has('a199')).toBeFalse();expect(c.selected().has('a200')).toBeFalse();
   c.toggleRow(rows[0] as any);expect(c.selected().size).toBe(198);
  });
  it('批量请求附带版本和未开始保护,部分失败保留待核对项',async()=>{
@@ -18,4 +18,45 @@ describe('跨日期预约批量管理',()=>{
   expect(c.selected().has('a0')).toBeFalse();expect(c.selected().has('a1')).toBeTrue();expect(c.message()).toContain('1');expect(c.error()).toContain('预约已变更');
   expect(c.rows()[0].cancelled).toBeTrue();expect(c.canSelect({...rows[0],statusUnverified:true} as any)).toBeFalse();
  });
+ it('查询、翻页、每页条数与跨页全选使用已应用的预约日期范围',async()=>{
+  const c=TestBed.createComponent(ScheduleBulkManagerComponent).componentInstance as any;
+  c.searchInput='杨珍';c.dateFromInput='2026-11-01';c.dateToInput='2026-11-30';await c.searchAppointments();
+  const filters={dateFrom:'2026-11-01',dateTo:'2026-11-30'};
+  expect(ops.list).toHaveBeenCalledWith('appointments',1,25,'杨珍',filters);
+  await c.changePage(2);expect(ops.list).toHaveBeenCalledWith('appointments',2,25,'杨珍',filters);
+  await c.changePageSize(50);expect(ops.list).toHaveBeenCalledWith('appointments',1,50,'杨珍',filters);
+  await c.selectAllMatching();expect(c.selected().size).toBe(199);
+  expect(ops.list.calls.allArgs().every(args=>JSON.stringify(args[4])===JSON.stringify(filters))).toBeTrue();
+ });
+ it('可只按日期全选,日期倒置时不发起查询',async()=>{
+  const c=TestBed.createComponent(ScheduleBulkManagerComponent).componentInstance as any;
+  c.dateFromInput='2026-11-01';c.dateToInput='2026-11-30';await c.searchAppointments();await c.selectAllMatching();
+  expect(c.selected().size).toBe(199);const count=ops.list.calls.count();
+  c.dateFromInput='2026-12-01';await c.searchAppointments();
+  expect(c.error()).toContain('开始日期不能晚于结束日期');expect(ops.list.calls.count()).toBe(count);
+ });
+ it('日期条件改动后必须先查询,不能继续按旧结果全选或批量取消',async()=>{
+  const c=TestBed.createComponent(ScheduleBulkManagerComponent).componentInstance as any;
+  c.searchInput='杨珍';await c.searchAppointments();c.toggleRow(rows[0]);
+  c.dateFromInput='2026-12-01';const count=ops.list.calls.count();spyOn(window,'confirm').and.returnValue(true);
+  await c.selectAllMatching();await c.cancelSelected();
+  expect(ops.list.calls.count()).toBe(count);expect(ops.mutate).not.toHaveBeenCalled();expect(c.error()).toContain('请先点击查询');
+ });
+ it('显示两个日期选择器,重置后清除日期、姓名和旧勾选',async()=>{
+  const fixture=TestBed.createComponent(ScheduleBulkManagerComponent),c=fixture.componentInstance;
+  fixture.detectChanges();await fixture.whenStable();
+  expect(fixture.nativeElement.querySelectorAll('app-operations-date-filter').length).toBe(2);
+  c.searchInput='杨珍';c.dateFromInput='2026-11-01';c.dateToInput='2026-11-30';await c.searchAppointments();c.toggleRow(rows[0] as any);
+  await c.resetFilters();
+  expect(c.searchInput).toBe('');expect(c.dateFromInput).toBe('');expect(c.dateToInput).toBe('');expect(c.selected().size).toBe(0);
+  expect(ops.list).toHaveBeenCalledWith('appointments',1,25,'',{dateFrom:'',dateTo:''});
+ });
+ it('新日期查询失败后不能勾选或取消旧列表中的预约',async()=>{
+  const c=TestBed.createComponent(ScheduleBulkManagerComponent).componentInstance;
+  c.searchInput='杨珍';await c.searchAppointments();
+  c.dateFromInput='2026-12-01';ops.list.and.rejectWith(Error('请求失败'));await c.searchAppointments();
+  c.toggleRow(rows[0] as any);expect(c.selected().size).toBe(0);
+  const count=ops.list.calls.count();await c.selectAllMatching();expect(ops.list.calls.count()).toBe(count);
+  spyOn(window,'confirm').and.returnValue(true);await c.cancelSelected();expect(ops.mutate).not.toHaveBeenCalled();
+ });
 });

+ 24 - 11
projects/xiaoshu-admin/src/app/pages/schedule-bulk-manager.component.ts

@@ -3,48 +3,61 @@ import { ChangeDetectionStrategy, Component, EventEmitter, OnInit, Output, compu
 import { FormsModule } from '@angular/forms';
 import { ScheduleAppointment } from '../admin.models';
 import { OperationsService } from '../operations.service';
+import { OperationsDateFilterComponent } from '../components/operations-date-filter.component';
 
 /** 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})
+@Component({selector:'app-schedule-bulk-manager',standalone:true,imports:[CommonModule,FormsModule,OperationsDateFilterComponent],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 dateFrom=signal('');readonly dateTo=signal('');readonly resultsReady=signal(false);
  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;
+ searchInput='';dateFromInput='';dateToInput='';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();}
+ hasPendingFilters():boolean{return this.searchInput.trim()!==this.query()||this.dateFromInput!==this.dateFrom()||this.dateToInput!==this.dateTo();}
+ private filters():Record<string,unknown>{return{dateFrom:this.dateFrom(),dateTo:this.dateTo()};}
+ async searchAppointments():Promise<void>{
+  if(this.loading()||this.selecting()||this.saving())return;
+  if(this.dateFromInput&&this.dateToInput&&this.dateFromInput>this.dateToInput){this.error.set('开始日期不能晚于结束日期');return;}
+  this.query.set(this.searchInput.trim());this.dateFrom.set(this.dateFromInput);this.dateTo.set(this.dateToInput);this.page.set(1);this.selected.set(new Map());this.message.set('');await this.loadPage();
+ }
+ async resetFilters():Promise<void>{if(this.loading()||this.selecting()||this.saving())return;this.searchInput='';this.dateFromInput='';this.dateToInput='';await this.searchAppointments();}
  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 id=++this.request;this.loading.set(true);this.resultsReady.set(false);this.error.set('');
+  try{const result=await this.operations.list('appointments',this.page(),this.pageSize(),this.query(),this.filters());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);
+   this.rows.set(result.items as unknown as ScheduleAppointment[]);this.total.set(result.total);this.resultsReady.set(true);
   }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;});}
+ toggleRow(row:ScheduleAppointment):void{if(this.loading()||this.selecting()||this.saving()||this.hasPendingFilters()||!this.resultsReady()||!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()||this.hasPendingFilters()||!this.resultsReady())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;}
+  if(this.hasPendingFilters()){this.error.set('筛选条件已修改,请先点击查询');return;}
+  if(!this.resultsReady()){this.error.set('请先完成查询后再操作预约');return;}
+  if(!this.query()&&!this.dateFrom()&&!this.dateTo()){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;
+  try{const found=new Map<string,ScheduleAppointment>(),filters=this.filters();const first=await this.operations.list('appointments',1,100,this.query(),filters),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);}
+   add(first.items);for(let page=2;page<=Math.ceil(total/100);page++){const result=await this.operations.list('appointments',page,100,this.query(),filters);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.hasPendingFilters()){this.error.set('筛选条件已修改,请先点击查询');return;}
+  if(!this.resultsReady()){this.error.set('请先完成查询后再操作预约');return;}
   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;

+ 9 - 1
scripts/deploy-admin-functions.mjs

@@ -3419,8 +3419,16 @@ async function businessRecordSqlPage(kind,sql,args,input){
  const terms=fields.map(key=>'record."'+key+'"::text');terms.push("record.\"studentLegacy\"->>'HoneyName'");if(kind==='appointments')terms.push("record.\"coachLegacy\"->>'HoneyName'");
  let where='';if(paging.search){values.push('%'+paging.search.replace(/[\\%_]/g,'\\$&')+'%');where=" WHERE LOWER(CONCAT_WS(' ',"+terms.join(',')+')) LIKE $'+values.length+" ESCAPE E'\\\\'";}
  if(input._storeId){values.push(String(input._storeId));where+=(where?' AND ':' WHERE ')+"record.\"studentLegacy\"->>'ParentUserID'=$"+values.length;}
+ const appointmentAt="COALESCE(CASE WHEN \"yysj\" ~ '[ T][0-9]{1,2}:[0-9]{2}' THEN xs_payroll_time(\"yysj\") END,xs_payroll_time(NULLIF(\"bxrq\",'')||' '||COALESCE(NULLIF(\"sdsd\",''),'00:00')))";
+ if(kind==='appointments'){
+  const from=text(paging.filters.dateFrom),to=text(paging.filters.dateTo);
+  for(const date of [from,to])if(date){const parsed=new Date(date+'T00:00:00Z');if(!/^\d{4}-\d{2}-\d{2}$/.test(date)||Number.isNaN(parsed.getTime())||parsed.toISOString().slice(0,10)!==date)fail(400,'预约日期无效');}
+  if(from&&to&&from>to)fail(400,'开始日期不能晚于结束日期');
+  if(from){values.push(from);where+=(where?' AND ':' WHERE ')+appointmentAt+'>=xs_payroll_time($'+values.length+')';}
+  if(to){values.push(to);where+=(where?' AND ':' WHERE ')+appointmentAt+'<xs_payroll_time($'+values.length+")+INTERVAL '1 day'";}
+ }
  values.push(paging.pageSize,(paging.page-1)*paging.pageSize);
- const order=kind==='appointments'?"COALESCE(CASE WHEN \"yysj\" ~ '[ T][0-9]{1,2}:[0-9]{2}' THEN xs_payroll_time(\"yysj\") END,xs_payroll_time(NULLIF(\"bxrq\",'')||' '||COALESCE(NULLIF(\"sdsd\",''),'00:00'))) DESC NULLS LAST,\"targetId\" DESC NULLS LAST,\"objectId\" DESC":'"commonUpdatedAt" DESC NULLS LAST,"targetId" DESC NULLS LAST,"objectId" DESC';
+ const order=kind==='appointments'?appointmentAt+' DESC NULLS LAST,"targetId" DESC NULLS LAST,"objectId" DESC':'"commonUpdatedAt" DESC NULLS LAST,"targetId" DESC NULLS LAST,"objectId" DESC';
  const query='WITH filtered AS MATERIALIZED (SELECT record.* FROM ('+base+') record'+where+') SELECT (SELECT count(*)::int FROM filtered) AS total,COALESCE((SELECT jsonb_agg(page) FROM (SELECT * FROM filtered ORDER BY '+order+' LIMIT $'+(values.length-1)+' OFFSET $'+values.length+") page),'[]'::jsonb) AS items";
  const result=(await Psql.query(query,values))[0];return {total:number(result?.total),items:Array.isArray(result?.items)?result.items:[]};
 }

+ 24 - 0
scripts/tests/business-record-pagination.test.mjs

@@ -14,6 +14,28 @@ test('完整来源超过5000条仍可分页、搜索较早记录,空末页保
  }finally{await db.close();}
 });
 
+test('预约日期范围包含北京时间首尾全天,单侧筛选、分页和无效范围均正确',async()=>{
+ const db=new PGlite();try{
+  await db.exec(readFileSync(new URL('../sql/payroll-integrity.sql',import.meta.url),'utf8').match(/CREATE OR REPLACE FUNCTION xs_payroll_time\([\s\S]*?\$\$;/)[0]);
+  await db.exec(`CREATE TABLE appointments(company text,"objectId" text,"targetId" text,"commonUpdatedAt" timestamptz,yysj text,bxrq text,sdsd text);
+   INSERT INTO appointments VALUES
+   ('co','before','before',now(),'2027-01-01T15:59:59Z','',''),
+   ('co','start','start',now(),'2027-01-01T16:00:00Z','',''),
+   ('co','split','split',now(),'2027-01-02','2027-01-02','12:00'),
+   ('co','end','end',now(),'2027-01-02T15:59:59Z','',''),
+   ('co','after','after',now(),'2027-01-02T16:00:00Z','',''),
+   ('other','private','private',now(),'2027-01-02 12:00','','');`);
+  const ctx=vm.createContext({Date,fail:(_code,message)=>{throw Error(message);},Psql:{query:async(sql,args)=>(await db.query(sql,args)).rows}});vm.runInContext(source,ctx);
+  const read=input=>ctx.businessRecordSqlPage('appointments','SELECT * FROM appointments WHERE company=$1',['co'],input);
+  const filters={dateFrom:'2027-01-02',dateTo:'2027-01-02'};
+  let result=await read({page:1,pageSize:2,filters});assert.equal(result.total,3);assert.deepEqual(result.items.map(r=>r.targetId),['end','split']);
+  result=await read({page:2,pageSize:2,filters});assert.equal(result.total,3);assert.deepEqual(result.items.map(r=>r.targetId),['start']);
+  assert.equal((await read({filters:{dateFrom:'2027-01-02'}})).total,4);
+  assert.equal((await read({filters:{dateTo:'2027-01-02'}})).total,4);
+  for(const filters of [{dateFrom:'2027-01-03',dateTo:'2027-01-02'},{dateFrom:'2027-02-30'},{dateTo:'invalid'}])await assert.rejects(read({filters}),/日期/);
+ }finally{await db.close();}
+});
+
 test('跨日期预约按实际预约时间倒序后分页,忽略更新时间并兼容分开的日期时间',async()=>{
  const db=new PGlite();try{
   const payrollSql=readFileSync(new URL('../sql/payroll-integrity.sql',import.meta.url),'utf8');
@@ -37,5 +59,7 @@ test('跨日期预约按实际预约时间倒序后分页,忽略更新时间
    assert.equal(result.total,records.length);items.push(...result.items);
   }
   assert.deepEqual(items.map(row=>row.targetId),['z-tie','february','separate-date','fallback-date','date-only','later-time','utc','january','december','unknown']);
+  const range=await ctx.businessRecordSqlPage('appointments','SELECT * FROM appointments WHERE company=$1',['co'],{page:2,pageSize:2,search:'目标学员',filters:{dateFrom:'2027-01-02',dateTo:'2027-01-02'}});
+  assert.equal(range.total,4);assert.deepEqual(range.items.map(row=>row.targetId),['utc','january']);
  }finally{await db.close();}
 });