operations-schedule.component.html 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. @if(data()?.workflowIssues?.length){
  2. <section class="notice error" aria-label="结课异常清单"><strong>结课凭据待核对(最近 200 条)</strong>
  3. @for(issue of data()?.workflowIssues; track issue.appointmentGeneralId){<p>预约 {{issue.appointmentGeneralId}}:{{issue.message}} <button type="button" (click)="viewWorkflowIssue(issue.targetId||issue.appointmentGeneralId)">查看凭据</button></p>}
  4. </section>
  5. }
  6. <header class="page-header">
  7. <div><p>SCHEDULING CENTER</p><h1>排课中心</h1><span>按陪练或时间快速排课,并在提交前发现冲突</span></div>
  8. <div class="header-actions">
  9. <button type="button" (click)="load()" [disabled]="loading()"><lucide-icon [img]="loading()?icons.LoaderCircle:icons.RefreshCw" [size]="17" [class.spin]="loading()" />{{loading()?'正在加载…':'刷新'}}</button>
  10. <button class="primary" type="button" (click)="openCreate()"><lucide-icon [img]="icons.Plus" [size]="17" />新增排课</button>
  11. </div>
  12. </header>
  13. <section class="toolbar">
  14. <div class="segmented">
  15. <button type="button" [class.active]="view()==='day'" (click)="view.set('day');load()">日</button>
  16. <button type="button" [class.active]="view()==='week'" (click)="view.set('week');load()">周</button>
  17. <button type="button" [class.active]="view()==='list'" (click)="view.set('list');load()">列表</button>
  18. </div>
  19. <button type="button" aria-label="上一时间段" (click)="move(view()==='week'?-7:-1)"><lucide-icon [img]="icons.ChevronLeft" [size]="17" /></button>
  20. <app-operations-date-filter class="date-filter" label="日期" [value]="date()" (valueChange)="updateDraftDate($event)" />
  21. <button type="button" aria-label="下一时间段" (click)="move(view()==='week'?7:1)"><lucide-icon [img]="icons.ChevronRight" [size]="17" /></button>
  22. <label class="coach-filter"><span>陪练筛选</span><select [ngModel]="coachFilter()" (ngModelChange)="coachFilter.set(+$event);load()"><option [ngValue]="0">全部陪练</option>@for(coach of data()?.coaches||[];track coach.userId){<option [ngValue]="coach.userId">{{coach.displayName}}</option>}</select></label>
  23. @if(data()?.dataDateTo){<button class="latest-data" type="button" (click)="showLatest()">最近有数据:{{data()?.dataDateTo}}</button>}
  24. </section>
  25. @if(loading()){
  26. <section class="schedule-loading" role="status" aria-live="polite" aria-atomic="true" data-testid="schedule-loading">
  27. <span class="loading-icon" aria-hidden="true"><lucide-icon [img]="icons.LoaderCircle" [size]="27" /></span>
  28. <div>
  29. <strong>{{data()?'正在更新排课数据':'正在加载排课数据'}}</strong>
  30. <span>正在读取所选日期、陪练和预约状态,数据较多时请稍候。</span>
  31. </div>
  32. <span class="loading-track" aria-hidden="true"><i></i></span>
  33. </section>
  34. }
  35. <section class="schedule-filterbar" aria-label="排课记录筛选">
  36. <label class="schedule-search"><span>搜索排课</span><span class="filter-control"><lucide-icon [img]="icons.Search" [size]="16"/><input type="search" [ngModel]="scheduleSearchInput()" (ngModelChange)="scheduleSearchInput.set($event)" (keydown.enter)="applyScheduleFilters()" placeholder="学员、陪练、课程或业务ID"/></span></label>
  37. <label class="status-filter"><span>业务状态</span><select [ngModel]="statusFilterInput()" (ngModelChange)="statusFilterInput.set($event)"><option value="all">全部状态</option><option value="upcoming">待开始</option><option value="live">上课中</option><option value="coach-ended">陪练已结束</option><option value="confirmed">学员已确认</option><option value="completed">已完课</option><option value="archived">已归档</option><option value="overdue">逾期未完成</option><option value="unverified">状态待核对</option><option value="cancelled">已取消</option><option value="unassigned">待分配 / 账号异常</option></select></label>
  38. <div class="filter-actions">
  39. <button class="find-button" type="button" (click)="applyScheduleFilters()" [disabled]="searching()"><lucide-icon [img]="searching()?icons.LoaderCircle:icons.Search" [size]="16" [class.spin]="searching()" />{{searching()?'正在查找':'查找'}}</button>
  40. <button class="reset-button" type="button" (click)="clearScheduleFilters()" [disabled]="searching()"><lucide-icon [img]="icons.RotateCcw" [size]="15" />重置</button>
  41. </div>
  42. <div class="filter-result" role="status"><strong>{{visibleAppointments().length}}</strong><span>条筛选结果</span><small>当前范围共 {{data()?.appointments?.length||0}} 条</small></div>
  43. </section>
  44. @if(message()){<p class="notice" role="status">{{message()}}</p>}
  45. @if(error()){<p class="notice error" role="alert">{{error()}}</p>}
  46. @if(data()&&!data()?.liveSourceAvailable){<p class="notice error" role="status">{{data()?.liveSourceMessage}}</p>}
  47. @if(data()?.source!=='database'&&data()?.sync?.state!=='synced'&&data()){<p class="notice error" role="status">旧系统持久化同步尚未追平。{{data()?.liveSourceAvailable?'本页已读取旧系统当前排课':'本页暂时无法核对旧系统当前排课'}};刷新页面不等于新数据库已同步,未核实记录不能据此认定缺课或结算工资。</p>}
  48. <section class="schedule-workflow" aria-label="预约到完课业务流程">
  49. <header>
  50. <div><strong>预约到完课流程</strong><span>排课、上课、结课与评价</span></div>
  51. <small>老师一次结课,学生随后评价</small>
  52. </header>
  53. <ol>
  54. <li><i>1</i><div><strong>运营创建预约</strong><span>选择学员已绑定课程、时间和陪练</span></div><em>状态 0</em></li>
  55. <li><i>2</i><div><strong>陪练开始上课</strong><span>预约当天均可开始,记录实际上课时间</span></div><em>上课中</em></li>
  56. <li><i>3</i><div><strong>老师结课并生成报告</strong><span>课堂结果、反馈与课时扣减一次完成</span></div><em>已结课</em></li>
  57. <li><i>4</i><div><strong>学生评价</strong><span>学生查看报告后评分;未评价不阻塞结课</span></div><em>评价独立</em></li>
  58. </ol>
  59. </section>
  60. <section class="summary-grid">
  61. <article class="summary-card"><span>当前范围课程</span><strong>{{data()?activeAppointments():'—'}}</strong><small>{{range().from}} 至 {{range().to}}</small></article>
  62. <article class="summary-card"><span>待分配</span><strong>{{data()?unassignedCount():'—'}}</strong><small>未分配老师时不能开始</small></article>
  63. <article class="summary-card"><span>在岗陪练</span><strong>{{data()?.coaches?.length??'—'}}</strong><small>未配置可用时间会提示</small></article>
  64. <article class="summary-card"><span>历史排课总量</span><strong>{{data()?.totalAppointments??'—'}}</strong><small>{{data()?.dataDateFrom||'—'}} 至 {{data()?.dataDateTo||'—'}}</small></article>
  65. <article class="summary-card"><span>数据库更新</span><strong>{{data()?.refreshedAt|date:'MM-dd HH:mm:ss'}}</strong><small>新排课程、教学状态以新库为准</small></article>
  66. </section>
  67. <section class="content-card" [class.is-loading]="loading()" [attr.aria-busy]="loading()">
  68. <header class="schedule-content-header">
  69. <div><strong>{{view()==='list'?'预约明细':'陪练排课泳道'}}</strong><span>{{loading()?'加载中…':'点击排课查看明细与可用操作'}}</span></div>
  70. <div class="status-legend" aria-label="排课状态图例">
  71. <span class="status-upcoming"><i></i>待开始</span>
  72. <span class="status-live"><i></i>上课中</span>
  73. <span class="status-coach-ended"><i></i>陪练已结束</span>
  74. <span class="status-confirmed"><i></i>学员已确认</span>
  75. <span class="status-completed"><i></i>已完课</span>
  76. <span class="status-archived"><i></i>已归档</span>
  77. <span class="status-overdue"><i></i>逾期未完成</span>
  78. <span class="status-unverified"><i></i>状态待核对</span>
  79. <span class="status-cancelled"><i></i>已取消</span>
  80. </div>
  81. </header>
  82. @if(view()==='list'){
  83. <div class="table-scroll"><table class="data-table"><thead><tr><th>时间</th><th>会员</th><th>陪练</th><th>课程</th><th>课型</th><th>状态</th><th>操作</th></tr></thead><tbody>
  84. @for(item of visibleAppointments();track item.targetId){<tr><td>{{item.startsAt|date:'MM-dd HH:mm'}}–{{item.endsAt|date:'HH:mm'}}</td><td>{{item.studentName}}</td><td>{{item.coachName}}@if(item.coachAccountMissing){<small class="exception">历史陪练账号已失效</small>}</td><td>{{item.courseName}}</td><td>{{item.classTypeLabel}}@if(item.readOnly){<small class="live-source">旧系统数据</small>}</td><td><span class="badge status-badge" [ngClass]="statusTone(item)">{{statusDisplayLabel(item)}}</span></td><td><div class="row-actions"><button type="button" (click)="openDetails(item)">查看明细</button>@if(!item.readOnly){<button type="button" (click)="viewFeedback(item)">查看教学记录</button>}@if(item.readOnly){<span class="read-only-label">原业务端维护</span>}@else{@if(rowActions(item);as actions){@if(actions.canEdit){<button type="button" (click)="openEdit(item)">修改</button>}@if(actions.canRestore){<button type="button" (click)="action(item,'ops/appointments/restore')">恢复</button>}@if(actions.canCancel){<button type="button" (click)="openDetails(item)">取消</button>}@if(actions.canComplete){<button type="button" (click)="action(item,'ops/appointments/complete')">{{actions.completeLabel}}</button>}@if(!actions.canEdit&&!actions.canRestore&&!actions.canCancel&&!actions.canComplete&&item.statusCode<11){<span class="read-only-label">按业务端状态流转</span>}}}</div></td></tr>}
  85. @empty{<tr><td colspan="7"><div class="empty">{{error()?'课表未能加载,请重试':hasScheduleFilters()?'没有符合筛选条件的排课':'当前范围暂无排课;历史数据范围为 '+(data()?.dataDateFrom||'—')+' 至 '+(data()?.dataDateTo||'—')}}</div></td></tr>}
  86. </tbody></table></div>
  87. } @else {
  88. <div class="table-scroll"><div class="calendar">
  89. @for(coach of visibleCoaches();track coach.userId){<div class="calendar-row"><div class="coach-label"><strong>{{coach.displayName}}</strong><span>当前 {{visibleCoachCount(coach.userId)}} 节排课</span>@if(!coach.availabilityConfigured){<span class="exception">未设置可用时间</span>}</div><div class="appointment-strip">@for(item of visibleAppointments();track item.targetId){@if(item.coachId===coach.userId&&!item.coachAccountMissing){<button type="button" class="appointment" [ngClass]="statusTone(item)" (click)="openDetails(item)"><strong>{{item.startsAt|date:'MM-dd HH:mm'}} {{item.studentName}}</strong><span>{{item.courseName}}</span><small>{{item.classTypeLabel}} · {{statusDisplayLabel(item)}}@if(item.readOnly){ · 实时}</small></button>}}</div></div>}
  90. @empty{<div class="empty">{{error()?'课表未能加载,请重试':'当前范围暂无已排课的陪练'}}</div>}
  91. @if(unassignedCount()){<div class="calendar-row"><div class="coach-label"><strong>待分配 / 历史账号</strong><span>{{data()?unassignedCount():'—'}} 节</span></div><div class="appointment-strip">@for(item of visibleAppointments();track item.targetId){@if(!item.coachId||item.coachAccountMissing){<button type="button" class="appointment" [ngClass]="statusTone(item)" (click)="openDetails(item)"><strong>{{item.startsAt|date:'MM-dd HH:mm'}} {{item.studentName}}</strong><span>{{item.coachAccountMissing?item.coachName:'未分配陪练'}}</span><small>{{item.classTypeLabel}} · {{statusDisplayLabel(item)}}</small></button>}}</div></div>}
  92. </div></div>
  93. }
  94. </section>
  95. @if(detail();as item){
  96. <div class="backdrop" (click)="closeDetails()"></div>
  97. <aside class="drawer schedule-detail-drawer" role="dialog" aria-modal="true" aria-label="排课明细" data-testid="schedule-detail">
  98. <header><div><p>SCHEDULE DETAILS</p><h2>排课明细</h2><span>业务 ID {{item.generalId||'—'}}</span></div><button class="icon-button" type="button" aria-label="关闭明细" (click)="closeDetails()"><lucide-icon [img]="icons.X" [size]="20" /></button></header>
  99. <div class="schedule-detail-status"><span class="badge status-badge" [ngClass]="statusTone(item)">{{statusDisplayLabel(item)}}</span><span>{{item.sourceLabel||'排课记录'}}</span></div>
  100. <dl class="schedule-detail-facts">
  101. <div><dt>学员</dt><dd>{{item.studentName}} <small>ID {{item.studentId||'—'}}</small></dd></div>
  102. <div><dt>陪练</dt><dd>{{item.coachName}} <small>ID {{item.coachId||'未分配'}}</small></dd></div>
  103. <div><dt>课程</dt><dd>{{item.courseName}} <small>ID {{item.courseId||'—'}}</small></dd></div>
  104. <div><dt>上课时间</dt><dd>{{item.startsAt|date:'yyyy-MM-dd HH:mm':'+0800'}} – {{item.endsAt|date:'HH:mm':'+0800'}}</dd></div>
  105. <div><dt>课程标准</dt><dd>{{item.classTypeLabel}} · {{item.durationMinutes||'—'}} 分钟</dd></div>
  106. <div><dt>授课方式</dt><dd>{{item.deliveryMethod||'—'}}</dd></div>
  107. @if(item.cancelled&&item.cancellationReason){<div class="wide"><dt>取消原因</dt><dd>{{item.cancellationReason}}</dd></div>}
  108. </dl>
  109. @if(rowActions(item).canCancel){
  110. <div class="schedule-detail-cancel"><label for="schedule-cancel-reason">取消原因</label><textarea id="schedule-cancel-reason" rows="2" [ngModel]="cancelReason()" (ngModelChange)="cancelReason.set($event)" placeholder="填写取消原因后确认,操作将记录在业务日志中"></textarea></div>
  111. } @else if(item.readOnly){<p class="notice">这条排课来自旧系统实时数据。当前没有安全的旧系统写回通道,新后台暂不能取消;请在原业务端取消后重新同步。</p>}
  112. @if(detailError()){<p class="notice error" role="alert">{{detailError()}}</p>}
  113. <footer>@if(!item.readOnly){<button type="button" (click)="viewFeedback(item)">查看教学记录</button>}<button type="button" (click)="closeDetails()" [disabled]="saving()">关闭</button>@if(rowActions(item).canEdit){<button type="button" (click)="editFromDetails(item)">修改排课</button>}@if(rowActions(item).canCancel){<button class="danger-action" type="button" (click)="cancelFromDetails()" [disabled]="saving()">{{saving()?'正在取消…':'确认取消排课'}}</button>}</footer>
  114. </aside>
  115. }
  116. @if(drawerOpen()){
  117. <div class="backdrop" (click)="closeDrawer()"></div>
  118. <aside class="drawer" role="dialog" aria-modal="true" aria-label="排课编辑">
  119. <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>
  120. @if(error()){<p class="notice error" role="alert" data-testid="schedule-form-error">{{error()}}</p>}
  121. <p class="notice">课程在上课当天 00:00 至 24:00 有效,可当天提前或延后上课;预约时间和时长用于安排课程与检查冲突。</p>
  122. <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>
  123. <p class="mode-guidance"><strong>{{mode()==='coach'?'先确定老师':'先确定课程时间'}}</strong><span>{{mode()==='coach'?'先选日期和陪练,再选择学员;只有一门已开课程时会自动带出课程与标准':'先选学员、课程和上课时间,陪练候选会把当前时段无冲突的老师排在前面'}}</span></p>
  124. <div class="form-grid" [class.coach-first]="mode()==='coach'" [class.time-first]="mode()==='time'">
  125. <label class="search-field member-step">
  126. <span>选择学员 <small>可搜索姓名、手机号或用户ID</small></span>
  127. <span class="search-control" [class.selected]="selectedMemberObjectId()">
  128. <lucide-icon [img]="icons.Search" [size]="17" />
  129. <input type="search" autocomplete="off" placeholder="输入学员姓名或手机号码" [ngModel]="memberSearch()" (ngModelChange)="updateMemberSearch($event)" (focus)="openSearch('member')" (click)="openSearch('member')" #memberSearchInput role="combobox" aria-autocomplete="list" [attr.aria-expanded]="activeSearch()==='member'" aria-controls="member-search-results" />
  130. @if(selectedMemberObjectId()){<span class="selected-mark" aria-label="已选择"><lucide-icon [img]="icons.CheckCircle2" [size]="17" /></span>}
  131. </span>
  132. @if(activeSearch()==='member'){
  133. <span class="search-results" [xsAnchoredMenu]="memberSearchInput" (xsDismiss)="activeSearch.set(null)" id="member-search-results" role="listbox">
  134. @for(member of filteredMembers();track member.objectId){
  135. <button type="button" class="search-option" role="option" [class.read-only]="member.readOnly" [attr.aria-disabled]="member.readOnly" [attr.aria-selected]="selectedMemberObjectId()===member.objectId" (click)="chooseMember(member)"><span><strong>{{member.displayName}}</strong><small>{{member.mobileMasked}}@if(member.userId){ · ID {{member.userId}}}@if(member.readOnly){ · 同步中,仅查看}</small></span><em>{{member.learningStatusLabel}}</em></button>
  136. } @empty {<span class="search-empty">没有匹配的学员,请检查姓名或手机号</span>}
  137. </span>
  138. }
  139. </label>
  140. <label class="search-field course-step">
  141. <span>选择课程 <small>仅显示当前学员已绑定课程,支持模糊匹配</small></span>
  142. <span class="search-control" [class.selected]="courseId()">
  143. <lucide-icon [img]="icons.Search" [size]="17" />
  144. <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" />
  145. @if(courseId()){<span class="selected-mark" aria-label="已选择"><lucide-icon [img]="icons.CheckCircle2" [size]="17" /></span>}
  146. </span>
  147. @if(activeSearch()==='course'){
  148. <span class="search-results" [xsAnchoredMenu]="courseSearchInput" (xsDismiss)="activeSearch.set(null)" id="course-search-results" role="listbox">
  149. @for(course of filteredCourses();track course.bindingId||course.courseId){
  150. <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>
  151. } @empty {<span class="search-empty">{{studentId()?'该学员没有匹配的已绑定课程,请先在会员详情绑定课程':'请先选择学员,再选择其已绑定课程'}}</span>}
  152. </span>
  153. }
  154. </label>
  155. <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>
  156. <label class="delivery-step">交付方式<select [ngModel]="deliveryMethod()" (ngModelChange)="deliveryMethod.set($event);preview.set(null)"><option>线上</option><option>线下</option></select></label>
  157. <app-operations-date-filter class="date-step" label="日期" [value]="date()" (valueChange)="updateDraftDate($event)" />
  158. <label class="time-step">开始时间<input type="time" step="1800" [ngModel]="startTime()" (ngModelChange)="updateStartTime($event)" /></label>
  159. <label class="search-field wide coach-step">
  160. <span>选择陪练老师 <small>可搜索老师姓名或手机号码</small></span>
  161. <span class="search-control" [class.selected]="coachId()">
  162. <lucide-icon [img]="icons.Search" [size]="17" />
  163. <input type="search" autocomplete="off" placeholder="输入陪练老师姓名或手机号码,可暂不分配" [ngModel]="coachSearch()" (ngModelChange)="updateCoachSearch($event)" (focus)="openSearch('coach')" (click)="openSearch('coach')" #coachSearchInput role="combobox" aria-autocomplete="list" [attr.aria-expanded]="activeSearch()==='coach'" aria-controls="coach-search-results" />
  164. @if(coachId()){<span class="selected-mark" aria-label="已选择"><lucide-icon [img]="icons.CheckCircle2" [size]="17" /></span>}
  165. </span>
  166. @if(activeSearch()==='coach'){
  167. <span class="search-results" [xsAnchoredMenu]="coachSearchInput" (xsDismiss)="activeSearch.set(null)" id="coach-search-results" role="listbox">
  168. <button type="button" class="search-option muted-option" role="option" [attr.aria-selected]="!coachId()" (click)="clearCoach()"><span><strong>暂不分配</strong><small>保存为待分配预约</small></span></button>
  169. @for(coach of filteredCoaches();track coach.userId){
  170. <button type="button" class="search-option" [class.busy-option]="coachBusyAtSelectedTime(coach.userId)" 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>{{coachCandidateHint(coach)}}</em></button>
  171. } @empty {<span class="search-empty">没有匹配的陪练老师,请检查姓名或手机号</span>}
  172. </span>
  173. }
  174. </label>
  175. @if(!editing()){
  176. <label class="recurrence-step">重复方式<select [ngModel]="recurrence()" (ngModelChange)="recurrence.set($event);preview.set(null)"><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>
  177. <label class="occurrences-step">排课次数<input type="number" min="1" max="60" [ngModel]="occurrences()" (ngModelChange)="occurrences.set(+$event);preview.set(null)" /></label>
  178. }
  179. <label class="wide reason-step">操作原因<textarea rows="2" [ngModel]="reason()" (ngModelChange)="reason.set($event)"></textarea></label>
  180. </div>
  181. @if(preview();as result){
  182. <p class="notice" [class.error]="previewConflictCount()>0">预检完成:可排 {{result['availableCount']||0}} 节,冲突 {{result['conflictCount']||0}} 节。@if(result['issueCount']&&result['issueCount']!==result['conflictCount']){共发现 {{result['issueCount']}} 个冲突原因。}{{result['warnings']||''}}</p>
  183. <div class="preflight-results" aria-label="批量排课逐项预检结果">
  184. @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>}
  185. </div>
  186. }
  187. <footer><button type="button" (click)="preflight()" [disabled]="saving()||checking()">{{checking()?'正在检查…':'检查冲突'}}</button><button class="primary" type="button" (click)="save()" [disabled]="saving()||!canSavePreview()">{{saveButtonLabel()}}</button></footer>
  188. </aside>
  189. }
  190. @if(feedback();as report){
  191. <div class="backdrop" (click)="closeFeedback()"></div>
  192. <aside class="drawer feedback-drawer" role="dialog" aria-modal="true" aria-label="完课反馈详情">
  193. <header><div><p>LESSON FEEDBACK</p><h2>完课反馈</h2><span>预约 {{report['appointmentGeneralId']}} · {{report['courseCategoryName']||'历史课程'}}</span></div><button class="icon-button" type="button" aria-label="关闭" (click)="closeFeedback()"><lucide-icon [img]="icons.X" [size]="20" /></button></header>
  194. <section class="feedback-facts"><article><span>计划上课</span><strong>{{report['plannedAt']}}</strong></article><article><span>实际上课</span><strong>{{$any(report['actualStartAt'])|date:'yyyy-MM-dd HH:mm'}} – {{$any(report['actualEndAt'])|date:'HH:mm'}}</strong></article><article><span>课程标准</span><strong>{{report['courseCategoryName']||'未映射'}} · {{report['durationMinutes']||'—'}}分钟 · {{report['deliveryLabel']}}</strong></article><article><span>生词总量</span><strong>{{report['vocabularyCount']||0}}</strong></article></section>
  195. <section class="feedback-sections"><article><span>预约编号</span><p>{{report['appointmentGeneralId']}}</p></article><article><span>课时处理</span><p>{{report['creditState']}} · 有效记录 {{report['lessonCount']}} 条</p></article><article><span>学生评价</span><p>{{$any(report['studentReview'])?.score ? $any(report['studentReview']).score+' 分' : '尚未评价'}} {{$any(report['studentReview'])?.comment}}</p></article>@if(report['issue']){<article role="alert"><span>待核对异常</span><p>{{report['issue']}}</p></article>}@if(!report['hasReport']){<article><p>尚未生成完课报告</p></article>}<article><span>1. 学习内容</span><p>{{report['contentSummary']||'—'}}</p></article><article><span>2. 复习 / 生词本</span><strong>{{report['reviewStatus']}}</strong><p>{{report['reviewSummary']||'无补充说明'}}</p></article><article><span>3. 训练进程</span><strong>{{report['trainingStatus']}}</strong><p>{{report['trainingSummary']}}</p></article><article><span>4. 当日抗遗忘</span><p>应复习 {{$any(report['antiForgetSummary'])?.total||0}} · 已完成 {{$any(report['antiForgetSummary'])?.completed||0}} · 待完成 {{$any(report['antiForgetSummary'])?.pending||0}}</p></article><article><span>5. 作业安排</span><p>{{report['homework']||'无作业'}}</p></article><article><span>老师点评</span><p>{{report['coachComment']||'无'}}</p></article><article><span>6. 备注</span><p>{{report['notes']||'无'}}</p></article></section>
  196. </aside>
  197. }