瀏覽代碼

fix(schedule): expose all course standards and inline enrollment

彭峰 1 天之前
父節點
當前提交
0b78761779

+ 6 - 0
projects/xiaoshu-admin/src/app/components/member-enrollment.component.spec.ts

@@ -13,6 +13,12 @@ describe('会员开课与课时操作', () => {
   operations.request.and.resolveTo(options());operations.mutate.and.resolveTo({});
   TestBed.configureTestingModule({imports:[MemberEnrollmentComponent,MemberCreditAccountComponent],providers:[{provide:OperationsService,useValue:operations},{provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}}]});
  });
+ it('排课入口传入课型时直接加载对应分类并限制学阶',async()=>{
+  const fixture=TestBed.createComponent(MemberEnrollmentComponent);
+  fixture.componentRef.setInput('studentId',20);fixture.componentRef.setInput('initialCategory','middle_writing');fixture.detectChanges();await fixture.whenStable();
+  expect(operations.request).toHaveBeenCalledWith('ops/course-bindings/options',{studentId:20,categoryKey:'middle_writing'});
+  expect(fixture.componentInstance.category).toBe('middle_writing');expect(fixture.componentInstance.stage()).toBe('middle');
+ });
  it('切换分类清空待开内容,提交明确分类与内容 ID', async () => {
   const fixture=TestBed.createComponent(MemberEnrollmentComponent),c=fixture.componentInstance;c.studentId=20;await c.load();fixture.detectChanges();
   c.toggle(100);c.changeCategory('word_self_study');expect(c.selected()).toEqual([]);await fixture.whenStable();

+ 3 - 3
projects/xiaoshu-admin/src/app/components/member-enrollment.component.ts

@@ -1,6 +1,6 @@
 import { DropdownDirective } from '../shared/dropdown.directive';
 import { CommonModule } from '@angular/common';
-import { Component, Input, Output, EventEmitter, OnChanges, inject, signal, computed } from '@angular/core';
+import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges, inject, signal, computed } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { OperationsService } from '../operations.service';
 import { EnrollmentOptions } from '../member-enrollment.models';
@@ -18,11 +18,11 @@ import { EnrollmentOptions } from '../member-enrollment.models';
 
 }</section>`,styleUrls:['./member-enrollment.scss']})
 export class MemberEnrollmentComponent implements OnChanges{
- @Input({required:true}) studentId=0;@Input() refreshVersion=0;@Output() changed=new EventEmitter<void>();private operations=inject(OperationsService);
+ @Input({required:true}) studentId=0;@Input() refreshVersion=0;@Input() initialCategory='word';@Output() changed=new EventEmitter<void>();private operations=inject(OperationsService);
  options=signal<EnrollmentOptions|null>(null);selected=signal<number[]>([]);search=signal('');stage=signal('');error=signal('');message=signal('');saving=signal(false);loading=signal(false);category='word';reason='为会员开课';private serial=0;private key='';private fingerprint='';
  categoryStage(){return ({primary_writing:'primary',middle_writing:'middle',high_writing:'high'} as Record<string,string>)[this.category]||'';}
  filtered=computed(()=>(this.options()?.items||[]).filter(c=>(!this.stage()||c.stageKey===this.stage())&&`${c.courseName} ${c.courseId}`.toLowerCase().includes(this.search().trim().toLowerCase())));
- ngOnChanges(){void this.load();}changeCategory(value:string){this.category=value;this.options.set(null);this.selected.set([]);this.stage.set(this.categoryStage());this.message.set('');void this.load();}
+ ngOnChanges(changes:SimpleChanges){if(changes['initialCategory']){this.category=this.initialCategory;this.stage.set(this.categoryStage());}void this.load();}changeCategory(value:string){this.category=value;this.options.set(null);this.selected.set([]);this.stage.set(this.categoryStage());this.message.set('');void this.load();}
  selectedCourses=computed(()=>(this.options()?.items||[]).filter(c=>this.selected().includes(c.courseId)));
  clearSelection(){if(!this.saving())this.selected.set([]);}
  toggle(id:number){const course=this.options()?.items.find(c=>c.courseId===id);if(this.saving()||this.loading()||!course?.eligible||course.alreadyOpened)return;this.selected.update(ids=>ids.includes(id)?ids.filter(x=>x!==id):[...ids,id]);}

+ 26 - 7
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.html

@@ -125,11 +125,15 @@
 @if(drawerOpen()){
   <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><span>搜索并确认学员、课程和陪练,再检查时间冲突</span></div><button class="icon-button" type="button" aria-label="关闭" (click)="closeDrawer()"><lucide-icon [img]="icons.X" [size]="20" /></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>
     @if(error()){<p class="notice error" role="alert" data-testid="schedule-form-error">{{error()}}</p>}
+    @if(enrollmentOpen()){
+      <div class="enrollment-return"><strong>为 {{memberSearch()}} 开通课型教材</strong><button type="button" (click)="closeEnrollment()" [disabled]="enrollment?.saving()||loading()">返回排课</button></div>
+      <app-member-enrollment [studentId]="studentId()" [initialCategory]="categoryKey()" (changed)="enrollmentChanged()" />
+    } @else {
     <p class="notice">课程在上课当天 00:00 至 24:00 有效,可当天提前或延后上课;预约时间和时长用于安排课程与检查冲突。</p>
     <div class="segmented"><button type="button" [class.active]="mode()==='coach'" (click)="setMode('coach')">陪练优先</button><button type="button" [class.active]="mode()==='time'" (click)="setMode('time')">时间优先</button></div>
-    <p class="mode-guidance"><strong>{{mode()==='coach'?'先确定老师':'先确定课程时间'}}</strong><span>{{mode()==='coach'?'先选日期和陪练,再选择学员;只有一门已开课程时会自动带出课程与标准':'先选学员、课程和上课时间,陪练候选会把当前时段无冲突的老师排在前面'}}</span></p>
+    <p class="mode-guidance"><strong>{{mode()==='coach'?'先确定老师':'先确定课程时间'}}</strong><span>{{mode()==='coach'?'先选日期和陪练,再选学员与课型;只有一门已开教材时会自动带出':'先选学员、课型时长和教材,再选上课时间;无冲突的陪练排在前面'}}</span></p>
     <div class="form-grid" [class.coach-first]="mode()==='coach'" [class.time-first]="mode()==='time'">
       <label class="search-field member-step">
         <span>选择学员 <small>可搜索姓名、手机号或用户ID</small></span>
@@ -146,22 +150,36 @@
           </span>
         }
       </label>
+      <label class="type-step"><span>课型与时长 <small>全部 10 种课程标准</small></span>
+        <select aria-label="课型与时长" [ngModel]="standardKey()" (ngModelChange)="selectStandard($event)" [disabled]="!studentId()">
+          @if(!categoryKey()){<option value="">请选择课程标准</option>}
+          @for(standard of courseStandards;track standard.key){<option [value]="standard.key">{{standard.label}} · {{standard.duration}}分钟{{studentId()?' · '+standardAvailability(standard.categoryKey):''}}</option>}
+        </select>
+        <small class="standard-help">先选课型与时长,再选择该学员对应的教材。</small>
+      </label>
       <label class="search-field course-step">
-        <span>选择课程 <small>仅显示当前学员已绑定课程,支持模糊匹配</small></span>
+        <span>课程教材 <small>当前课型已开 {{categoryCourses().length}} 门,可搜索名称或 ID</small></span>
         <span class="search-control" [class.selected]="courseId()">
           <lucide-icon [img]="icons.Search" [size]="17" />
-          <input type="search" autocomplete="off" [disabled]="!studentId()" [placeholder]="!studentId()?'请先选择学员':studentCourses().length?'请选择 '+studentCourses().length+' 门已开课程中的一门':'该学员没有有效的已开课程'" [ngModel]="courseSearch()" (ngModelChange)="updateCourseSearch($event)" (focus)="openSearch('course')" (click)="openSearch('course')" #courseSearchInput role="combobox" aria-autocomplete="list" [attr.aria-expanded]="activeSearch()==='course'" aria-controls="course-search-results" />
+          <input type="search" autocomplete="off" [disabled]="!studentId()" [placeholder]="!studentId()?'请先选择学员':categoryCourses().length?'选择 '+standardLabel()+' 的教材':'该课型尚未开通教材'" [ngModel]="courseSearch()" (ngModelChange)="updateCourseSearch($event)" (focus)="openSearch('course')" (click)="openSearch('course')" #courseSearchInput 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" [xsAnchoredMenu]="courseSearchInput" (xsDismiss)="activeSearch.set(null)" id="course-search-results" role="listbox">
             @for(course of filteredCourses();track course.bindingId||course.courseId){
               <button type="button" class="search-option" role="option" [attr.aria-selected]="courseId()===course.courseId && bindingId()===(course.bindingId||'')" (click)="chooseCourse(course)"><span><strong>{{course.categoryName}} · {{course.courseName}}</strong><small>课程 ID {{course.courseId}} · {{course.mappingConfirmed?((course.stageName ? course.stageName+' · ' : '')+course.categoryName):'待处理课程分类'}}</small></span></button>
-            } @empty {<span class="search-empty">{{studentId()?'该学员没有匹配的已绑定课程,请先在会员详情绑定课程':'请先选择学员,再选择其已绑定课程'}}</span>}
+            } @empty {<span class="search-empty">{{studentId()?'该课型没有匹配的已开教材,可清空搜索或在下方开课':'请先选择学员,再选择其已绑定课程'}}</span>}
           </span>
         }
       </label>
-      <label class="type-step">课程标准<select [ngModel]="durationMinutes()" (ngModelChange)="updateDuration(+$event)" [disabled]="!selectedCourse()?.mappingConfirmed">@for(duration of selectedCourse()?.supportedDurations||[30,60];track duration){<option [ngValue]="duration">{{selectedCourse()?.categoryName||'请选择已开课程'}} · {{duration}}分钟</option>}</select>@if(selectedCourse()&&!selectedCourse()?.mappingConfirmed){<small class="exception">该课程尚未完成分类映射,或数据库课程大类缺失,不能排课</small>}</label>
+      @if(studentId()){
+        <section class="wide course-guidance" aria-live="polite">
+          @if(!categoryCourses().length){<p>该学员尚未开通「{{standardLabel()}}」教材。先开课,再排课;开课本身不扣课时。</p>}
+          @else if(selectedCourse()&&!courseSelectionValid()){<p class="exception">所选教材没有当前课型、时长的有效规则,请调整标准或在“排课与工资标准”中配置。</p>}
+          @else{<p>当前课型:{{standardLabel()}} · {{durationMinutes()}}分钟。教材用于确定本次学习内容。</p>}
+          <button type="button" (click)="openEnrollment()">{{categoryCourses().length?'开通其他教材':'为该学员开课'}}</button>
+        </section>
+      }
       <label class="delivery-step">交付方式<select [ngModel]="deliveryMethod()" (ngModelChange)="deliveryMethod.set($event);preview.set(null)"><option>线上</option><option>线下</option></select></label>
       <app-operations-date-filter class="date-step" label="日期" [value]="date()" (valueChange)="updateDraftDate($event)" />
       <label class="time-step">开始时间<input type="time" step="1800" [ngModel]="startTime()" (ngModelChange)="updateStartTime($event)" /></label>
@@ -193,7 +211,8 @@
         @for(item of previewCandidates();track item.index){<article [class.conflict]="!item.available"><i>{{item.index+1}}</i><div><strong>{{item.startsAt|date:'yyyy-MM-dd HH:mm'}}–{{item.endsAt|date:'HH:mm'}}</strong><span>{{item.available?'可以排课':item.reasons.join(';')}}</span></div><em>{{item.available?'可提交':'需调整'}}</em></article>}
       </div>
     }
-    <footer><button type="button" (click)="preflight()" [disabled]="saving()||checking()">{{checking()?'正在检查…':'检查冲突'}}</button><button class="primary" type="button" (click)="save()" [disabled]="saving()||!canSavePreview()">{{saveButtonLabel()}}</button></footer>
+    <footer><button type="button" (click)="preflight()" [disabled]="saving()||checking()||!courseSelectionValid()">{{checking()?'正在检查…':'检查冲突'}}</button><button class="primary" type="button" (click)="save()" [disabled]="saving()||!canSavePreview()">{{saveButtonLabel()}}</button></footer>
+    }
   </aside>
 }
 @if(feedback();as report){

+ 13 - 4
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.scss

@@ -705,16 +705,16 @@ textarea:focus-visible {
 .form-grid.coach-first .time-step { order: 2; }
 .form-grid.coach-first .coach-step { order: 3; }
 .form-grid.coach-first .member-step { order: 4; }
-.form-grid.coach-first .course-step { order: 5; }
-.form-grid.coach-first .type-step { order: 6; }
+.form-grid.coach-first .course-step { order: 6; }
+.form-grid.coach-first .type-step { order: 5; }
 .form-grid.coach-first .delivery-step { order: 7; }
 .form-grid.coach-first .recurrence-step { order: 8; }
 .form-grid.coach-first .occurrences-step { order: 9; }
 .form-grid.coach-first .reason-step { order: 10; }
 
 .form-grid.time-first .member-step { order: 1; }
-.form-grid.time-first .course-step { order: 2; }
-.form-grid.time-first .type-step { order: 3; }
+.form-grid.time-first .course-step { order: 3; }
+.form-grid.time-first .type-step { order: 2; }
 .form-grid.time-first .delivery-step { order: 4; }
 .form-grid.time-first .date-step { order: 5; }
 .form-grid.time-first .time-step { order: 6; }
@@ -1284,3 +1284,12 @@ textarea:focus-visible {
   .schedule-loading .loading-icon lucide-icon,
   .loading-track i { animation-duration: 2.2s; }
 }
+
+.form-grid .course-guidance { order: 7; }
+.form-grid.coach-first .course-guidance { order: 7; }
+.form-grid.time-first .course-guidance { order: 4; }
+.standard-help { color: #526b60; font-size: 12px; line-height: 1.5; }
+.course-guidance, .enrollment-return { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; padding: 12px; border-radius: 12px; background: #eaf5f0; }
+.course-guidance p { flex: 1 1 230px; margin: 0; color: #36584a; font-size: 13px; line-height: 1.6; }
+.course-guidance button, .enrollment-return button { min-height: 44px; padding: 8px 14px; border: 1px solid #8bbbaa; border-radius: 8px; background: white; color: #16634f; cursor: pointer; }
+.enrollment-return { margin-bottom: 16px; }

+ 64 - 1
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.spec.ts

@@ -262,7 +262,8 @@ describe('排课表单预检', () => {
     const component = fixture.componentInstance;
     spyOn(component, 'load').and.resolveTo();
     fixture.detectChanges();await fixture.whenStable();
-    component.openCreate();component.studentId.set(20);component.selectedMemberObjectId.set('member');component.courseId.set(100);
+    component.data.set({members:[],courses:[{bindingId:'word-binding',courseId:100,courseName:'词表',memberIds:[20],categoryKey:'word',mappingConfirmed:true,supportedDurations:[30,60]}],appointments:[],coaches:[]} as unknown as ScheduleCalendarData);
+    component.openCreate();component.studentId.set(20);component.selectedMemberObjectId.set('member');component.chooseCourse(component.data()!.courses[0]);
     return { fixture, component };
   }
   it('预检失败直接显示在抽屉内,旧的成功结果不能继续保存', async () => {
@@ -331,3 +332,65 @@ describe('排课页面复用后的会员与开课数据', () => {
     expect(fixture.componentInstance.studentId()).toBe(1935);
   }));
 });
+
+
+describe('排课完整课型选择与教材联动',()=>{
+  let page:OperationsScheduleComponent;
+  let fixture:ComponentFixture<OperationsScheduleComponent>;
+  let request:jasmine.Spy;
+  const course=(bindingId:string,categoryKey:string,courseId=100)=>({bindingId,categoryKey,courseId,courseName:'体验词库一',memberIds:[20],mappingConfirmed:true,supportedDurations:categoryKey==='trial'||categoryKey==='word_self_study'?[60]:[30,60]});
+  beforeEach(async()=>{
+    request=jasmine.createSpy().and.resolveTo({availableCount:1,conflictCount:0});
+    await TestBed.configureTestingModule({imports:[OperationsScheduleComponent],providers:[
+      {provide:AdminSessionService,useValue:{session:()=>({operationsRole:'ops-manager'})}},
+      {provide:OperationsService,useValue:{request}},
+      {provide:ActivatedRoute,useValue:{snapshot:{queryParamMap:convertToParamMap({})}}},
+      {provide:OperationsLiveRefreshService,useValue:{watch:()=>()=>undefined}},
+    ]}).compileComponents();
+    fixture=TestBed.createComponent(OperationsScheduleComponent);page=fixture.componentInstance;
+    spyOn(page,'load').and.resolveTo();fixture.detectChanges();await fixture.whenStable();
+    page.data.set({courses:[course('word','word'),course('trial','trial'),course('writing','middle_writing',101)],members:[],appointments:[],coaches:[]} as unknown as ScheduleCalendarData);
+    page.openCreate();page.studentId.set(20);page.selectedMemberObjectId.set('member');page.selectStandard('word:30');
+  });
+  afterEach(()=>fixture.destroy());
+  it('显示截图中全部十种标准,包括未开课的分类',()=>{
+    fixture.detectChanges();
+    const options=Array.from(fixture.nativeElement.querySelectorAll('select[aria-label="课型与时长"] option')) as HTMLOptionElement[];
+    expect(options.length).toBe(10);
+    expect(options.map(row=>row.textContent?.split(' · 待开课')[0].split(' · 已开')[0])).toEqual([
+      '单词课 · 30分钟','单词课 · 60分钟','体验课 · 60分钟','小学语法写作 · 30分钟','初中语法写作 · 30分钟','高中语法写作 · 30分钟','小学语法写作 · 60分钟','初中语法写作 · 60分钟','高中语法写作 · 60分钟','单词自学课 · 60分钟',
+    ]);
+    expect(options.find(row=>row.value==='word_self_study:60')?.disabled).toBeFalse();
+  });
+  it('同一教材切换体验课使用对应 bindingId 与60分钟,旧预检失效',async()=>{
+    await page.preflight();expect(page.canSavePreview()).toBeTrue();
+    page.selectStandard('trial:60');expect(page.bindingId()).toBe('trial');expect(page.durationMinutes()).toBe(60);expect(page.preview()).toBeNull();
+    await page.preflight();expect(request.calls.mostRecent().args[1].payload).toEqual(jasmine.objectContaining({bindingId:'trial',courseId:100,durationMinutes:60}));
+  });
+  it('未开课的分类清空旧教材、禁止预检,并提供当前分类的开课入口',async()=>{
+    page.selectStandard('high_writing:60');fixture.detectChanges();
+    expect(page.courseId()).toBe(0);expect(page.bindingId()).toBe('');expect(page.durationMinutes()).toBe(60);
+    expect(page.courseSelectionValid()).toBeFalse();expect(fixture.nativeElement.textContent).toContain('为该学员开课');
+    await page.preflight();expect(request).not.toHaveBeenCalled();
+    page.openEnrollment();expect(page.enrollmentOpen()).toBeTrue();expect(page.categoryKey()).toBe('high_writing');
+  });
+  it('选择60分钟后切换教材不重置为30分钟,并展示超过12门的全部教材',()=>{
+    page.data.update(data=>({...data!,courses:Array.from({length:15},(_,i)=>course('word-'+i,'word',100+i))}));
+    page.selectStandard('word:60');expect(page.filteredCourses().length).toBe(15);
+    page.chooseCourse(page.filteredCourses()[14]);expect(page.durationMinutes()).toBe(60);expect(page.bindingId()).toBe('word-14');expect(page.filteredCourses().length).toBe(15);
+  });
+  it('教材或规则刷新后失效不能继续保存旧预检',async()=>{
+    await page.preflight();expect(page.canSavePreview()).toBeTrue();
+    page.data.update(data=>({...data!,courses:[]}));expect(page.canSavePreview()).toBeFalse();
+  });
+  it('修改排课保留原课型与时长,再新建时重置重复方式',()=>{
+    page.openEdit({startsAt:'2026-10-20T11:00:00Z',studentId:20,studentObjectId:'member',courseId:100,bindingId:'trial',courseCategoryKey:'trial',courseName:'体验词库一',classType:3,durationMinutes:60} as ScheduleAppointment);
+    expect(page.standardKey()).toBe('trial:60');expect(page.bindingId()).toBe('trial');expect(page.courseSelectionValid()).toBeTrue();
+    page.recurrence.set('daily');page.occurrences.set(10);page.openCreate();expect(page.standardKey()).toBe('word:30');expect(page.recurrence()).toBe('once');expect(page.occurrences()).toBe(1);
+  });
+  it('开课后留在排课窗口,刷新并选中新增分类教材,保留日期和陪练',async()=>{
+    page.selectStandard('word_self_study:60');page.openEnrollment();page.coachId.set(88);page.startTime.set('15:00');
+    (page.load as jasmine.Spy).and.callFake(async()=>{page.data.update(data=>({...data!,courses:[...data!.courses,course('self','word_self_study')]}));});
+    await page.enrollmentChanged();expect(page.enrollmentOpen()).toBeFalse();expect(page.bindingId()).toBe('self');expect(page.durationMinutes()).toBe(60);expect(page.coachId()).toBe(88);expect(page.startTime()).toBe('15:00');
+  });
+});

+ 50 - 10
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.ts

@@ -5,11 +5,13 @@ import { DropdownDirective } from '../shared/dropdown.directive';
 import { skip, Subscription } from 'rxjs';
 import { ActivatedRoute } from '@angular/router';
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, ViewChild, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, CircleAlert, LoaderCircle, LucideAngularModule, Plus, RefreshCw, RotateCcw, Search, X } from 'lucide-angular';
 import { ScheduleAppointment, ScheduleCalendarData } from '../admin.models';
 import { OperationsDateFilterComponent } from '../components/operations-date-filter.component';
+import { MemberEnrollmentComponent } from '../components/member-enrollment.component';
+import { SCHEDULE_COURSE_STANDARDS } from './schedule-course-standards';
 import { OperationsService } from '../operations.service';
 import { OperationsLiveRefreshService } from '../operations-live-refresh.service';
 import { ScheduleStatusFilter, ScheduleStatusTone, scheduleMatchesStatus, scheduleStatusLabel, scheduleStatusTone } from './operations-schedule-status';
@@ -128,7 +130,7 @@ export function scheduleCoachBusy(
   });
 }
 
-@Component({ selector:'app-operations-schedule', host:{class:'schedule-ui'}, standalone:true, imports: [LessonRecordDetailComponent,AnchoredMenuDirective, DropdownDirective, CommonModule,FormsModule,LucideAngularModule,OperationsDateFilterComponent], templateUrl:'./operations-schedule.component.html', styleUrls:['./operations-professional.scss','./operations-schedule.component.scss'], changeDetection:ChangeDetectionStrategy.OnPush })
+@Component({ selector:'app-operations-schedule', host:{class:'schedule-ui'}, standalone:true, imports: [MemberEnrollmentComponent,LessonRecordDetailComponent,AnchoredMenuDirective, DropdownDirective, CommonModule,FormsModule,LucideAngularModule,OperationsDateFilterComponent], templateUrl:'./operations-schedule.component.html', styleUrls:['./operations-professional.scss','./operations-schedule.component.scss'], changeDetection:ChangeDetectionStrategy.OnPush })
 export class OperationsScheduleComponent implements OnInit, OnDestroy {
   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 lessonAppointmentId=signal(''); readonly feedback=signal<Record<string,unknown>|null>(null); readonly detail=signal<ScheduleAppointment|null>(null); readonly cancelReason=signal(''); readonly detailError=signal(''); 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 bindingId=signal(''); private readonly route=inject(ActivatedRoute); readonly studentId=signal(0); readonly courseId=signal(0); readonly coachId=signal(0); readonly classType=signal(1); readonly durationMinutes=signal(30); 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 checking=signal(false);
@@ -141,14 +143,22 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
   readonly hasScheduleFilters=computed(()=>Boolean(this.scheduleSearch().trim())||this.statusFilter()!=='all');
   readonly filteredMembers=computed(()=>(this.data()?.members||[]).filter(member=>matchesScheduleSearch(this.memberSearch(),[member.displayName,member.mobileMasked,member.userId])).slice(0,12));
   readonly studentCourses=computed(()=>scheduleCoursesForStudent(this.data()?.courses||[],this.studentId()));
-  readonly filteredCourses=computed(()=>this.studentCourses().filter(course=>matchesScheduleSearch(this.courseSearch(),[course.courseName,course.categoryName,course.courseId])).slice(0,12));
+  readonly courseStandards=SCHEDULE_COURSE_STANDARDS;
+  readonly categoryKey=signal('word');
+  readonly standardKey=computed(()=>`${this.categoryKey()}:${this.durationMinutes()}`);
+  readonly standardLabel=computed(()=>this.courseStandards.find(row=>row.key===this.standardKey())?.label||'当前课型');
+  readonly categoryCourses=computed(()=>this.studentCourses().filter(course=>course.categoryKey===this.categoryKey()));
+  readonly filteredCourses=computed(()=>this.categoryCourses().filter(course=>matchesScheduleSearch(this.courseId()?'':this.courseSearch(),[course.courseName,course.categoryName,course.courseId])));
+  readonly enrollmentOpen=signal(false);
+  @ViewChild(MemberEnrollmentComponent) enrollment?:MemberEnrollmentComponent;
   readonly selectedCourse=computed(()=>this.data()?.courses.find((course)=>course.courseId===this.courseId()&&(!this.bindingId()||course.bindingId===this.bindingId())&&scheduleCourseAvailable(course,this.studentId()))||null);
+  readonly courseSelectionValid=computed(()=>{const course=this.selectedCourse();return Boolean(course?.mappingConfirmed&&course.categoryKey===this.categoryKey()&&course.supportedDurations?.includes(this.durationMinutes()));});
   readonly filteredCoaches=computed(()=>(this.data()?.coaches||[]).filter(coach=>matchesScheduleSearch(this.coachSearch(),[coach.displayName,coach.mobile,coach.mobileMasked,coach.userId])).sort((a,b)=>this.mode()==='time'?(Number(this.coachBusyAtSelectedTime(a.userId))-Number(this.coachBusyAtSelectedTime(b.userId)))||(Number(b.availabilityConfigured)-Number(a.availabilityConfigured))||a.displayName.localeCompare(b.displayName,'zh-CN'):a.displayName.localeCompare(b.displayName,'zh-CN')).slice(0,12));
   readonly previewCandidates=computed(()=>schedulePreflightCandidates(this.preview()));
   private preflightRequest=0; private previewFingerprint=''; private initialRange=true; private saveKey=''; private saveFingerprint=''; private stopLiveRefresh?:()=>void; private loadRequest=0;
   previewConflictCount():number{return Number(this.preview()?.['conflictCount']||0);}
   previewAvailableCount():number{return Number(this.preview()?.['availableCount']||0);}
-  canSavePreview():boolean{return !this.checking()&&this.previewFingerprint===JSON.stringify([this.editing()?.targetId||'',this.payload()])&&Boolean(this.preview())&&(!this.previewConflictCount()||(this.previewCandidates().length>1&&this.previewAvailableCount()>0));}
+  canSavePreview():boolean{return this.courseSelectionValid()&&!this.enrollmentOpen()&&!this.checking()&&this.previewFingerprint===JSON.stringify([this.editing()?.targetId||'',this.payload()])&&Boolean(this.preview())&&(!this.previewConflictCount()||(this.previewCandidates().length>1&&this.previewAvailableCount()>0));}
   saveButtonLabel():string{if(this.saving())return'保存中…';if(!this.preview())return'请先检查冲突';if(this.previewConflictCount()&&this.previewCandidates().length>1&&this.previewAvailableCount()>0)return`仅保存 ${this.previewAvailableCount()} 节无冲突排课`;return this.editing()?'确认修改':'确认保存';}
   statusTone(item:ScheduleAppointment):ScheduleStatusTone{return scheduleStatusTone(item);}
   statusDisplayLabel(item:ScheduleAppointment):string{return scheduleStatusLabel(item);}
@@ -221,9 +231,9 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
   async applyScheduleFilters():Promise<void>{if(this.searching())return;this.searching.set(true);this.scheduleSearch.set(this.scheduleSearchInput().trim());this.statusFilter.set(this.statusFilterInput());await new Promise(resolve=>setTimeout(resolve,220));this.searching.set(false);}
   clearScheduleFilters():void{this.scheduleSearchInput.set('');this.statusFilterInput.set('all');this.scheduleSearch.set('');this.statusFilter.set('all');this.searching.set(false);}
   visibleCoachCount(coachId:number):number{return this.visibleAppointments().filter(item=>item.coachId===coachId).length;}
-  openCreate():void{++this.preflightRequest;this.checking.set(false);this.error.set('');this.editing.set(null);this.studentId.set(0);this.courseId.set(0);this.bindingId.set('');this.coachId.set(0);this.durationMinutes.set(30);this.deliveryMethod.set('线上');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.preflightRequest;this.checking.set(false);this.error.set('');if(item.readOnly){this.message.set('这是教师端实时业务数据,目前可在后台查看;修改、取消和完课仍请在原业务端操作。');return;}this.editing.set(item);this.recurrence.set('once');this.occurrences.set(1);this.studentId.set(item.studentId);this.courseId.set(item.courseId);this.bindingId.set(item.bindingId||'');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.durationMinutes.set(item.durationMinutes||(item.classType===2||item.classType===3?60:30));const start=new Date(item.startsAt);this.date.set(start.toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}));this.startTime.set(start.toLocaleTimeString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false,hour:'2-digit',minute:'2-digit'}));this.deliveryMethod.set(item.deliveryMethod||'线上');this.preview.set(null);this.drawerOpen.set(true);}
-  closeDrawer():void{++this.preflightRequest;this.checking.set(false);this.activeSearch.set(null);this.drawerOpen.set(false);}
+  openCreate():void{++this.preflightRequest;this.checking.set(false);this.error.set('');this.editing.set(null);this.enrollmentOpen.set(false);this.categoryKey.set('word');this.recurrence.set('once');this.occurrences.set(1);this.studentId.set(0);this.courseId.set(0);this.bindingId.set('');this.coachId.set(0);this.durationMinutes.set(30);this.deliveryMethod.set('线上');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.preflightRequest;this.checking.set(false);this.error.set('');if(item.readOnly){this.message.set('这是教师端实时业务数据,目前可在后台查看;修改、取消和完课仍请在原业务端操作。');return;}this.enrollmentOpen.set(false);this.editing.set(item);this.recurrence.set('once');this.occurrences.set(1);this.studentId.set(item.studentId);this.courseId.set(item.courseId);this.bindingId.set(item.bindingId||'');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.categoryKey.set(item.courseCategoryKey||this.selectedCourse()?.categoryKey||'');this.classType.set(item.classType);this.durationMinutes.set(item.durationMinutes||(item.classType===2||item.classType===3?60:30));const start=new Date(item.startsAt);this.date.set(start.toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}));this.startTime.set(start.toLocaleTimeString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false,hour:'2-digit',minute:'2-digit'}));this.deliveryMethod.set(item.deliveryMethod||'线上');this.preview.set(null);this.drawerOpen.set(true);}
+  closeDrawer():void{if(this.saving()||this.enrollment?.saving()||this.loading())return;this.enrollmentOpen.set(false);++this.preflightRequest;this.checking.set(false);this.activeSearch.set(null);this.drawerOpen.set(false);}
   async viewFeedback(item:ScheduleAppointment):Promise<void>{this.lessonAppointmentId.set(String(item.generalId));}
   async viewWorkflowIssue(targetId:string):Promise<void>{this.error.set('');try{this.feedback.set(await this.operations.request<Record<string,unknown>>('ops/lesson-feedback/detail',{targetId}));}catch(error){this.error.set(error instanceof Error?error.message:'异常详情加载失败');}}
   closeFeedback():void{this.feedback.set(null);}
@@ -232,8 +242,37 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
   updateMemberSearch(value:string):void{this.memberSearch.set(value);this.studentId.set(0);this.selectedMemberObjectId.set('');this.courseId.set(0);this.bindingId.set('');this.courseSearch.set('');this.preview.set(null);this.openSearch('member');}
   updateCourseSearch(value:string):void{this.courseSearch.set(value);this.courseId.set(0);this.bindingId.set('');this.preview.set(null);this.openSearch('course');}
   updateCoachSearch(value:string):void{this.coachSearch.set(value);this.coachId.set(0);this.preview.set(null);this.openSearch('coach');}
-  chooseMember(member:ScheduleCalendarData['members'][number]):void{if(member.readOnly){this.error.set(member.readOnlyReason||'该会员尚未完成持续同步,当前只能查看实时资料,暂不能从新后台排课。');return;}this.studentId.set(member.userId);this.selectedMemberObjectId.set(member.objectId);this.memberSearch.set(`${member.displayName} · ${member.mobileMasked}`);this.courseId.set(0);this.bindingId.set('');this.courseSearch.set('');const courses=scheduleCoursesForStudent(this.data()?.courses||[],member.userId);if(courses.length===1){this.chooseCourse(courses[0]);if(courses[0].mappingConfirmed)this.message.set(`已自动选择该学员唯一的已开课程:${courses[0].courseName}`);}else{this.activeSearch.set(courses.length?'course':null);}this.preview.set(null);}
-  chooseCourse(course:ScheduleCalendarData['courses'][number]):void{this.courseId.set(course.courseId);this.bindingId.set(course.bindingId||'');this.courseSearch.set(course.courseName);const first=course.supportedDurations?.[0];if(first)this.durationMinutes.set(first);this.activeSearch.set(null);this.preview.set(null);if(!course.mappingConfirmed)this.message.set('该开课分类缺少有效课时规则,请在排课与工资标准中配置。');}
+  chooseMember(member:ScheduleCalendarData['members'][number]):void{if(member.readOnly){this.error.set(member.readOnlyReason||'该会员尚未完成持续同步,当前只能查看实时资料,暂不能从新后台排课。');return;}this.studentId.set(member.userId);this.selectedMemberObjectId.set(member.objectId);this.memberSearch.set(`${member.displayName} · ${member.mobileMasked}`);this.courseId.set(0);this.bindingId.set('');this.courseSearch.set('');const courses=scheduleCoursesForStudent(this.data()?.courses||[],member.userId);if(courses.length===1){this.chooseCourse(courses[0]);if(courses[0].mappingConfirmed)this.message.set(`已自动选择该学员唯一的已开课程:${courses[0].courseName}`);}else{this.selectStandard(this.standardKey());}this.preview.set(null);}
+  chooseCourse(course:ScheduleCalendarData['courses'][number]):void{
+    if(!scheduleCourseAvailable(course,this.studentId()))return;
+    const sameCategory=course.categoryKey===this.categoryKey();
+    this.categoryKey.set(course.categoryKey||'');this.courseId.set(course.courseId);this.bindingId.set(course.bindingId||'');this.courseSearch.set(course.courseName);
+    if(!sameCategory&&!course.supportedDurations?.includes(this.durationMinutes()))this.durationMinutes.set(course.supportedDurations?.[0]||30);
+    this.activeSearch.set(null);this.preview.set(null);
+  }
+  standardAvailability(categoryKey:string):string{
+    if(!this.studentId())return '';
+    const count=this.studentCourses().filter(course=>course.categoryKey===categoryKey).length;
+    return count?`已开 ${count} 门教材`:'待开课';
+  }
+  selectStandard(key:string):void{
+    const standard=this.courseStandards.find(row=>row.key===key);if(!standard)return;
+    const previous=this.selectedCourse();
+    this.categoryKey.set(standard.categoryKey);this.durationMinutes.set(standard.duration);this.error.set('');this.preview.set(null);
+    const courses=this.categoryCourses();
+    const course=courses.find(row=>previous&&row.bindingId===previous.bindingId&&row.courseId===previous.courseId)||(courses.length===1?courses[0]:null);
+    if(course)this.chooseCourse(course);
+    else{this.courseId.set(0);this.bindingId.set('');this.courseSearch.set('');this.activeSearch.set(null);}
+  }
+  openEnrollment():void{if(!this.studentId())return;this.preview.set(null);this.activeSearch.set(null);this.enrollmentOpen.set(true);}
+  closeEnrollment():void{if(this.enrollment?.saving()||this.loading())return;this.enrollmentOpen.set(false);}
+  async enrollmentChanged():Promise<void>{
+    const category=this.enrollment?.category||this.categoryKey();
+    await this.load();if(this.error())return;
+    const standard=this.courseStandards.find(row=>row.categoryKey===category&&row.duration===this.durationMinutes())||this.courseStandards.find(row=>row.categoryKey===category);
+    if(standard)this.selectStandard(standard.key);
+    this.enrollmentOpen.set(false);this.message.set('开课资料已更新,请选择教材并检查排课冲突');
+  }
   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);}
   updateDraftDate(value:string):void{if(!/^\d{4}-\d{2}-\d{2}$/.test(value))return;this.date.set(value);this.preview.set(null);void this.load();}
@@ -245,10 +284,11 @@ export class OperationsScheduleComponent implements OnInit, OnDestroy {
   memberMobile(userId:number):string{return this.data()?.members.find(member=>member.userId===userId)?.mobileMasked||'';}
   payload():Record<string,unknown>{return{bindingId:this.bindingId(),studentId:this.studentId(),courseId:this.courseId(),coachId:this.coachId(),classType:this.classType(),durationMinutes:this.durationMinutes(),deliveryMethod:this.deliveryMethod(),deliveryMode:this.deliveryMethod()==='线下'?'offline':'online',date:this.date(),startTime:this.startTime(),recurrence:this.recurrence(),occurrences:this.occurrences()};}
   async preflight():Promise<void>{
-    if(this.checking()||this.saving())return;
+    if(this.checking()||this.saving()||this.enrollmentOpen())return;
     this.error.set('');this.preview.set(null);this.previewFingerprint='';
     if(!this.selectedMemberObjectId()||!this.courseId()){this.error.set('请先选择会员和课程');return;}
     if(!this.studentId()){this.error.set('该会员缺少排课编号,暂时不能排课');return;}
+    if(!this.courseSelectionValid()){this.error.set('请选择该课型下的有效已开教材,并确认所选时长有可用规则');return;}
     const request=++this.preflightRequest,targetId=this.editing()?.targetId||'',payload=this.payload(),fingerprint=JSON.stringify([targetId,payload]);
     this.checking.set(true);
     try{

+ 14 - 0
projects/xiaoshu-admin/src/app/pages/schedule-course-standards.ts

@@ -0,0 +1,14 @@
+// The fixed teaching catalog. Availability still comes from the student's
+// active bindings; the server validates dated rules and credit reservations.
+export const SCHEDULE_COURSE_STANDARDS = [
+  { key: 'word:30', categoryKey: 'word', label: '单词课', duration: 30 },
+  { key: 'word:60', categoryKey: 'word', label: '单词课', duration: 60 },
+  { key: 'trial:60', categoryKey: 'trial', label: '体验课', duration: 60 },
+  { key: 'primary_writing:30', categoryKey: 'primary_writing', label: '小学语法写作', duration: 30 },
+  { key: 'middle_writing:30', categoryKey: 'middle_writing', label: '初中语法写作', duration: 30 },
+  { key: 'high_writing:30', categoryKey: 'high_writing', label: '高中语法写作', duration: 30 },
+  { key: 'primary_writing:60', categoryKey: 'primary_writing', label: '小学语法写作', duration: 60 },
+  { key: 'middle_writing:60', categoryKey: 'middle_writing', label: '初中语法写作', duration: 60 },
+  { key: 'high_writing:60', categoryKey: 'high_writing', label: '高中语法写作', duration: 60 },
+  { key: 'word_self_study:60', categoryKey: 'word_self_study', label: '单词自学课', duration: 60 },
+];