Просмотр исходного кода

fix(admin): streamline course selection when scheduling

彭峰 4 недель назад
Родитель
Сommit
cdc0998e85

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

@@ -98,7 +98,7 @@
   <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>
     <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>
@@ -119,7 +119,7 @@
         <span>选择课程 <small>仅显示当前学员已绑定课程,支持模糊匹配</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()?'输入课程名称关键词':'请先选择学员'" [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()?'请先选择学员':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" />
           @if(courseId()){<span class="selected-mark" aria-label="已选择"><lucide-icon [img]="icons.CheckCircle2" [size]="17" /></span>}
         </span>
         @if(activeSearch()==='course'){

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

@@ -683,11 +683,11 @@ textarea:focus-visible {
 
 .form-grid.coach-first .date-step { order: 1; }
 .form-grid.coach-first .time-step { order: 2; }
-.form-grid.coach-first .type-step { order: 3; }
-.form-grid.coach-first .delivery-step { order: 4; }
-.form-grid.coach-first .coach-step { order: 5; }
-.form-grid.coach-first .member-step { order: 6; }
-.form-grid.coach-first .course-step { order: 7; }
+.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 .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; }

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

@@ -1,6 +1,6 @@
 import { TestBed } from '@angular/core/testing';
 import { ActivatedRoute, convertToParamMap } from '@angular/router';
-import { matchesScheduleSearch, OperationsScheduleComponent, scheduleCoachBusy, scheduleCoachesWithAppointments, scheduleCourseAvailable, schedulePreflightCandidates, scheduleRowActions } from './operations-schedule.component';
+import { matchesScheduleSearch, OperationsScheduleComponent, scheduleCoachBusy, scheduleCoachesWithAppointments, scheduleCourseAvailable, scheduleCoursesForStudent, schedulePreflightCandidates, scheduleRowActions } from './operations-schedule.component';
 import { ScheduleAppointment, ScheduleCalendarData } from '../admin.models';
 import { OperationsService } from '../operations.service';
 import { scheduleMatchesStatus, scheduleStatusLabel, scheduleStatusTone } from './operations-schedule-status';
@@ -108,6 +108,15 @@ describe('会员课程归属', () => {
   it('保留原系统写作课程 398 的全员可选兼容规则', () => {
     expect(scheduleCourseAvailable({ memberIds: [], availableToAll: true }, 303)).toBeTrue();
   });
+
+  it('按学员返回完整的已开课程选项,供唯一课程自动带出', () => {
+    const courses = [
+      { bindingId: 'a', courseId: 80, courseName: '人教版', memberIds: [1845] },
+      { bindingId: 'b', courseId: 81, courseName: '外研版', memberIds: [2000] },
+    ] as ScheduleCalendarData['courses'];
+    expect(scheduleCoursesForStudent(courses, 1845).map((course) => course.bindingId)).toEqual(['a']);
+    expect(scheduleCoursesForStudent(courses, 0)).toEqual([]);
+  });
 });
 
 describe('时间优先陪练候选', () => {

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

@@ -55,6 +55,13 @@ export function scheduleCourseAvailable(
   return course.availableToAll === true || (course.memberIds || []).includes(studentId);
 }
 
+export function scheduleCoursesForStudent(
+  courses: ScheduleCalendarData['courses'],
+  studentId: number,
+): ScheduleCalendarData['courses'] {
+  return courses.filter((course) => scheduleCourseAvailable(course, studentId));
+}
+
 export function scheduleCoachesWithAppointments(
   coaches: ScheduleCalendarData['coaches'],
   appointments: ScheduleAppointment[],
@@ -129,7 +136,8 @@ export class OperationsScheduleComponent implements OnInit {
   readonly unassignedCount=computed(()=>this.visibleAppointments().filter(item=>!item.coachId||item.coachAccountMissing).length);
   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 filteredCourses=computed(()=>(this.data()?.courses||[]).filter(course=>scheduleCourseAvailable(course,this.studentId())&&matchesScheduleSearch(this.courseSearch(),[course.courseName,course.categoryName,course.courseId])).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 selectedCourse=computed(()=>this.data()?.courses.find((course)=>course.courseId===this.courseId()&&(!this.bindingId()||course.bindingId===this.bindingId())&&scheduleCourseAvailable(course,this.studentId()))||null);
   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()));
@@ -159,7 +167,7 @@ export class OperationsScheduleComponent implements OnInit {
   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.openSearch('course');}
   updateCoachSearch(value:string):void{this.coachSearch.set(value);this.coachId.set(0);this.openSearch('coach');}
-  chooseMember(member:ScheduleCalendarData['members'][number]):void{if(member.readOnly){this.error.set(member.readOnlyReason||'该会员尚未完成持续同步,当前只能查看实时资料,暂不能从新后台排课。');return;}const selectedCourse=this.data()?.courses.find(course=>course.courseId===this.courseId()&&course.bindingId===this.bindingId());this.studentId.set(member.userId);this.selectedMemberObjectId.set(member.objectId);this.memberSearch.set(`${member.displayName} · ${member.mobileMasked}`);if(selectedCourse&&!scheduleCourseAvailable(selectedCourse,member.userId)){this.courseId.set(0);this.bindingId.set('');this.courseSearch.set('');}this.activeSearch.set(null);this.preview.set(null);}
+  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('该开课分类缺少有效课时规则,请在排课与工资标准中配置。');}
   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);}