Jelajahi Sumber

feat(admin): add searchable schedule selectors

彭峰 2 bulan lalu
induk
melakukan
92aa8b011b

+ 48 - 5
src/app/admin/pages/operations-schedule.component.html

@@ -47,18 +47,61 @@
 </section>
 
 @if(drawerOpen()){
-  <div class="backdrop" (click)="drawerOpen.set(false)"></div>
+  <div class="backdrop" (click)="closeDrawer()"></div>
   <aside class="drawer" role="dialog" aria-modal="true" aria-label="排课编辑">
-    <header><div><p>SCHEDULE FORM</p><h2>{{editing()?'修改排课':'新增排课'}}</h2></div><button type="button" aria-label="关闭" (click)="drawerOpen.set(false)">×</button></header>
+    <header><div><p>SCHEDULE FORM</p><h2>{{editing()?'修改排课':'新增排课'}}</h2><span>搜索并确认学员、课程和陪练,再检查时间冲突</span></div><button class="icon-button" type="button" aria-label="关闭" (click)="closeDrawer()"><lucide-icon [img]="icons.X" [size]="20" /></button></header>
     <div class="segmented"><button type="button" [class.active]="mode()==='coach'" (click)="mode.set('coach')">陪练优先</button><button type="button" [class.active]="mode()==='time'" (click)="mode.set('time')">时间优先</button></div>
     <div class="form-grid">
-      <label>会员<select [ngModel]="studentId()" (ngModelChange)="studentId.set(+$event)"><option [ngValue]="0">请选择会员</option>@for(member of data()?.members||[];track member.objectId){<option [ngValue]="member.userId">{{member.displayName}} · {{member.mobileMasked}} · {{member.learningStatusLabel}}</option>}</select></label>
-      <label>课程<select [ngModel]="courseId()" (ngModelChange)="courseId.set(+$event)"><option [ngValue]="0">请选择课程</option>@for(course of data()?.courses||[];track course.courseId){<option [ngValue]="course.courseId">{{course.courseName}}</option>}</select></label>
+      <label class="search-field">
+        <span>选择学员 <small>可搜索姓名、手机号或用户ID</small></span>
+        <span class="search-control" [class.selected]="selectedMemberObjectId()">
+          <lucide-icon [img]="icons.Search" [size]="17" />
+          <input type="search" autocomplete="off" placeholder="输入学员姓名或手机号码" [ngModel]="memberSearch()" (ngModelChange)="updateMemberSearch($event)" (focus)="openSearch('member')" role="combobox" aria-autocomplete="list" [attr.aria-expanded]="activeSearch()==='member'" aria-controls="member-search-results" />
+          @if(selectedMemberObjectId()){<span class="selected-mark" aria-label="已选择"><lucide-icon [img]="icons.CheckCircle2" [size]="17" /></span>}
+        </span>
+        @if(activeSearch()==='member'){
+          <span class="search-results" id="member-search-results" role="listbox">
+            @for(member of filteredMembers();track member.objectId){
+              <button type="button" class="search-option" role="option" [attr.aria-selected]="selectedMemberObjectId()===member.objectId" (click)="chooseMember(member)"><span><strong>{{member.displayName}}</strong><small>{{member.mobileMasked}}@if(member.userId){ · ID {{member.userId}}}</small></span><em>{{member.learningStatusLabel}}</em></button>
+            } @empty {<span class="search-empty">没有匹配的学员,请检查姓名或手机号</span>}
+          </span>
+        }
+      </label>
+      <label class="search-field">
+        <span>选择课程 <small>支持课程名称模糊匹配</small></span>
+        <span class="search-control" [class.selected]="courseId()">
+          <lucide-icon [img]="icons.Search" [size]="17" />
+          <input type="search" autocomplete="off" placeholder="输入课程名称关键词" [ngModel]="courseSearch()" (ngModelChange)="updateCourseSearch($event)" (focus)="openSearch('course')" role="combobox" aria-autocomplete="list" [attr.aria-expanded]="activeSearch()==='course'" aria-controls="course-search-results" />
+          @if(courseId()){<span class="selected-mark" aria-label="已选择"><lucide-icon [img]="icons.CheckCircle2" [size]="17" /></span>}
+        </span>
+        @if(activeSearch()==='course'){
+          <span class="search-results" id="course-search-results" role="listbox">
+            @for(course of filteredCourses();track course.courseId){
+              <button type="button" class="search-option" role="option" [attr.aria-selected]="courseId()===course.courseId" (click)="chooseCourse(course)"><span><strong>{{course.courseName}}</strong><small>课程 ID {{course.courseId}}</small></span></button>
+            } @empty {<span class="search-empty">没有匹配的课程,请更换关键词</span>}
+          </span>
+        }
+      </label>
       <label>课程类型<select [ngModel]="classType()" (ngModelChange)="classType.set(+$event)"><option [ngValue]="1">30分钟</option><option [ngValue]="2">60分钟</option><option [ngValue]="3">体验课</option><option [ngValue]="4">陪练</option></select></label>
       <label>交付方式<select [ngModel]="deliveryMethod()" (ngModelChange)="deliveryMethod.set($event)"><option>线上</option><option>线下</option></select></label>
       <label>日期<input type="date" [ngModel]="date()" (ngModelChange)="date.set($event)" /></label>
       <label>开始时间<input type="time" step="1800" [ngModel]="startTime()" (ngModelChange)="startTime.set($event)" /></label>
-      <label>陪练<select [ngModel]="coachId()" (ngModelChange)="coachId.set(+$event)"><option [ngValue]="0">暂不分配</option>@for(coach of data()?.coaches||[];track coach.userId){<option [ngValue]="coach.userId">{{coach.displayName}}</option>}</select></label>
+      <label class="search-field wide">
+        <span>选择陪练老师 <small>可搜索老师姓名或手机号码</small></span>
+        <span class="search-control" [class.selected]="coachId()">
+          <lucide-icon [img]="icons.Search" [size]="17" />
+          <input type="search" autocomplete="off" placeholder="输入陪练老师姓名或手机号码,可暂不分配" [ngModel]="coachSearch()" (ngModelChange)="updateCoachSearch($event)" (focus)="openSearch('coach')" role="combobox" aria-autocomplete="list" [attr.aria-expanded]="activeSearch()==='coach'" aria-controls="coach-search-results" />
+          @if(coachId()){<span class="selected-mark" aria-label="已选择"><lucide-icon [img]="icons.CheckCircle2" [size]="17" /></span>}
+        </span>
+        @if(activeSearch()==='coach'){
+          <span class="search-results" id="coach-search-results" role="listbox">
+            <button type="button" class="search-option muted-option" role="option" [attr.aria-selected]="!coachId()" (click)="clearCoach()"><span><strong>暂不分配</strong><small>保存为待分配预约</small></span></button>
+            @for(coach of filteredCoaches();track coach.userId){
+              <button type="button" class="search-option" role="option" [attr.aria-selected]="coachId()===coach.userId" (click)="chooseCoach(coach)"><span><strong>{{coach.displayName}}</strong><small>{{coach.mobileMasked||'未登记手机号'}} · ID {{coach.userId}}</small></span><em>{{coach.availabilityConfigured?'已设置时间':'未设置时间'}}</em></button>
+            } @empty {<span class="search-empty">没有匹配的陪练老师,请检查姓名或手机号</span>}
+          </span>
+        }
+      </label>
       @if(!editing()){
         <label>重复方式<select [ngModel]="recurrence()" (ngModelChange)="recurrence.set($event)"><option value="once">单次</option><option value="daily">每天</option><option value="alternate">隔天</option><option value="weekly">每周</option><option value="same_day_30">同日间隔30分钟</option><option value="same_day_60">同日间隔60分钟</option></select></label>
         <label>排课次数<input type="number" min="1" max="60" [ngModel]="occurrences()" (ngModelChange)="occurrences.set(+$event)" /></label>

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

@@ -0,0 +1,331 @@
+:host {
+  --schedule-green: #176c55;
+  --schedule-green-soft: #eaf5f1;
+  --schedule-border: #dce6e2;
+  --schedule-text-muted: #6f7e79;
+}
+
+.page-header {
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 18px;
+}
+
+.page-header h1 {
+  margin: 8px 0 10px;
+}
+
+.page-header span {
+  color: var(--schedule-text-muted);
+}
+
+.page-header button,
+.toolbar > button,
+.drawer footer button {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 7px;
+  min-height: 42px;
+  padding: 9px 15px;
+  border: 1px solid var(--schedule-border);
+  border-radius: 12px;
+  background: #fff;
+  color: #33463f;
+  font-weight: 700;
+  transition: border-color .18s ease, box-shadow .18s ease, transform .18s ease;
+}
+
+.page-header button:hover,
+.toolbar > button:hover,
+.drawer footer button:hover {
+  border-color: #9db8ae;
+  box-shadow: 0 6px 16px rgba(29, 73, 58, .09);
+  transform: translateY(-1px);
+}
+
+button:focus-visible,
+input:focus-visible,
+select:focus-visible,
+textarea:focus-visible {
+  outline: 3px solid rgba(31, 130, 98, .2);
+  outline-offset: 2px;
+}
+
+.toolbar input,
+.toolbar select,
+.drawer input,
+.drawer select,
+.drawer textarea {
+  border-radius: 12px;
+}
+
+.toolbar .segmented,
+.drawer > .segmented {
+  border-radius: 13px;
+}
+
+.toolbar .segmented button,
+.drawer > .segmented button {
+  border-radius: 10px;
+}
+
+.summary-grid {
+  grid-template-columns: repeat(5, minmax(0, 1fr));
+}
+
+.calendar {
+  gap: 8px;
+}
+
+.calendar-row {
+  min-height: 74px;
+  overflow: hidden;
+  border-radius: 14px;
+  transition: border-color .18s ease, box-shadow .18s ease;
+}
+
+.calendar-row:hover {
+  border-color: #c5d8d1;
+  box-shadow: 0 5px 14px rgba(34, 71, 59, .06);
+}
+
+.coach-label {
+  padding: 11px 14px;
+}
+
+.appointment-strip {
+  min-height: 72px;
+  padding: 8px 10px;
+}
+
+.appointment {
+  min-width: 220px;
+  border: 1px solid #d7ebe4;
+  border-left: 4px solid #2a8b6d;
+  border-radius: 12px;
+  text-align: left;
+}
+
+.drawer {
+  width: min(720px, 100%);
+  padding: 28px 30px;
+  border-radius: 24px 0 0 24px;
+  background: #f8fbfa;
+}
+
+.drawer header {
+  align-items: flex-start;
+}
+
+.drawer header p {
+  margin: 0 0 6px;
+  color: #6e7d78;
+  letter-spacing: .12em;
+}
+
+.drawer header h2 {
+  margin: 0 0 7px;
+}
+
+.drawer header span {
+  color: var(--schedule-text-muted);
+  font-size: 13px;
+}
+
+.drawer .icon-button {
+  display: inline-grid;
+  place-items: center;
+  width: 40px;
+  height: 40px;
+  border: 1px solid var(--schedule-border);
+  border-radius: 12px;
+  background: #fff;
+  color: #4f625b;
+}
+
+.drawer > .segmented {
+  margin-bottom: 20px;
+  padding: 4px;
+}
+
+.drawer > .segmented button {
+  flex: 1;
+  min-height: 38px;
+  font-weight: 700;
+}
+
+.form-grid {
+  gap: 17px 14px;
+}
+
+.form-grid label > span:first-child {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+}
+
+.form-grid label > span:first-child small {
+  color: #8a9792;
+  font-size: 10px;
+  font-weight: 500;
+}
+
+.search-field {
+  position: relative;
+  z-index: 1;
+}
+
+.search-field:focus-within {
+  z-index: 12;
+}
+
+.search-control {
+  position: relative;
+  display: flex !important;
+  align-items: center !important;
+  min-height: 46px;
+  border: 1px solid var(--schedule-border);
+  border-radius: 13px;
+  background: #fff;
+  color: #87948f;
+  transition: border-color .18s ease, box-shadow .18s ease;
+}
+
+.search-control:focus-within {
+  border-color: #6fa992;
+  box-shadow: 0 0 0 4px rgba(34, 126, 96, .1);
+}
+
+.search-control.selected {
+  border-color: #9fc9ba;
+  background: #fbfefd;
+}
+
+.search-control > lucide-icon {
+  position: absolute;
+  left: 13px;
+  display: inline-flex;
+  pointer-events: none;
+}
+
+.search-control input {
+  width: 100%;
+  min-height: 44px;
+  padding: 10px 42px 10px 40px;
+  border: 0;
+  border-radius: 13px;
+  background: transparent;
+  color: #23372f;
+}
+
+.search-control input:focus-visible {
+  outline: 0;
+}
+
+.selected-mark {
+  position: absolute;
+  right: 12px;
+  display: inline-flex !important;
+  color: var(--schedule-green);
+}
+
+.search-results {
+  position: absolute;
+  z-index: 20;
+  top: calc(100% + 7px);
+  right: 0;
+  left: 0;
+  display: grid;
+  max-height: 310px;
+  padding: 7px;
+  overflow: auto;
+  border: 1px solid #d9e5e0;
+  border-radius: 14px;
+  background: #fff;
+  box-shadow: 0 18px 46px rgba(24, 57, 46, .17);
+}
+
+.search-option {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  width: 100%;
+  min-height: 54px;
+  padding: 9px 11px;
+  border: 0;
+  border-radius: 10px;
+  background: #fff;
+  color: #2c3f38;
+  text-align: left;
+}
+
+.search-option:hover,
+.search-option:focus-visible,
+.search-option[aria-selected='true'] {
+  background: var(--schedule-green-soft);
+}
+
+.search-option span,
+.search-option strong,
+.search-option small {
+  display: block;
+}
+
+.search-option small {
+  margin-top: 4px;
+  color: #82908b;
+  font-size: 11px;
+}
+
+.search-option em {
+  flex: none;
+  padding: 4px 8px;
+  border-radius: 999px;
+  background: #eff5f2;
+  color: #60736c;
+  font-size: 10px;
+  font-style: normal;
+}
+
+.muted-option {
+  border-bottom: 1px solid #edf1ef;
+}
+
+.search-empty {
+  display: grid;
+  min-height: 72px;
+  place-items: center;
+  color: #7c8a85;
+  font-size: 12px;
+  text-align: center;
+}
+
+.drawer footer {
+  padding-top: 18px;
+  border-top: 1px solid #e5ece9;
+}
+
+@media (max-width: 1300px) {
+  .summary-grid {
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+  }
+}
+
+@media (max-width: 700px) {
+  .page-header {
+    align-items: flex-start;
+  }
+
+  .drawer {
+    padding: 22px 18px;
+    border-radius: 0;
+  }
+
+  .summary-grid {
+    grid-template-columns: 1fr;
+  }
+}

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

@@ -0,0 +1,18 @@
+import { matchesScheduleSearch } from './operations-schedule.component';
+
+describe('排课搜索匹配', () => {
+  it('支持学员姓名和课程名称的模糊匹配', () => {
+    expect(matchesScheduleSearch('宇昊', ['万宇昊', '135****5177'])).toBeTrue();
+    expect(matchesScheduleSearch('pep', ['人教版(PEP)三年级起2022年录'])).toBeTrue();
+  });
+
+  it('支持使用完整手机号匹配脱敏手机号', () => {
+    expect(matchesScheduleSearch('13576045177', ['135****5177'])).toBeTrue();
+    expect(matchesScheduleSearch('13876045177', ['135****5177'])).toBeFalse();
+  });
+
+  it('支持用户或课程数字 ID 搜索', () => {
+    expect(matchesScheduleSearch('2048', ['会员甲', 2048])).toBeTrue();
+    expect(matchesScheduleSearch('1009', ['自然拼读', 1008])).toBeFalse();
+  });
+});

+ 35 - 5
src/app/admin/pages/operations-schedule.component.ts

@@ -7,12 +7,32 @@ 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')}`;
 
-@Component({ selector:'app-operations-schedule', standalone:true, imports:[CommonModule,FormsModule,LucideAngularModule], templateUrl:'./operations-schedule.component.html', styleUrl:'./operations-professional.scss', changeDetection:ChangeDetectionStrategy.OnPush })
+type SearchField='member'|'course'|'coach';
+
+export function matchesScheduleSearch(query:string,values:Array<string|number|undefined|null>):boolean{
+  const normalizedQuery=query.trim().toLocaleLowerCase();
+  if(!normalizedQuery)return true;
+  const normalizedValues=values.map(value=>String(value??'').trim().toLocaleLowerCase()).filter(Boolean);
+  if(normalizedValues.some(value=>value.includes(normalizedQuery)))return true;
+  const queryDigits=normalizedQuery.replace(/\D/g,'');
+  if(!queryDigits)return false;
+  return normalizedValues.some(value=>{
+    const valueDigits=value.replace(/\D/g,'');
+    if(valueDigits.includes(queryDigits))return true;
+    return value.includes('*')&&queryDigits.length>=7&&valueDigits.startsWith(queryDigits.slice(0,3))&&valueDigits.endsWith(queryDigits.slice(-4));
+  });
+}
+
+@Component({ selector:'app-operations-schedule', standalone:true, imports:[CommonModule,FormsModule,LucideAngularModule], templateUrl:'./operations-schedule.component.html', styleUrls:['./operations-professional.scss','./operations-schedule.component.scss'], changeDetection:ChangeDetectionStrategy.OnPush })
 export class OperationsScheduleComponent implements OnInit {
   readonly data=signal<ScheduleCalendarData|null>(null); readonly loading=signal(true); readonly saving=signal(false); readonly error=signal(''); readonly message=signal(''); readonly drawerOpen=signal(false); readonly editing=signal<ScheduleAppointment|null>(null); readonly mode=signal<'coach'|'time'>('coach'); readonly view=signal<'day'|'week'|'list'>('day'); readonly date=signal(localDateKey(new Date())); readonly coachFilter=signal(0);
   readonly studentId=signal(0); readonly courseId=signal(0); readonly coachId=signal(0); readonly classType=signal(1); readonly deliveryMethod=signal('线上'); readonly startTime=signal('19:00'); readonly recurrence=signal('once'); readonly occurrences=signal(1); readonly reason=signal('运营排课'); readonly preview=signal<Record<string,unknown>|null>(null);
+  readonly memberSearch=signal(''); readonly courseSearch=signal(''); readonly coachSearch=signal(''); readonly selectedMemberObjectId=signal(''); readonly activeSearch=signal<SearchField|null>(null);
   readonly icons={CalendarDays,CheckCircle2,ChevronLeft,ChevronRight,CircleAlert,Plus,RefreshCw,Search,X}; private readonly operations=inject(OperationsService);
   readonly activeAppointments=computed(()=>this.data()?.appointments.filter(item=>!item.cancelled).length||0); readonly unassignedCount=computed(()=>this.data()?.unassigned.length||0);
+  readonly filteredMembers=computed(()=>(this.data()?.members||[]).filter(member=>matchesScheduleSearch(this.memberSearch(),[member.displayName,member.mobileMasked,member.userId])).slice(0,12));
+  readonly filteredCourses=computed(()=>(this.data()?.courses||[]).filter(course=>matchesScheduleSearch(this.courseSearch(),[course.courseName,course.courseId])).slice(0,12));
+  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);}
   ngOnInit():void{void this.load();}
@@ -20,10 +40,20 @@ export class OperationsScheduleComponent implements OnInit {
   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);}}
   move(days:number):void{const value=new Date(`${this.date()}T00:00:00`);value.setDate(value.getDate()+days);this.date.set(localDateKey(value));void this.load();}
   showLatest():void{const latest=this.data()?.dataDateTo;if(!latest)return;this.date.set(latest);void this.load();}
-  openCreate():void{this.editing.set(null);this.studentId.set(0);this.courseId.set(0);this.coachId.set(0);this.preview.set(null);this.drawerOpen.set(true);}
-  openEdit(item:ScheduleAppointment):void{this.editing.set(item);this.studentId.set(item.studentId);this.courseId.set(item.courseId);this.coachId.set(item.coachId);this.classType.set(item.classType);this.date.set(item.startsAt.slice(0,10));this.startTime.set(item.startsAt.slice(11,16));this.deliveryMethod.set(item.deliveryMethod||'线上');this.preview.set(null);this.drawerOpen.set(true);}
+  openCreate():void{this.editing.set(null);this.studentId.set(0);this.courseId.set(0);this.coachId.set(0);this.memberSearch.set('');this.courseSearch.set('');this.coachSearch.set('');this.selectedMemberObjectId.set('');this.activeSearch.set(null);this.preview.set(null);this.drawerOpen.set(true);}
+  openEdit(item:ScheduleAppointment):void{this.editing.set(item);this.studentId.set(item.studentId);this.courseId.set(item.courseId);this.coachId.set(item.coachId);this.memberSearch.set(`${item.studentName}${this.memberMobile(item.studentId)?` · ${this.memberMobile(item.studentId)}`:''}`);this.courseSearch.set(item.courseName);this.coachSearch.set(item.coachId?item.coachName:'');this.selectedMemberObjectId.set(item.studentObjectId);this.activeSearch.set(null);this.classType.set(item.classType);this.date.set(item.startsAt.slice(0,10));this.startTime.set(item.startsAt.slice(11,16));this.deliveryMethod.set(item.deliveryMethod||'线上');this.preview.set(null);this.drawerOpen.set(true);}
+  closeDrawer():void{this.activeSearch.set(null);this.drawerOpen.set(false);}
+  openSearch(field:SearchField):void{this.activeSearch.set(field);}
+  updateMemberSearch(value:string):void{this.memberSearch.set(value);this.studentId.set(0);this.selectedMemberObjectId.set('');this.openSearch('member');}
+  updateCourseSearch(value:string):void{this.courseSearch.set(value);this.courseId.set(0);this.openSearch('course');}
+  updateCoachSearch(value:string):void{this.coachSearch.set(value);this.coachId.set(0);this.openSearch('coach');}
+  chooseMember(member:ScheduleCalendarData['members'][number]):void{this.studentId.set(member.userId);this.selectedMemberObjectId.set(member.objectId);this.memberSearch.set(`${member.displayName} · ${member.mobileMasked}`);this.activeSearch.set(null);this.preview.set(null);}
+  chooseCourse(course:ScheduleCalendarData['courses'][number]):void{this.courseId.set(course.courseId);this.courseSearch.set(course.courseName);this.activeSearch.set(null);this.preview.set(null);}
+  chooseCoach(coach:ScheduleCalendarData['coaches'][number]):void{this.coachId.set(coach.userId);this.coachSearch.set(`${coach.displayName}${coach.mobileMasked?` · ${coach.mobileMasked}`:''}`);this.activeSearch.set(null);this.preview.set(null);}
+  clearCoach():void{this.coachId.set(0);this.coachSearch.set('');this.activeSearch.set(null);this.preview.set(null);}
+  memberMobile(userId:number):string{return this.data()?.members.find(member=>member.userId===userId)?.mobileMasked||'';}
   payload():Record<string,unknown>{return{studentId:this.studentId(),courseId:this.courseId(),coachId:this.coachId(),classType:this.classType(),deliveryMethod:this.deliveryMethod(),date:this.date(),startTime:this.startTime(),recurrence:this.recurrence(),occurrences:this.occurrences()};}
-  async preflight():Promise<void>{this.error.set('');try{this.preview.set(await this.operations.request('ops/appointments/preflight',{targetId:this.editing()?.targetId||'',payload:this.payload()}));}catch(error){this.error.set(error instanceof Error?error.message:'排课预检失败');}}
-  async save():Promise<void>{if(!this.studentId()||!this.courseId()||!this.reason().trim()){this.error.set('请选择会员、课程并填写操作原因');return;}this.saving.set(true);this.error.set('');try{const editing=this.editing();await this.operations.mutate(editing?'ops/appointments/update':'ops/appointments/create',editing?.targetId||'',this.reason(),this.payload(),editing?.updatedAt||'');this.message.set(editing?'排课已更新':'排课已创建');this.drawerOpen.set(false);await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'排课保存失败');}finally{this.saving.set(false);}}
+  async preflight():Promise<void>{this.error.set('');if(!this.selectedMemberObjectId()||!this.courseId()){this.error.set('请先选择会员和课程');return;}if(!this.studentId()){this.error.set('该会员缺少旧系统排课编号,暂时不能排课;请先补齐该会员的迁移标识');return;}try{this.preview.set(await this.operations.request('ops/appointments/preflight',{targetId:this.editing()?.targetId||'',payload:this.payload()}));}catch(error){this.error.set(error instanceof Error?error.message:'排课预检失败');}}
+  async save():Promise<void>{if(!this.selectedMemberObjectId()||!this.courseId()||!this.reason().trim()){this.error.set('请选择会员、课程并填写操作原因');return;}if(!this.studentId()){this.error.set('该会员缺少旧系统排课编号,暂时不能保存;请先补齐该会员的迁移标识');return;}this.saving.set(true);this.error.set('');try{const editing=this.editing();await this.operations.mutate(editing?'ops/appointments/update':'ops/appointments/create',editing?.targetId||'',this.reason(),this.payload(),editing?.updatedAt||'');this.message.set(editing?'排课已更新':'排课已创建');this.drawerOpen.set(false);await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'排课保存失败');}finally{this.saving.set(false);}}
   async action(item:ScheduleAppointment,operation:string):Promise<void>{const reason=globalThis.prompt(operation.endsWith('restore')?'请输入恢复原因':operation.endsWith('complete')?'请输入确认完成原因':'请输入取消原因');if(!reason?.trim())return;this.saving.set(true);try{await this.operations.mutate(operation,item.targetId,reason,{},item.updatedAt);await this.load();}catch(error){this.error.set(error instanceof Error?error.message:'操作失败');}finally{this.saving.set(false);}}
 }