Procházet zdrojové kódy

feat(admin): complete operations management updates

彭峰 před 1 měsícem
rodič
revize
5544d26e23
23 změnil soubory, kde provedl 769 přidání a 68 odebrání
  1. 101 1
      projects/xiaoshu-admin/src/app/admin.models.ts
  2. 40 0
      projects/xiaoshu-admin/src/app/components/operations-date-filter.component.ts
  3. 5 1
      projects/xiaoshu-admin/src/app/operations.service.ts
  4. 5 5
      projects/xiaoshu-admin/src/app/pages/operations-coaches.component.html
  5. 126 0
      projects/xiaoshu-admin/src/app/pages/operations-coaches.component.scss
  6. 6 5
      projects/xiaoshu-admin/src/app/pages/operations-coaches.component.ts
  7. 15 4
      projects/xiaoshu-admin/src/app/pages/operations-learning-reports.component.html
  8. 0 0
      projects/xiaoshu-admin/src/app/pages/operations-learning-reports.component.scss
  9. 78 0
      projects/xiaoshu-admin/src/app/pages/operations-learning-reports.component.spec.ts
  10. 85 4
      projects/xiaoshu-admin/src/app/pages/operations-learning-reports.component.ts
  11. 22 2
      projects/xiaoshu-admin/src/app/pages/operations-payroll.component.html
  12. 39 0
      projects/xiaoshu-admin/src/app/pages/operations-payroll.component.scss
  13. 1 2
      projects/xiaoshu-admin/src/app/pages/operations-payroll.component.ts
  14. 21 10
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.html
  15. 55 12
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.scss
  16. 18 2
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.spec.ts
  17. 31 12
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.ts
  18. 5 5
      projects/xiaoshu-admin/src/app/pages/operations-users.component.html
  19. 0 0
      projects/xiaoshu-admin/src/app/pages/operations-users.component.scss
  20. 89 0
      projects/xiaoshu-admin/src/app/pages/operations-users.component.spec.ts
  21. 15 3
      projects/xiaoshu-admin/src/app/pages/operations-users.component.ts
  22. 6 0
      scripts/deploy-admin-functions.mjs
  23. 6 0
      scripts/smoke-operations-functions.mjs

+ 101 - 1
projects/xiaoshu-admin/src/app/admin.models.ts

@@ -573,6 +573,15 @@ export interface ScheduleAppointment extends Record<string, unknown> {
   classType: number;
   classTypeLabel: string;
   deliveryMethod: string;
+  courseCategoryKey?: string;
+  courseCategoryName?: string;
+  durationMinutes?: number;
+  deliveryMode?: TeachingDeliveryMode;
+  teachingRuleId?: string;
+  teacherPaySnapshot?: number;
+  creditCostsSnapshot?: TeachingCreditCost[];
+  creditHoldStatus?: string;
+  creditHoldReason?: string;
   startsAt: string;
   endsAt: string;
   statusCode: number;
@@ -601,7 +610,7 @@ export interface ScheduleCalendarData {
   appointments: ScheduleAppointment[];
   unassigned: ScheduleAppointment[];
   members: Array<{ objectId: string; userId: number; displayName: string; mobileMasked: string; periods30: number; periods60: number; trialPeriods: number; userPoint: number; learningStatusLabel: string; liveOnly?: boolean; writeReady?: boolean; readOnly?: boolean; readOnlyReason?: string }>;
-  courses: Array<{ courseId: number; courseName: string; memberIds: number[]; availableToAll?: boolean }>;
+  courses: Array<{ courseId: number; courseName: string; memberIds: number[]; availableToAll?: boolean; categoryKey?: string; categoryName?: string; mappingConfirmed?: boolean; suggestedCategoryName?: string; supportedDurations?: number[] }>;
   sync?: OperationsSyncIndicator;
 }
 
@@ -653,6 +662,91 @@ export interface PayRate extends Record<string, unknown> {
   updatedAt: string;
 }
 
+export type TeachingDeliveryMode = 'online' | 'offline';
+export type TeachingCreditAccount = 'Purse' | 'SilverCoin' | 'UserExp' | 'UserPoint';
+
+export interface TeachingCourseCategory extends Record<string, unknown> {
+  objectId: string;
+  key: string;
+  name: string;
+  sortOrder: number;
+  active: boolean;
+  system: boolean;
+  updatedAt: string;
+}
+
+export interface TeachingCreditCost extends Record<string, unknown> {
+  account: TeachingCreditAccount;
+  label: string;
+  amount: number;
+}
+
+export interface TeachingRule extends Record<string, unknown> {
+  objectId: string;
+  categoryKey: string;
+  categoryName: string;
+  durationMinutes: number;
+  deliveryMode: TeachingDeliveryMode;
+  deliveryLabel: string;
+  teacherPay: number;
+  creditCosts: TeachingCreditCost[];
+  effectiveFrom: string;
+  active: boolean;
+  isDefault?: boolean;
+  updatedAt: string;
+}
+
+export interface CourseTeachingMapping extends Record<string, unknown> {
+  objectId: string;
+  courseId: number;
+  courseName: string;
+  categoryKey: string;
+  categoryName: string;
+  suggestedCategoryKey: string;
+  suggestedCategoryName: string;
+  confirmed: boolean;
+  updatedAt: string;
+}
+
+export interface TeachingStandardsData {
+  categories: TeachingCourseCategory[];
+  rules: TeachingRule[];
+  mappings: CourseTeachingMapping[];
+  summary: {
+    courseCount: number;
+    confirmedCount: number;
+    unmappedCount: number;
+    activeRuleCount: number;
+  };
+  refreshedAt: string;
+}
+
+export interface CreditReservationReconciliation {
+  items: Array<{
+    appointmentId: string;
+    appointmentGeneralId: number;
+    studentId: number;
+    studentName: string;
+    courseId: number;
+    courseName: string;
+    startsAt: string;
+    categoryName: string;
+    durationMinutes: number;
+    deliveryLabel: string;
+    creditCosts: TeachingCreditCost[];
+    status: 'ready' | 'insufficient' | 'unmapped' | 'missing_rule' | 'missing_student';
+    statusLabel: string;
+    reason: string;
+  }>;
+  summary: {
+    total: number;
+    ready: number;
+    insufficient: number;
+    exceptions: number;
+  };
+  refreshedAt: string;
+}
+
 export type PayrollStatus = 'draft' | 'reviewed' | 'paid';
 
 export interface PayrollSummary extends Record<string, unknown> {
@@ -692,6 +786,12 @@ export interface PayrollLine extends Record<string, unknown> {
   settlementMonth: string;
   classType: number;
   classTypeLabel: string;
+  courseCategoryKey?: string;
+  courseCategoryName?: string;
+  deliveryMode?: TeachingDeliveryMode | '';
+  deliveryLabel?: string;
+  durationMinutes?: number;
+  teachingRuleId?: string;
   durationHours: number;
   rateAmount: number;
   amount: number;

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 40 - 0
projects/xiaoshu-admin/src/app/components/operations-date-filter.component.ts


+ 5 - 1
projects/xiaoshu-admin/src/app/operations.service.ts

@@ -1,5 +1,5 @@
 import { inject, Injectable } from '@angular/core';
-import { CoachAvailability, CoachDetailData, CoachPage, LearningReportDetail, LearningReportPage, LegacySyncRunResult, LegacySyncStatus, MediaUploadTicket, MemberImportPreview, MemberImportResult, MemberListSort, MemberPage, OperationsDashboardData, OperationsPage, OperationsUserDetail, PayRate, PayrollDetail, PayrollExportData, PayrollOverview, PayrollSummary, ScheduleCalendarData, ScheduleTrendData, ScheduleTrendPeriod, SpecialTrainingPage, StoreDetailData, StorePage, VocabularyImportPreview, VocabularyTree, VocabularyWord } from './admin.models';
+import { CoachAvailability, CoachDetailData, CoachPage, LearningReportDetail, LearningReportPage, LegacySyncRunResult, LegacySyncStatus, MediaUploadTicket, MemberImportPreview, MemberImportResult, MemberListSort, MemberPage, OperationsDashboardData, OperationsPage, OperationsUserDetail, PayRate, PayrollDetail, PayrollExportData, PayrollOverview, PayrollSummary, ScheduleCalendarData, ScheduleTrendData, ScheduleTrendPeriod, SpecialTrainingPage, StoreDetailData, StorePage, TeachingStandardsData, VocabularyImportPreview, VocabularyTree, VocabularyWord } from './admin.models';
 import { CloudFunctionsService } from './cloud-functions.service';
 
 export type OperationsListKind = 'relations' | 'course-bindings' | 'appointments' | 'lessons' | 'learning-records' | 'finance' | 'money-logs';
@@ -96,6 +96,10 @@ export class OperationsService {
     return this.functions.operations<OperationsPage<PayRate>>('ops/pay-rates/list', { page: 1, pageSize: 100 });
   }
 
+  teachingStandards(): Promise<TeachingStandardsData> {
+    return this.functions.operations<TeachingStandardsData>('ops/teaching-rules/list');
+  }
+
   payrolls(page = 1, pageSize = 20): Promise<OperationsPage<PayrollSummary>> {
     return this.functions.operations<OperationsPage<PayrollSummary>>('ops/payroll/list', { page, pageSize });
   }

+ 5 - 5
projects/xiaoshu-admin/src/app/pages/operations-coaches.component.html

@@ -1,11 +1,11 @@
 <header class="page-header">
   <div><p>COACH OPERATIONS</p><h1>陪练老师</h1><span>查看老师排课、完成课次、工时、评分与预估工资</span></div>
-  <div class="header-actions"><button class="ui-button secondary" type="button" (click)="load(page()?.page||1,true)" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="loading()"/>刷新实时数据</button>@if(page()?.canManage){<button class="ui-button primary" type="button" (click)="openCreate()"><lucide-icon [img]="icons.Plus" [size]="17"/>新增陪练</button>}</div>
+  <div class="header-actions"><button class="ui-button secondary" type="button" (click)="load(page()?.page||1,true,'refresh')" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="refreshing()"/>{{refreshing()?'刷新中':'刷新实时数据'}}</button>@if(page()?.canManage){<button class="ui-button primary" type="button" (click)="openCreate()"><lucide-icon [img]="icons.Plus" [size]="17"/>新增陪练</button>}</div>
 </header>
-<section class="toolbar">
-  <label class="grow"><span>搜索老师</span><input [ngModel]="query()" (ngModelChange)="query.set($event)" (keyup.enter)="load(1)" placeholder="姓名、账号、手机号或所属门店"/></label>
-  <label><span>账号状态</span><select [ngModel]="status()" (ngModelChange)="status.set($event)"><option value="all">全部</option><option value="active">正常</option><option value="disabled">已停用</option></select></label>
-  <button class="ui-button primary" type="button" (click)="load(1)"><lucide-icon [img]="icons.Search" [size]="17"/>查询</button><button class="ui-button secondary" type="button" (click)="reset()">重置</button>
+<section class="toolbar coach-filters" aria-label="陪练老师筛选" [attr.aria-busy]="querying()">
+  <label class="grow"><span>搜索老师</span><span class="search-control"><lucide-icon [img]="icons.Search" [size]="17"/><input type="search" [ngModel]="queryInput()" (ngModelChange)="queryInput.set($event)" (keyup.enter)="applyFilters()" placeholder="姓名、账号、手机号、业务 ID 或所属门店"/></span></label>
+  <label class="status-control"><span>账号状态</span><select [ngModel]="statusInput()" (ngModelChange)="statusInput.set($event)"><option value="all">全部状态</option><option value="active">正常</option><option value="disabled">已停用</option></select></label>
+  <div class="filter-actions"><button class="ui-button primary" type="button" (click)="applyFilters()" [disabled]="querying()"><lucide-icon [img]="querying()?icons.LoaderCircle:icons.Search" [size]="17" [class.spin]="querying()"/>{{querying()?'正在查询':'查询'}}</button><button class="ui-button secondary" type="button" (click)="reset()" [disabled]="querying()"><lucide-icon [img]="icons.RotateCcw" [size]="16"/>重置</button></div>
 </section>
 @if(message()){<p class="notice" role="status">{{message()}}</p>}@if(error()&&!drawerOpen()){<p class="notice error" role="alert">{{error()}}</p>}
 @if(page()&&!page()?.liveSourceAvailable&&page()?.liveSourceMessage){<p class="notice warning" role="status">{{page()?.liveSourceMessage}}</p>}

+ 126 - 0
projects/xiaoshu-admin/src/app/pages/operations-coaches.component.scss

@@ -2,6 +2,91 @@
   --control-height: 42px;
 }
 
+.page-header {
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 24px;
+  margin-bottom: 20px;
+}
+
+.page-header > div:first-child {
+  min-width: 0;
+}
+
+.page-header p {
+  margin: 0 0 9px;
+  color: #237a61;
+  font-size: 11px;
+  font-weight: 800;
+  letter-spacing: .16em;
+}
+
+.page-header h1 {
+  margin: 0;
+  color: #213730;
+  font-size: 31px;
+  line-height: 1.15;
+}
+
+.page-header span {
+  display: block;
+  margin-top: 9px;
+  color: #74847e;
+  font-size: 13px;
+}
+
+.header-actions {
+  flex: 0 0 auto;
+  align-self: flex-end;
+}
+
+.coach-filters {
+  display: grid;
+  grid-template-columns: minmax(320px, 1fr) 210px auto;
+  align-items: end;
+}
+
+.coach-filters .grow {
+  min-width: 0;
+}
+
+.search-control {
+  display: flex;
+  align-items: center;
+  gap: 9px;
+  min-height: var(--control-height);
+  padding: 0 12px;
+  border: 1px solid #d7e1dd;
+  border-radius: 12px;
+  color: #788882;
+  background: #fff;
+}
+
+.search-control:focus-within {
+  border-color: #278668;
+  box-shadow: 0 0 0 3px rgba(39, 134, 104, .11);
+}
+
+.search-control input {
+  width: 100%;
+  min-width: 0;
+  min-height: 40px;
+  padding: 0;
+  border: 0;
+  outline: 0;
+  background: transparent;
+}
+
+.status-control select {
+  width: 100%;
+}
+
+.filter-actions {
+  display: flex;
+  gap: 9px;
+}
+
 .coach-summary {
   display: grid;
   grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -149,7 +234,48 @@
   margin-right: auto;
 }
 
+@media (max-width: 1000px) {
+  .coach-filters {
+    grid-template-columns: minmax(0, 1fr) 190px;
+  }
+
+  .coach-filters .grow {
+    min-width: 0;
+  }
+
+  .filter-actions {
+    grid-column: 1 / -1;
+    justify-content: flex-end;
+  }
+}
+
 @media (max-width: 700px) {
+  .page-header {
+    align-items: stretch;
+    flex-direction: column;
+    gap: 14px;
+  }
+
+  .page-header h1 {
+    font-size: 27px;
+  }
+
+  .header-actions {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    width: 100%;
+  }
+
+  .coach-filters {
+    grid-template-columns: 1fr;
+  }
+
+  .filter-actions {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    grid-column: auto;
+  }
+
   .coach-summary {
     grid-template-columns: repeat(2, minmax(0, 1fr));
     gap: 10px;

+ 6 - 5
projects/xiaoshu-admin/src/app/pages/operations-coaches.component.ts

@@ -2,17 +2,18 @@ import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { RouterLink } from '@angular/router';
-import { ChevronLeft, ChevronRight, Eye, GraduationCap, LucideAngularModule, Pencil, Plus, RefreshCw, Search, Trash2, X } from 'lucide-angular';
+import { ChevronLeft, ChevronRight, Eye, GraduationCap, LoaderCircle, LucideAngularModule, Pencil, Plus, RefreshCw, RotateCcw, Search, Trash2, X } from 'lucide-angular';
 import { CoachPage, CoachSummary } from '../admin.models';
 import { OperationsService } from '../operations.service';
 
 @Component({selector:'app-operations-coaches',standalone:true,imports:[CommonModule,FormsModule,RouterLink,LucideAngularModule],templateUrl:'./operations-coaches.component.html',styleUrls:['./operations-professional.scss','./operations-coaches.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
 export class OperationsCoachesComponent implements OnInit{
- readonly page=signal<CoachPage|null>(null);readonly query=signal('');readonly status=signal('all');readonly loading=signal(true);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly drawerOpen=signal(false);readonly editing=signal<CoachSummary|null>(null);readonly deleteArmed=signal(false);
+ readonly page=signal<CoachPage|null>(null);readonly queryInput=signal('');readonly statusInput=signal('all');readonly query=signal('');readonly status=signal('all');readonly loading=signal(true);readonly querying=signal(false);readonly refreshing=signal(false);readonly saving=signal(false);readonly error=signal('');readonly message=signal('');readonly drawerOpen=signal(false);readonly editing=signal<CoachSummary|null>(null);readonly deleteArmed=signal(false);
  readonly username=signal('');readonly password=signal('');readonly displayName=signal('');readonly mobile=signal('');readonly parentUserId=signal(0);readonly disabled=signal(false);readonly reason=signal('');
- readonly icons={ChevronLeft,ChevronRight,Eye,GraduationCap,Pencil,Plus,RefreshCw,Search,Trash2,X};private readonly operations=inject(OperationsService);
- ngOnInit():void{void this.load(1);}async load(page=1,refresh=false):Promise<void>{this.loading.set(true);this.error.set('');try{this.page.set(await this.operations.coaches(page,20,this.query(),{status:this.status()},refresh));}catch(error){this.error.set(error instanceof Error?error.message:'陪练老师加载失败');}finally{this.loading.set(false);}}
- reset():void{this.query.set('');this.status.set('all');void this.load(1);}pageCount():number{return Math.max(1,Math.ceil((this.page()?.total||0)/(this.page()?.pageSize||20)));}
+ readonly icons={ChevronLeft,ChevronRight,Eye,GraduationCap,LoaderCircle,Pencil,Plus,RefreshCw,RotateCcw,Search,Trash2,X};private readonly operations=inject(OperationsService);
+ ngOnInit():void{void this.load(1);}async load(page=1,refresh=false,action:'load'|'query'|'refresh'='load'):Promise<void>{this.loading.set(true);this.querying.set(action==='query');this.refreshing.set(action==='refresh');this.error.set('');try{this.page.set(await this.operations.coaches(page,20,this.query(),{status:this.status()},refresh));}catch(error){this.error.set(error instanceof Error?error.message:'陪练老师加载失败');}finally{this.loading.set(false);this.querying.set(false);this.refreshing.set(false);}}
+ applyFilters():void{this.query.set(this.queryInput().trim());this.status.set(this.statusInput());void this.load(1,false,'query');}
+ reset():void{this.queryInput.set('');this.statusInput.set('all');this.query.set('');this.status.set('all');void this.load(1,false,'query');}pageCount():number{return Math.max(1,Math.ceil((this.page()?.total||0)/(this.page()?.pageSize||20)));}
  openCreate():void{this.editing.set(null);this.username.set('');this.password.set('');this.displayName.set('');this.mobile.set('');this.parentUserId.set(0);this.disabled.set(false);this.reason.set('新增陪练老师');this.deleteArmed.set(false);this.error.set('');this.drawerOpen.set(true);}
  openEdit(row:CoachSummary,deleteMode=false):void{this.editing.set(row);this.username.set(row.username);this.password.set('');this.displayName.set(row.displayName);this.mobile.set(row.mobile||'');this.parentUserId.set(row.parentUserId||0);this.disabled.set(row.status==='disabled');this.reason.set('');this.deleteArmed.set(deleteMode);this.error.set('');this.drawerOpen.set(true);}
  closeDrawer():void{this.drawerOpen.set(false);this.deleteArmed.set(false);}

+ 15 - 4
projects/xiaoshu-admin/src/app/pages/operations-learning-reports.component.html

@@ -6,16 +6,27 @@
 @if (studentObjectId()) { <div class="member-scope"><span>当前仅显示该会员的学习报表</span><a routerLink="/admin/learning-reports">查看全部会员</a></div> }
 
 <section class="toolbar report-toolbar" aria-label="学习报表筛选">
-  <label><span>统计月份</span><input type="month" [ngModel]="month()" (ngModelChange)="month.set($event)" /></label>
-  <label><span>开始日期</span><input type="date" [ngModel]="dateFrom()" (ngModelChange)="dateFrom.set($event)" /></label>
-  <label><span>结束日期</span><input type="date" [ngModel]="dateTo()" (ngModelChange)="dateTo.set($event)" /></label>
+  <div class="filter-field date-picker-field"><span>统计月份</span><button class="picker-trigger" type="button" aria-label="统计月份" aria-haspopup="dialog" [attr.aria-expanded]="activePicker()==='month'" [class.active]="activePicker()==='month'" (click)="toggleMonthPicker($event)"><lucide-icon [img]="icons.CalendarDays" [size]="17" /><strong>{{ monthLabel() }}</strong><lucide-icon class="picker-chevron" [img]="icons.ChevronDown" [size]="16" /></button>
+    @if(activePicker()==='month'){<div class="calendar-popover month-popover" role="dialog" aria-label="选择统计月份" (click)="$event.stopPropagation()"><header><button type="button" aria-label="上一年" (click)="shiftMonthPickerYear(-1,$event)"><lucide-icon [img]="icons.ChevronLeft" [size]="17" /></button><strong>{{ monthPickerYear() }}年</strong><button type="button" aria-label="下一年" (click)="shiftMonthPickerYear(1,$event)"><lucide-icon [img]="icons.ChevronRight" [size]="17" /></button></header><div class="month-grid">@for(name of monthNames;track $index){<button type="button" [class.selected]="month()===monthOptionValue($index)" (click)="selectMonth($index)">{{ name }}</button>}</div></div>}
+  </div>
+  <div class="filter-field date-picker-field"><span>开始日期</span><button class="picker-trigger" type="button" aria-label="开始日期" aria-haspopup="dialog" [attr.aria-expanded]="activePicker()==='from'" [class.active]="activePicker()==='from'" (click)="toggleDatePicker('from',$event)"><lucide-icon [img]="icons.CalendarDays" [size]="17" /><strong>{{ dateLabel(dateFrom()) }}</strong><lucide-icon class="picker-chevron" [img]="icons.ChevronDown" [size]="16" /></button>
+    @if(activePicker()==='from'){<ng-container [ngTemplateOutlet]="dateCalendar" />}
+  </div>
+  <div class="filter-field date-picker-field"><span>结束日期</span><button class="picker-trigger" type="button" aria-label="结束日期" aria-haspopup="dialog" [attr.aria-expanded]="activePicker()==='to'" [class.active]="activePicker()==='to'" (click)="toggleDatePicker('to',$event)"><lucide-icon [img]="icons.CalendarDays" [size]="17" /><strong>{{ dateLabel(dateTo()) }}</strong><lucide-icon class="picker-chevron" [img]="icons.ChevronDown" [size]="16" /></button>
+    @if(activePicker()==='to'){<ng-container [ngTemplateOutlet]="dateCalendar" />}
+  </div>
   <label class="search-field"><span>学员</span><input [ngModel]="search()" (ngModelChange)="search.set($event)" placeholder="姓名、手机号或ID" (keyup.enter)="applyFilters()" /></label>
   <label><span>陪练</span><input [ngModel]="coachSearch()" (ngModelChange)="coachSearch.set($event)" placeholder="姓名或ID" /></label>
   <label><span>课程</span><input [ngModel]="courseSearch()" (ngModelChange)="courseSearch.set($event)" placeholder="课程名称" /></label>
   <label><span>报表状态</span><select [ngModel]="status()" (ngModelChange)="status.set($event)"><option value="">全部状态</option><option value="available">可生成</option><option value="historical">历史记录</option><option value="incomplete">学习未完成</option><option value="missing">明细缺失</option></select></label>
-  <button class="ui-button primary query-action" type="button" (click)="applyFilters()"><lucide-icon [img]="icons.Search" [size]="17" />查询</button>
+  <div class="filter-actions">
+    <button class="ui-button secondary reset-action" type="button" (click)="resetFilters()" [disabled]="searching()"><lucide-icon [img]="icons.RotateCcw" [size]="17" />重置</button>
+    <button class="ui-button primary query-action" type="button" (click)="applyFilters()" [disabled]="searching()"><lucide-icon [img]="searching() ? icons.LoaderCircle : icons.Search" [size]="17" [class.spin]="searching()" />{{ searching() ? '正在查找' : '查找' }}</button>
+  </div>
 </section>
 
+<ng-template #dateCalendar><div class="calendar-popover day-popover" role="dialog" aria-label="选择日期" (click)="$event.stopPropagation()"><header><button type="button" aria-label="上一月" (click)="shiftCalendar(-1,$event)"><lucide-icon [img]="icons.ChevronLeft" [size]="17" /></button><strong>{{ calendarTitle() }}</strong><button type="button" aria-label="下一月" (click)="shiftCalendar(1,$event)"><lucide-icon [img]="icons.ChevronRight" [size]="17" /></button></header><div class="week-grid" aria-hidden="true">@for(day of weekDays;track day){<span>{{ day }}</span>}</div><div class="day-grid">@for(day of calendarDays();track $index){@if(day){<button type="button" [class.today]="day.date===(today | date:'yyyy-MM-dd')" [class.selected]="day.date===selectedDate()" [attr.aria-label]="day.date" [attr.aria-current]="day.date===selectedDate()?'date':null" (click)="selectDate(day.date)">{{ day.day }}</button>}@else{<span></span>}}</div></div></ng-template>
+
 @if (data(); as pageData) {
   <div class="source-line" [class.delayed]="pageData.sourceStatus.state !== 'synced'" role="status"><i></i><strong>{{ pageData.sourceStatus.label }}</strong><span>{{ pageData.sourceStatus.message }}</span>@if (pageData.sourceStatus.lastSuccessAt) { <time>{{ pageData.sourceStatus.lastSuccessAt | date:'MM-dd HH:mm:ss' }}</time> }</div>
   <section class="report-metrics" aria-label="学习报表摘要">

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
projects/xiaoshu-admin/src/app/pages/operations-learning-reports.component.scss


+ 78 - 0
projects/xiaoshu-admin/src/app/pages/operations-learning-reports.component.spec.ts

@@ -46,4 +46,82 @@ describe('OperationsLearningReportsComponent', () => {
     expect((fixture.nativeElement as HTMLElement).textContent).toContain('apple');
     expect((fixture.nativeElement as HTMLElement).textContent).toContain('21天复习计划');
   });
+
+  it('submits every visible filter when the operator queries reports', async () => {
+    const component = fixture.componentInstance;
+    operations.learningReports.calls.reset();
+    component.month.set('2026-09');
+    component.dateFrom.set('2026-09-01');
+    component.dateTo.set('2026-09-04');
+    component.search.set('熊诗琦');
+    component.coachSearch.set('樊颖');
+    component.courseSearch.set('人教版');
+    component.status.set('available');
+
+    await component.applyFilters();
+
+    expect(operations.learningReports).toHaveBeenCalledWith(1, 20, '熊诗琦', {
+      month: '2026-09',
+      dateFrom: '2026-09-01',
+      dateTo: '2026-09-04',
+      status: 'available',
+      coachSearch: '樊颖',
+      courseSearch: '人教版',
+      studentObjectId: 'student-1',
+    }, false);
+  });
+
+  it('resets every editable filter and immediately reloads the default month', async () => {
+    const component = fixture.componentInstance;
+    operations.learningReports.calls.reset();
+    component.month.set('2025-02');
+    component.dateFrom.set('2025-02-01');
+    component.dateTo.set('2025-02-28');
+    component.search.set('旧学员');
+    component.coachSearch.set('旧陪练');
+    component.courseSearch.set('旧课程');
+    component.status.set('missing');
+
+    await component.resetFilters();
+
+    const now = new Date();
+    const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
+    expect(component.month()).toBe(currentMonth);
+    expect(component.dateFrom()).toBe('');
+    expect(component.dateTo()).toBe('');
+    expect(component.search()).toBe('');
+    expect(component.coachSearch()).toBe('');
+    expect(component.courseSearch()).toBe('');
+    expect(component.status()).toBe('');
+    expect(operations.learningReports).toHaveBeenCalledWith(1, 20, '', {
+      month: currentMonth,
+      dateFrom: '',
+      dateTo: '',
+      status: '',
+      coachSearch: '',
+      courseSearch: '',
+      studentObjectId: 'student-1',
+    }, false);
+  });
+
+  it('shows an explicit searching state until the query finishes', async () => {
+    const component = fixture.componentInstance;
+    const currentPage = component.data() as LearningReportPage;
+    let resolveRequest!: (value: LearningReportPage) => void;
+    operations.learningReports.and.returnValue(new Promise((resolve) => { resolveRequest = resolve; }));
+
+    const pending = component.applyFilters();
+    fixture.detectChanges();
+
+    const queryButton = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('.query-action');
+    expect(component.searching()).toBeTrue();
+    expect(queryButton?.disabled).toBeTrue();
+    expect(queryButton?.textContent).toContain('正在查找');
+
+    resolveRequest(currentPage);
+    await pending;
+    fixture.detectChanges();
+    expect(component.searching()).toBeFalse();
+    expect(queryButton?.textContent).toContain('查找');
+  });
 });

+ 85 - 4
projects/xiaoshu-admin/src/app/pages/operations-learning-reports.component.ts

@@ -1,9 +1,9 @@
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, ElementRef, OnInit, ViewChild, inject, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, ElementRef, HostListener, OnInit, ViewChild, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, RouterLink } from '@angular/router';
 import html2pdf from 'html2pdf.js';
-import { ChevronLeft, ChevronRight, Download, Eye, FileChartColumn, LoaderCircle, LucideAngularModule, RefreshCw, Search, X } from 'lucide-angular';
+import { CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Download, Eye, FileChartColumn, LoaderCircle, LucideAngularModule, RefreshCw, RotateCcw, Search, X } from 'lucide-angular';
 import { LearningReportDetail, LearningReportListItem, LearningReportPage } from '../admin.models';
 import { OperationsService } from '../operations.service';
 
@@ -12,6 +12,9 @@ const localMonth = (): string => {
   return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
 };
 
+type ReportDatePicker = 'month' | 'from' | 'to';
+interface CalendarDay { day: number; date: string; }
+
 @Component({
   selector: 'app-operations-learning-reports',
   standalone: true,
@@ -26,6 +29,7 @@ export class OperationsLearningReportsComponent implements OnInit {
   readonly data = signal<LearningReportPage | null>(null);
   readonly detail = signal<LearningReportDetail | null>(null);
   readonly loading = signal(true);
+  readonly searching = signal(false);
   readonly refreshing = signal(false);
   readonly detailLoading = signal(false);
   readonly exportingId = signal('');
@@ -41,7 +45,14 @@ export class OperationsLearningReportsComponent implements OnInit {
   readonly courseSearch = signal('');
   readonly status = signal('');
   readonly studentObjectId = signal('');
-  readonly icons = { ChevronLeft, ChevronRight, Download, Eye, FileChartColumn, LoaderCircle, RefreshCw, Search, X };
+  readonly activePicker = signal<ReportDatePicker | null>(null);
+  readonly calendarYear = signal(new Date().getFullYear());
+  readonly calendarMonth = signal(new Date().getMonth() + 1);
+  readonly monthPickerYear = signal(new Date().getFullYear());
+  readonly weekDays = ['一', '二', '三', '四', '五', '六', '日'];
+  readonly monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
+  readonly today = new Date();
+  readonly icons = { CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Download, Eye, FileChartColumn, LoaderCircle, RefreshCw, RotateCcw, Search, X };
 
   private readonly operations = inject(OperationsService);
   private readonly route = inject(ActivatedRoute);
@@ -73,7 +84,77 @@ export class OperationsLearningReportsComponent implements OnInit {
     }
   }
 
-  async applyFilters(): Promise<void> { this.page.set(1); await this.load(false); }
+  async applyFilters(): Promise<void> {
+    if (this.searching()) return;
+    this.activePicker.set(null); this.searching.set(true); this.page.set(1);
+    try { await this.load(false); }
+    finally { this.searching.set(false); }
+  }
+
+  async resetFilters(): Promise<void> {
+    if (this.searching()) return;
+    this.month.set(localMonth()); this.dateFrom.set(''); this.dateTo.set(''); this.search.set('');
+    this.coachSearch.set(''); this.courseSearch.set(''); this.status.set(''); this.activePicker.set(null);
+    await this.applyFilters();
+  }
+
+  @HostListener('document:click')
+  closePicker(): void { this.activePicker.set(null); }
+
+  @HostListener('document:keydown.escape')
+  closePickerWithKeyboard(): void { this.closePicker(); }
+
+  toggleMonthPicker(event: Event): void {
+    event.stopPropagation();
+    if (this.activePicker() === 'month') { this.activePicker.set(null); return; }
+    const year = Number(this.month().slice(0, 4));
+    this.monthPickerYear.set(year || new Date().getFullYear()); this.activePicker.set('month');
+  }
+
+  toggleDatePicker(target: 'from' | 'to', event: Event): void {
+    event.stopPropagation();
+    if (this.activePicker() === target) { this.activePicker.set(null); return; }
+    const value = target === 'from' ? this.dateFrom() : this.dateTo();
+    const base = /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : `${this.month() || localMonth()}-01`;
+    this.calendarYear.set(Number(base.slice(0, 4))); this.calendarMonth.set(Number(base.slice(5, 7))); this.activePicker.set(target);
+  }
+
+  selectMonth(monthIndex: number): void {
+    this.month.set(`${this.monthPickerYear()}-${String(monthIndex + 1).padStart(2, '0')}`); this.activePicker.set(null);
+  }
+
+  shiftMonthPickerYear(delta: number, event: Event): void { event.stopPropagation(); this.monthPickerYear.update((year) => year + delta); }
+
+  shiftCalendar(delta: number, event: Event): void {
+    event.stopPropagation();
+    const next = new Date(this.calendarYear(), this.calendarMonth() - 1 + delta, 1);
+    this.calendarYear.set(next.getFullYear()); this.calendarMonth.set(next.getMonth() + 1);
+  }
+
+  selectDate(date: string): void {
+    if (this.activePicker() === 'from') {
+      this.dateFrom.set(date); if (this.dateTo() && this.dateTo() < date) this.dateTo.set('');
+    } else if (this.activePicker() === 'to') {
+      this.dateTo.set(date); if (this.dateFrom() && this.dateFrom() > date) this.dateFrom.set('');
+    }
+    this.activePicker.set(null);
+  }
+
+  calendarDays(): Array<CalendarDay | null> {
+    const year = this.calendarYear(), month = this.calendarMonth();
+    const leading = (new Date(year, month - 1, 1).getDay() + 6) % 7;
+    const count = new Date(year, month, 0).getDate();
+    return [...Array.from({ length: leading }, () => null), ...Array.from({ length: count }, (_, index) => {
+      const day = index + 1;
+      return { day, date: `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` };
+    })];
+  }
+
+  monthLabel(): string { const [year, month] = this.month().split('-'); return year && month ? `${year}年${Number(month)}月` : '选择月份'; }
+  monthOptionValue(monthIndex: number): string { return `${this.monthPickerYear()}-${String(monthIndex + 1).padStart(2, '0')}`; }
+  dateLabel(value: string): string { return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value.replace(/^(\d{4})-(\d{2})-(\d{2})$/, '$1年$2月$3日') : '选择日期'; }
+  calendarTitle(): string { return `${this.calendarYear()}年 ${this.calendarMonth()}月`; }
+  selectedDate(): string { return this.activePicker() === 'from' ? this.dateFrom() : this.dateTo(); }
   async changePage(next: number): Promise<void> {
     const totalPages = Math.max(1, Math.ceil((this.data()?.total || 0) / this.pageSize));
     if (next < 1 || next > totalPages || next === this.page()) return;

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

@@ -1,6 +1,6 @@
 <header class="page-header payroll-header"><div><p>PAYROLL CENTER</p><h1>工资结算</h1><span>按旧系统有效上课记录核算收益,并完成月度审核与发放登记</span></div><div class="header-actions"><button class="ui-button refresh-action" type="button" (click)="refreshLive()" [disabled]="refreshing()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="refreshing()" />{{refreshing()?'正在刷新':'刷新实时数据'}}</button><button class="ui-button export-action" type="button" (click)="exportExcel()" [disabled]="saving()"><lucide-icon [img]="icons.Download" [size]="17" />导出 Excel</button></div></header>
 @if(message()){<p class="notice" role="status">{{message()}}</p>} @if(error()){<p class="notice error" role="alert">{{error()}}</p>}
-<nav class="tabs payroll-tabs" aria-label="工资结算功能"><button type="button" [class.active]="tab()==='overview'" (click)="tab.set('overview')"><lucide-icon [img]="icons.TrendingUp" [size]="17" />收益统计</button><button type="button" [class.active]="tab()==='batches'" (click)="tab.set('batches')"><lucide-icon [img]="icons.Banknote" [size]="17" />工资批次</button><button type="button" [class.active]="tab()==='rates'" (click)="tab.set('rates')"><lucide-icon [img]="icons.Settings2" [size]="17" />计价规则</button></nav>
+<nav class="tabs payroll-tabs" aria-label="工资结算功能"><button type="button" [class.active]="tab()==='overview'" (click)="tab.set('overview')"><lucide-icon [img]="icons.TrendingUp" [size]="17" />收益统计</button><button type="button" [class.active]="tab()==='batches'" (click)="tab.set('batches')"><lucide-icon [img]="icons.Banknote" [size]="17" />工资批次</button><button type="button" [class.active]="tab()==='rates'" (click)="tab.set('rates')"><lucide-icon [img]="icons.Settings2" [size]="17" />排课与工资标准</button></nav>
 @if(tab()!=='rates'){<section class="toolbar payroll-toolbar"><label><span>统计月份</span><input type="month" [ngModel]="month()" (ngModelChange)="month.set($event)" /></label><label><span>门店</span><select [ngModel]="storeId()" (ngModelChange)="storeId.set(+$event)"><option [ngValue]="0">全部门店</option>@for(store of overview()?.stores||[];track store.storeId){<option [ngValue]="store.storeId">{{store.storeName==='未绑定门店'?'未绑定门店(ID '+store.storeId+')':store.storeName}}</option>}</select></label><label class="search-field"><span>陪练搜索</span><input [ngModel]="search()" (ngModelChange)="search.set($event)" placeholder="姓名或手机号" (keyup.enter)="applyFilters()" /></label><button class="ui-button primary query-action" type="button" (click)="applyFilters()"><lucide-icon [img]="icons.Search" [size]="17" />查询</button><span class="source-chip" [class.cache]="overview()?.sourceStatus?.mode==='cache'" role="status"><i></i>{{overview()?.sourceStatus?.mode==='live'?'实时数据':'缓存数据'}} · {{overview()?.sourceStatus?.refreshedAt|date:'MM-dd HH:mm:ss'}}</span></section>}
 @if(tab()==='overview'){
  @if(overview();as data){
@@ -11,7 +11,27 @@
 } @else if(tab()==='batches'){
  <section class="content-card"><header><div><strong>全公司月度工资批次</strong><span>每月仅一张;门店筛选不会拆分工资单</span></div></header><div class="table-scroll"><table class="data-table"><thead><tr><th>月份</th><th>状态</th><th>陪练</th><th>有效课次</th><th>工时</th><th>基础工资</th><th>调整</th><th>应发</th><th>异常</th><th>操作</th></tr></thead><tbody>@for(row of page()?.items||[];track row.objectId){<tr><td><strong>{{row.month}}</strong></td><td><span class="badge" [class.warn]="row.status==='draft'">{{row.statusLabel}}</span></td><td>{{row.coachCount}}</td><td>{{row.lessonCount}}</td><td>{{row.totalHours|number:'1.1-1'}}h</td><td>¥{{row.baseAmount|number:'1.2-2'}}</td><td>¥{{row.adjustmentAmount|number:'1.2-2'}}</td><td class="money">¥{{row.payableAmount|number:'1.2-2'}}</td><td [class.exception]="row.exceptionCount">{{row.exceptionCount}}</td><td><button type="button" aria-label="查看工资单" (click)="open(row)"><lucide-icon [img]="icons.Eye" [size]="17" /></button></td></tr>}@empty{<tr><td colspan="10"><div class="empty">尚未生成工资批次。请先在“收益统计”核对实时数据。</div></td></tr>}</tbody></table></div></section>
 } @else {
- <section class="content-card"><header><div><strong>按生效日期管理课酬</strong><span>历史工资保存单价快照,不受后续改价影响</span></div></header>@if(isManager()){<div class="panel"><div class="form-grid"><label>课程类型<select [ngModel]="rateType()" (ngModelChange)="rateType.set(+$event)"><option [ngValue]="1">30分钟</option><option [ngValue]="2">60分钟</option><option [ngValue]="3">体验课</option><option [ngValue]="4">课型4(如需启用)</option></select></label><label>每节课酬<input type="number" min="0" step="0.01" [ngModel]="rateAmount()" (ngModelChange)="rateAmount.set(+$event)" /></label><label>计薪工时<input type="number" min="0" step="0.5" [ngModel]="rateHours()" (ngModelChange)="rateHours.set(+$event)" /></label><label>生效日期<input type="date" [ngModel]="effectiveFrom()" (ngModelChange)="effectiveFrom.set($event)" /></label><button class="ui-button primary" type="button" (click)="saveRate()" [disabled]="saving()">保存规则</button></div></div>}<div class="table-scroll"><table class="data-table"><thead><tr><th>课型</th><th>每节课酬</th><th>计薪工时</th><th>生效日期</th><th>状态</th></tr></thead><tbody>@for(rate of rates();track rate.objectId){<tr><td>{{rate.classTypeLabel}}</td><td class="money">¥{{rate.amount|number:'1.2-2'}}</td><td>{{rate.durationHours}}h</td><td>@if(rate.isDefault){系统默认}@else { {{rate.effectiveFrom|date:'yyyy-MM-dd'}} }</td><td><span class="badge">{{rate.active?'生效':'停用'}}</span>@if(rate.isDefault){<small>原系统默认规则</small>}</td></tr>}</tbody></table></div><p class="type4-note">课型4当前没有默认规则,保持“只计课次、不计工资、不计工时”。只有明确新增生效规则后才应改变口径。</p></section>
+ @if(standards();as config){
+  <section class="standards-summary"><article><span>课程数</span><strong>{{config.summary.courseCount}}</strong></article><article><span>已确认映射</span><strong>{{config.summary.confirmedCount}}</strong></article><article [class.warn]="config.summary.unmappedCount"><span>待确认课程</span><strong>{{config.summary.unmappedCount}}</strong></article><article><span>生效规则</span><strong>{{config.summary.activeRuleCount}}</strong></article></section>
+  <nav class="standards-nav" aria-label="排课与工资标准分组"><button type="button" [class.active]="standardsSection()==='rules'" (click)="standardsSection.set('rules')">工资与课时规则</button><button type="button" [class.active]="standardsSection()==='mappings'" (click)="standardsSection.set('mappings')">课程映射 <span>{{config.summary.unmappedCount}}</span></button><button type="button" [class.active]="standardsSection()==='categories'" (click)="standardsSection.set('categories')">课程分类</button><button type="button" [class.active]="standardsSection()==='reservations'" (click)="standardsSection.set('reservations');previewReservations()">历史预约预留</button><button type="button" [class.active]="standardsSection()==='reconciliation'" (click)="standardsSection.set('reconciliation');previewReconciliation()">复习课课时核对</button></nav>
+  @if(standardsSection()==='rules'){
+   <section class="content-card"><header><div><strong>工资与课时规则</strong><span>按课程分类、时长、线上/线下和生效日期配置;已审核工资永久使用原快照</span></div></header>
+   @if(isManager()){<div class="panel standard-editor"><div class="form-grid"><label>课程分类<select [ngModel]="ruleCategoryKey()" (ngModelChange)="ruleCategoryKey.set($event)">@for(category of config.categories;track category.key){<option [value]="category.key">{{category.name}}</option>}</select></label><label>课程时长<select [ngModel]="ruleDuration()" (ngModelChange)="ruleDuration.set(+$event)"><option [ngValue]="30">30 分钟</option><option [ngValue]="60">60 分钟</option></select></label><label>授课方式<select [ngModel]="ruleDelivery()" (ngModelChange)="ruleDelivery.set($event)"><option value="online">线上</option><option value="offline">线下</option></select></label><label>每节教师工资<input type="number" min="0" step="0.5" [ngModel]="rulePay()" (ngModelChange)="rulePay.set(+$event)" /></label><label>生效日期<input type="date" [ngModel]="effectiveFrom()" (ngModelChange)="effectiveFrom.set($event)" /></label><div class="credit-preview"><span>课时扣减</span><strong>@for(cost of creditCosts();track cost.account){ {{cost.account==='Purse'?'30分钟课时':cost.account==='SilverCoin'?'60分钟课时':cost.account==='UserExp'?'体验课时':'陪练时长'}} -{{cost.amount}} }</strong></div><button class="ui-button primary" type="button" (click)="saveTeachingRule()" [disabled]="saving()">保存新版本</button></div></div>}
+   <div class="table-scroll"><table class="data-table standards-table"><thead><tr><th>课程分类</th><th>时长</th><th>方式</th><th>教师工资</th><th>课时扣减</th><th>生效日期</th><th>版本</th><th>操作</th></tr></thead><tbody>@for(rule of config.rules;track rule.objectId){<tr><td><strong>{{rule.categoryName}}</strong></td><td>{{rule.durationMinutes}}分钟</td><td><span class="delivery-badge" [class.offline]="rule.deliveryMode==='offline'">{{rule.deliveryLabel}}</span></td><td class="money">¥{{rule.teacherPay|number:'1.2-2'}}</td><td>{{creditSummary(rule)}}</td><td>{{rule.effectiveFrom}}</td><td><span class="badge" [class.warn]="!rule.active">{{rule.active?'生效':'停用'}}</span><small>{{rule.isDefault?'初始标准':'管理员版本'}}</small></td><td>@if(isManager()&&!rule.isDefault&&rule.active){<button class="ui-button danger compact" type="button" (click)="deactivateTeachingRule(rule)" [disabled]="saving()">停用</button>}@else{<span class="muted-text">—</span>}</td></tr>}</tbody></table></div></section>
+  } @else if(standardsSection()==='mappings'){
+   <section class="content-card"><header><div><strong>课程分类映射</strong><span>未确认映射的课程不允许新增排课,系统建议仅供管理员确认</span></div><label class="mapping-search"><span class="sr-only">搜索课程</span><input [ngModel]="mappingSearch()" (ngModelChange)="mappingSearch.set($event)" placeholder="搜索课程名称或 ID" /></label></header><div class="table-scroll"><table class="data-table mapping-table"><thead><tr><th>课程</th><th>系统建议</th><th>确认分类</th><th>状态</th><th>操作</th></tr></thead><tbody>@for(row of filteredMappings();track row.courseId){<tr><td><strong>{{row.courseName}}</strong><small>ID {{row.courseId}}</small></td><td>{{row.suggestedCategoryName}}</td><td><select [ngModel]="mappingSelections()[row.courseId]" (ngModelChange)="setMapping(row.courseId,$event)">@for(category of config.categories;track category.key){<option [value]="category.key">{{category.name}}</option>}</select></td><td><span class="badge" [class.warn]="!row.confirmed">{{row.confirmed?'已确认':'待确认'}}</span></td><td>@if(isManager()){<button class="ui-button secondary compact" type="button" (click)="confirmMapping(row)" [disabled]="saving()">{{row.confirmed?'更新':'确认'}}</button>}</td></tr>}@empty{<tr><td colspan="5"><div class="empty">没有匹配的课程</div></td></tr>}</tbody></table></div></section>
+  } @else if(standardsSection()==='categories') {
+   <section class="content-card"><header><div><strong>标准课程分类</strong><span>分类编码永久稳定;管理员可调整显示名称或停用分类,历史数据不受影响</span></div></header><div class="category-grid">@for(category of config.categories;track category.key){<article><span>{{category.key}}</span>@if(isManager()){<input [ngModel]="categoryNames()[category.key]" (ngModelChange)="setCategoryName(category.key,$event)" [disabled]="saving()" aria-label="课程分类显示名称" /><div class="category-actions"><button class="ui-button secondary compact" type="button" (click)="saveCategory(category)" [disabled]="saving()">保存名称</button><button class="ui-button compact" [class.danger]="category.active" type="button" (click)="saveCategory(category,!category.active)" [disabled]="saving()">{{category.active?'停用':'恢复'}}</button></div>}@else{<strong>{{category.name}}</strong>}<small>{{category.active?'使用中':'已停用'}}</small></article>}</div></section>
+  } @else if(standardsSection()==='reservations') {
+   <section class="content-card reconciliation-card"><header><div><strong>历史未来预约课时预留</strong><span>按预约时间顺序核对现有余额;确认后仅补建预留,不扣减实际课时</span></div><div class="header-actions"><button class="ui-button secondary" type="button" (click)="previewReservations()" [disabled]="saving()"><lucide-icon [img]="icons.RefreshCw" [size]="16" />重新核对</button>@if(isManager()&&reservationReconciliation()?.summary?.total){<button class="ui-button primary" type="button" (click)="commitReservations()" [disabled]="saving()"><lucide-icon [img]="icons.FileCheck2" [size]="16" />确认补建预留</button>}</div></header>
+   @if(reservationReconciliation();as result){<div class="reconciliation-summary"><article><span>待核对预约</span><strong>{{result.summary.total}}</strong></article><article><span>可补建预留</span><strong>{{result.summary.ready}}</strong></article><article><span>待补课时</span><strong>{{result.summary.insufficient}}</strong></article><article><span>映射/数据异常</span><strong>{{result.summary.exceptions}}</strong></article></div><div class="table-scroll"><table class="data-table"><thead><tr><th>预约ID</th><th>学员</th><th>课程</th><th>预约时间</th><th>标准</th><th>课时预留</th><th>处理结果</th></tr></thead><tbody>@for(row of result.items;track row.appointmentId){<tr><td>{{row.appointmentGeneralId}}</td><td>{{row.studentName}}<small>ID {{row.studentId}}</small></td><td>{{row.courseName}}</td><td>{{row.startsAt|date:'yyyy-MM-dd HH:mm'}}</td><td>{{row.categoryName||'未映射'}}<small>{{row.durationMinutes}}分钟 · {{row.deliveryLabel}}</small></td><td>@for(cost of row.creditCosts;track cost.account){<span>{{cost.label}} {{cost.amount}}</span>}</td><td><span class="badge" [class.warn]="row.status!=='ready'">{{row.statusLabel}}</span><small>{{row.reason}}</small></td></tr>}@empty{<tr><td colspan="7"><div class="empty">所有未来预约均已建立课时预留</div></td></tr>}</tbody></table></div>}@else{<div class="empty">正在核对历史未来预约……</div>}
+   </section>
+  } @else {
+   <section class="content-card reconciliation-card"><header><div><strong>2026 年 9 月起复习课课时差异</strong><span>只预览新规则生效后的已完课记录;确认前不会修改余额</span></div><div class="header-actions"><button class="ui-button secondary" type="button" (click)="previewReconciliation()" [disabled]="saving()"><lucide-icon [img]="icons.RefreshCw" [size]="16" />重新核对</button>@if(isManager()&&reconciliation()?.summary?.differenceCount){<button class="ui-button primary" type="button" (click)="commitReconciliation()" [disabled]="saving()"><lucide-icon [img]="icons.FileCheck2" [size]="16" />确认退回差额</button>}</div></header>
+   @if(reconciliation();as result){<div class="reconciliation-summary"><article><span>已完成复习课</span><strong>{{result.summary.completedReviewCount}}</strong></article><article><span>待处理差异</span><strong>{{result.summary.differenceCount}}</strong></article><article><span>应退陪练时长</span><strong>{{result.summary.refundTotal|number:'1.1-2'}}</strong></article></div><div class="table-scroll"><table class="data-table"><thead><tr><th>预约ID</th><th>学员ID</th><th>上课时间</th><th>时长</th><th>规则应扣</th><th>实际已扣</th><th>应退</th><th>状态</th></tr></thead><tbody>@for(row of result.items;track row.appointmentId){<tr><td>{{row.appointmentGeneralId}}</td><td>{{row.studentId}}</td><td>{{row.lessonAt}}</td><td>{{row.durationMinutes}}分钟</td><td>{{row.expected}}</td><td>{{row.charged}}</td><td [class.money]="row.adjustment">{{row.adjustment}}</td><td><span class="badge" [class.warn]="row.adjustment">{{row.status}}</span></td></tr>}@empty{<tr><td colspan="8"><div class="empty">暂无已完成的复习课记录</div></td></tr>}</tbody></table></div>}@else{<div class="empty">正在核对复习课课时……</div>}
+   </section>
+  }
+ }
 }
 @if(detail()||selectedCoachId()){
  <div class="backdrop" (click)="closeDrawer()"></div><aside class="drawer payroll-drawer" role="dialog" aria-modal="true" aria-label="工资明细"><header><div><p>{{detail()?.objectId?'PAYROLL BATCH':'COACH LESSONS'}}</p><h2>@if(detail();as batch){ {{batch.month}} · {{batch.statusLabel}} }@else{ {{selectedCoachName()||'老师上课明细'}} }</h2><span>{{coachLines().length}} 条来源记录</span></div><button class="icon-action" type="button" aria-label="关闭" (click)="closeDrawer()"><lucide-icon [img]="icons.X" [size]="20" /></button></header>@if(detail()?.exceptionCount){<p class="notice error">存在 {{detail()?.exceptionCount}} 条异常,处理完成前不能审核。</p>}<div class="table-scroll"><table class="data-table"><thead><tr><th>陪练</th><th>学员 / 课程</th><th>日期</th><th>课型</th><th>工时</th><th>单价</th><th>金额</th><th>来源ID</th><th>说明</th></tr></thead><tbody>@for(line of coachLines();track line.objectId){<tr [class.muted]="line.excluded"><td>{{line.coachName}}<small>{{line.storeName}}</small></td><td>{{line.studentName}}<small>{{line.courseName}}</small></td><td>{{line.lessonAt|date:'MM-dd HH:mm'}}</td><td>{{line.classTypeLabel}}</td><td>{{line.durationHours}}h</td><td>¥{{line.rateAmount|number:'1.2-2'}}</td><td class="money">¥{{line.amount|number:'1.2-2'}}</td><td>{{line.legacyGeneralId||'—'}}</td><td [class.exception]="line.exception&&!line.excluded">{{line.excluded?'已排除':line.exception||line.description||'—'}} @if(detail()?.status==='draft'&&isManager()&&line.exception&&!line.excluded){<button class="ui-button danger compact" type="button" (click)="excludeLine(line.objectId)">排除</button>}</td></tr>}@empty{<tr><td colspan="9"><div class="empty">暂无可查看记录</div></td></tr>}</tbody></table></div>

+ 39 - 0
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.scss

@@ -308,6 +308,41 @@
   font-weight: 800;
 }
 
+.standards-summary { display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin:0 0 14px; }
+.standards-summary article { padding:18px 20px;border:1px solid #dce7e3;border-radius:16px;background:#fff; }
+.standards-summary article.warn { border-color:#efc78e;background:#fff9ef; }
+.standards-summary span,.standards-summary strong { display:block; }
+.standards-summary span { color:#71837d;font-size:12px;font-weight:700; }
+.standards-summary strong { margin-top:8px;color:#173d32;font-size:24px; }
+.standards-nav { display:flex;gap:8px;margin-bottom:14px; }
+.standards-nav button { min-height:42px;padding:0 16px;border:1px solid #d7e3df;border-radius:12px;color:#50665e;background:#fff;font-weight:750; }
+.standards-nav button.active { border-color:#176c55;color:#fff;background:#176c55; }
+.standards-nav button span { margin-left:5px;padding:2px 6px;border-radius:999px;color:#9a5a00;background:#fff2d9;font-size:11px; }
+.standard-editor .form-grid { grid-template-columns:repeat(5,minmax(130px,1fr)) minmax(220px,1.4fr) auto;align-items:end; }
+.credit-preview { min-height:64px;padding:10px 13px;border:1px solid #d9e5e1;border-radius:12px;background:#f7fbf9; }
+.credit-preview span,.credit-preview strong { display:block; }
+.credit-preview span { color:#71837d;font-size:11px; }
+.credit-preview strong { margin-top:4px;color:#23493e;font-size:12px; }
+.standards-table td,.mapping-table td { white-space:nowrap; }
+.delivery-badge { display:inline-flex;padding:4px 9px;border-radius:999px;color:#156c54;background:#e9f8f2;font-size:12px;font-weight:750; }
+.delivery-badge.offline { color:#855411;background:#fff2d9; }
+.mapping-search { width:min(340px,100%); }
+.mapping-search input,.mapping-table select { min-height:42px;border:1px solid #d7e3df;border-radius:11px;background:#fff; }
+.category-grid { display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;padding:18px; }
+.category-grid article { padding:18px;border:1px solid #dce7e3;border-radius:14px;background:#fbfdfc; }
+.category-grid span,.category-grid strong,.category-grid small { display:block; }
+.category-grid span { color:#8a9a94;font-size:11px; }
+.category-grid strong { margin:5px 0;color:#1d4136; }
+.category-grid input { width:100%;margin:8px 0;border:1px solid #d5e2de;border-radius:10px;padding:9px 10px;background:#fff;color:#193a31;font:inherit; }
+.category-actions { display:flex;gap:8px;flex-wrap:wrap;margin:2px 0 8px; }
+.muted-text { color:#98a7a2; }
+.reconciliation-card .header-actions { display:flex;align-items:center;gap:8px; }
+.reconciliation-summary { display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;padding:18px; }
+.reconciliation-summary article { padding:16px 18px;border:1px solid #dce7e3;border-radius:14px;background:#f8fbfa; }
+.reconciliation-summary span,.reconciliation-summary strong { display:block; }
+.reconciliation-summary span { color:#71837d;font-size:12px;font-weight:700; }
+.reconciliation-summary strong { margin-top:7px;color:#176c55;font-size:24px; }
+
 @media (max-width: 1400px) {
   .payroll-metrics {
     grid-template-columns: repeat(3, 1fr);
@@ -324,6 +359,8 @@
 }
 
 @media (max-width: 900px) {
+  .standards-summary,.category-grid,.reconciliation-summary { grid-template-columns:repeat(2,minmax(0,1fr)); }
+  .standard-editor .form-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
   .payroll-header {
     align-items: stretch;
     flex-direction: column;
@@ -343,6 +380,8 @@
 }
 
 @media (max-width: 700px) {
+  .standards-summary,.category-grid,.standard-editor .form-grid { grid-template-columns:1fr; }
+  .standards-nav { overflow-x:auto; }
   .payroll-header .header-actions {
     display: grid;
     grid-template-columns: 1fr 1fr;

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 2
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.ts


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

@@ -13,17 +13,20 @@
     <button type="button" [class.active]="view()==='list'" (click)="view.set('list');load()">列表</button>
   </div>
   <button type="button" aria-label="上一时间段" (click)="move(view()==='week'?-7:-1)"><lucide-icon [img]="icons.ChevronLeft" [size]="17" /></button>
-  <label class="date-filter"><span>日期</span><input type="date" [ngModel]="date()" (ngModelChange)="date.set($event);load()" /></label>
+  <app-operations-date-filter class="date-filter" label="日期" [value]="date()" (valueChange)="updateDraftDate($event)" />
   <button type="button" aria-label="下一时间段" (click)="move(view()==='week'?7:1)"><lucide-icon [img]="icons.ChevronRight" [size]="17" /></button>
   <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>
   @if(data()?.dataDateTo){<button class="latest-data" type="button" (click)="showLatest()">最近有数据:{{data()?.dataDateTo}}</button>}
 </section>
 
 <section class="schedule-filterbar" aria-label="排课记录筛选">
-  <label class="schedule-search"><span>搜索排课</span><span class="filter-control"><lucide-icon [img]="icons.Search" [size]="16"/><input type="search" [ngModel]="scheduleSearch()" (ngModelChange)="scheduleSearch.set($event)" placeholder="学员、陪练、课程或业务ID"/></span></label>
-  <label class="status-filter"><span>业务状态</span><select [ngModel]="statusFilter()" (ngModelChange)="statusFilter.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="cancelled">已取消</option><option value="unassigned">待分配 / 账号异常</option></select></label>
+  <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>
+  <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="cancelled">已取消</option><option value="unassigned">待分配 / 账号异常</option></select></label>
+  <div class="filter-actions">
+    <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>
+    <button class="reset-button" type="button" (click)="clearScheduleFilters()" [disabled]="searching()"><lucide-icon [img]="icons.RotateCcw" [size]="15" />重置</button>
+  </div>
   <div class="filter-result" role="status"><strong>{{visibleAppointments().length}}</strong><span>条筛选结果</span><small>当前范围共 {{data()?.appointments?.length||0}} 条</small></div>
-  @if(hasScheduleFilters()){<button type="button" (click)="clearScheduleFilters()">清除筛选</button>}
 </section>
 
 @if(message()){<p class="notice" role="status">{{message()}}</p>}
@@ -67,13 +70,13 @@
   </header>
   @if(view()==='list'){
     <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>
-      @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">@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)="action(item,'ops/appointments/cancel')">取消</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){<span class="read-only-label">按业务端状态流转</span>}}}</div></td></tr>}
+      @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">@if(item.statusCode>=11){<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)="action(item,'ops/appointments/cancel')">取消</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>}
       @empty{<tr><td colspan="7"><div class="empty">{{hasScheduleFilters()?'没有符合筛选条件的排课':'当前范围暂无排课;历史数据范围为 '+(data()?.dataDateFrom||'—')+' 至 '+(data()?.dataDateTo||'—')}}</div></td></tr>}
     </tbody></table></div>
   } @else {
     <div class="table-scroll"><div class="calendar">
-      @for(coach of data()?.coaches||[];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){<button type="button" class="appointment" [ngClass]="statusTone(item)" (click)="openEdit(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>}
-      @empty{<div class="empty">暂无陪练资料</div>}
+      @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)="item.statusCode>=11?viewFeedback(item):openEdit(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>}
+      @empty{<div class="empty">当前范围暂无已排课的陪练</div>}
       @if(unassignedCount()){<div class="calendar-row"><div class="coach-label"><strong>待分配 / 历史账号</strong><span>{{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)="openEdit(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>}
     </div></div>
   }
@@ -111,14 +114,14 @@
         @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>
+              <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}} · {{course.mappingConfirmed?course.categoryName:'待确认分类'}}</small></span></button>
             } @empty {<span class="search-empty">{{studentId()?'该学员没有匹配的已绑定课程,请先在会员详情绑定课程':'请先选择学员,再选择其已绑定课程'}}</span>}
           </span>
         }
       </label>
-      <label class="type-step">课程类型<select [ngModel]="classType()" (ngModelChange)="updateClassType(+$event)"><option [ngValue]="1">30分钟</option><option [ngValue]="2">60分钟</option><option [ngValue]="3">体验课</option><option [ngValue]="4">陪练</option></select></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>
       <label class="delivery-step">交付方式<select [ngModel]="deliveryMethod()" (ngModelChange)="deliveryMethod.set($event);preview.set(null)"><option>线上</option><option>线下</option></select></label>
-      <label class="date-step">日期<input type="date" [ngModel]="date()" (ngModelChange)="updateDraftDate($event)" /></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>
       <label class="search-field wide coach-step">
         <span>选择陪练老师 <small>可搜索老师姓名或手机号码</small></span>
@@ -151,3 +154,11 @@
     <footer><button type="button" (click)="preflight()" [disabled]="saving()">检查冲突</button><button class="primary" type="button" (click)="save()" [disabled]="saving()||!canSavePreview()">{{saveButtonLabel()}}</button></footer>
   </aside>
 }
+@if(feedback();as report){
+  <div class="backdrop" (click)="closeFeedback()"></div>
+  <aside class="drawer feedback-drawer" role="dialog" aria-modal="true" aria-label="完课反馈详情">
+    <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>
+    <section class="feedback-facts"><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>
+    <section class="feedback-sections"><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>6. 备注</span><p>{{report['notes']||'无'}}</p></article></section>
+  </aside>
+}

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

@@ -190,7 +190,16 @@ textarea:focus-visible {
   font-size: 10px;
 }
 
-.schedule-filterbar > button {
+.filter-actions {
+  display: flex;
+  gap: 8px;
+}
+
+.filter-actions button {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 7px;
   height: 42px;
   padding: 0 14px;
   border: 1px solid #d7e3df;
@@ -201,6 +210,29 @@ textarea:focus-visible {
   white-space: nowrap;
 }
 
+.filter-actions .find-button {
+  min-width: 96px;
+  border-color: #176c55;
+  color: #fff;
+  background: #176c55;
+}
+
+.filter-actions .find-button:hover:not(:disabled) {
+  border-color: #115d49;
+  background: #115d49;
+  box-shadow: 0 7px 16px rgba(23, 108, 85, .2);
+}
+
+.filter-actions .reset-button:hover:not(:disabled) {
+  border-color: #a7bbb4;
+  background: #f5f9f7;
+}
+
+.filter-actions button:disabled {
+  cursor: wait;
+  opacity: .72;
+}
+
 @media (min-width: 1001px) {
   .toolbar > .segmented {
     width: 210px;
@@ -215,10 +247,7 @@ textarea:focus-visible {
   .toolbar .date-filter {
     flex: 0 0 220px;
     width: 220px;
-  }
-
-  .toolbar .date-filter input {
-    width: 100%;
+    --date-filter-height: 44px;
   }
 }
 
@@ -943,17 +972,13 @@ textarea:focus-visible {
     grid-column: 2;
     width: 100%;
     min-width: 0;
+    --date-filter-height: 44px;
   }
 
-  .toolbar .date-filter > span {
+  .toolbar .date-filter::part(field-label) {
     display: none;
   }
 
-  .toolbar .date-filter input {
-    width: 100%;
-    min-width: 0;
-  }
-
   .toolbar .coach-filter {
     grid-column: 1 / -1;
     flex-basis: auto;
@@ -993,7 +1018,13 @@ textarea:focus-visible {
     min-width: 0;
   }
 
-  .schedule-filterbar > button {
+  .filter-actions {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    grid-column: 1 / -1;
+  }
+
+  .filter-actions button {
     width: 100%;
   }
 
@@ -1118,3 +1149,15 @@ textarea:focus-visible {
     overflow-wrap: anywhere;
   }
 }
+
+.feedback-drawer { width:min(760px,94vw); }
+.feedback-facts { display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;padding:18px 22px 0; }
+.feedback-facts article,.feedback-sections article { padding:14px;border:1px solid #dce7e3;border-radius:13px;background:#f9fcfb; }
+.feedback-facts span,.feedback-sections span { display:block;margin-bottom:6px;color:#72837d;font-size:11px;font-weight:750; }
+.feedback-facts strong,.feedback-sections strong { color:#173d32;line-height:1.45; }
+.feedback-sections { display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;padding:14px 22px 24px; }
+.feedback-sections p { margin:5px 0 0;color:#415851;line-height:1.65;white-space:pre-wrap; }
+
+@media (max-width: 720px) {
+  .feedback-facts,.feedback-sections { grid-template-columns:1fr;padding-inline:16px; }
+}

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

@@ -1,5 +1,5 @@
-import { matchesScheduleSearch, scheduleCoachBusy, scheduleCourseAvailable, schedulePreflightCandidates, scheduleRowActions } from './operations-schedule.component';
-import { ScheduleAppointment } from '../admin.models';
+import { matchesScheduleSearch, scheduleCoachBusy, scheduleCoachesWithAppointments, scheduleCourseAvailable, schedulePreflightCandidates, scheduleRowActions } from './operations-schedule.component';
+import { ScheduleAppointment, ScheduleCalendarData } from '../admin.models';
 import { scheduleMatchesStatus, scheduleStatusLabel, scheduleStatusTone } from './operations-schedule-status';
 
 describe('排课搜索匹配', () => {
@@ -19,6 +19,22 @@ describe('排课搜索匹配', () => {
   });
 });
 
+describe('陪练排课泳道', () => {
+  it('只显示当前结果中已经有排课的老师', () => {
+    const coaches = [
+      { userId: 10, displayName: '有课老师' },
+      { userId: 20, displayName: '无课老师' },
+      { userId: 30, displayName: '历史账号老师' },
+    ] as ScheduleCalendarData['coaches'];
+    const appointments = [
+      { coachId: 10, coachAccountMissing: false },
+      { coachId: 30, coachAccountMissing: true },
+    ] as ScheduleAppointment[];
+
+    expect(scheduleCoachesWithAppointments(coaches, appointments).map((coach) => coach.userId)).toEqual([10]);
+  });
+});
+
 describe('排课状态视觉分类', () => {
   const now = Date.parse('2026-08-20T10:00:00+08:00');
 

+ 31 - 12
projects/xiaoshu-admin/src/app/pages/operations-schedule.component.ts

@@ -1,8 +1,9 @@
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
-import { CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, CircleAlert, LucideAngularModule, Plus, RefreshCw, Search, X } from 'lucide-angular';
+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 { OperationsService } from '../operations.service';
 import { ScheduleStatusFilter, ScheduleStatusTone, scheduleMatchesStatus, scheduleStatusLabel, scheduleStatusTone } from './operations-schedule-status';
 
@@ -51,6 +52,18 @@ export function scheduleCourseAvailable(
   return course.availableToAll === true || (course.memberIds || []).includes(studentId);
 }
 
+export function scheduleCoachesWithAppointments(
+  coaches: ScheduleCalendarData['coaches'],
+  appointments: ScheduleAppointment[],
+): ScheduleCalendarData['coaches'] {
+  const scheduledCoachIds = new Set(
+    appointments
+      .filter((appointment) => appointment.coachId > 0 && !appointment.coachAccountMissing)
+      .map((appointment) => appointment.coachId),
+  );
+  return coaches.filter((coach) => scheduledCoachIds.has(coach.userId));
+}
+
 export type SchedulePreflightCandidate = {
   index: number;
   startsAt: string;
@@ -101,18 +114,20 @@ export function scheduleCoachBusy(
   });
 }
 
-@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 })
+@Component({ selector:'app-operations-schedule', standalone:true, imports:[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 {
-  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 scheduleSearch=signal(''); readonly statusFilter=signal<ScheduleStatusFilter>('all');
-  readonly icons={CalendarDays,CheckCircle2,ChevronLeft,ChevronRight,CircleAlert,Plus,RefreshCw,Search,X}; private readonly operations=inject(OperationsService);
+  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 feedback=signal<Record<string,unknown>|null>(null); 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 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 memberSearch=signal(''); readonly courseSearch=signal(''); readonly coachSearch=signal(''); readonly selectedMemberObjectId=signal(''); readonly activeSearch=signal<SearchField|null>(null); readonly scheduleSearchInput=signal(''); readonly statusFilterInput=signal<ScheduleStatusFilter>('all'); readonly scheduleSearch=signal(''); readonly statusFilter=signal<ScheduleStatusFilter>('all'); readonly searching=signal(false);
+  readonly icons={CalendarDays,CheckCircle2,ChevronLeft,ChevronRight,CircleAlert,LoaderCircle,Plus,RefreshCw,RotateCcw,Search,X}; private readonly operations=inject(OperationsService);
   readonly activeAppointments=computed(()=>this.data()?.appointments.filter(item=>!item.cancelled).length||0);
   readonly visibleAppointments=computed(()=>(this.data()?.appointments||[]).filter(item=>scheduleMatchesStatus(item,this.statusFilter())&&matchesScheduleSearch(this.scheduleSearch(),[item.studentName,item.coachName,item.courseName,item.studentId,item.coachId,item.courseId,item.generalId])));
+  readonly visibleCoaches=computed(()=>scheduleCoachesWithAppointments(this.data()?.coaches||[],this.visibleAppointments()));
   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.courseId])).slice(0,12));
+  readonly selectedCourse=computed(()=>this.data()?.courses.find((course)=>course.courseId===this.courseId())||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()));
   private initialRange=true;
@@ -128,27 +143,31 @@ 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();}
-  clearScheduleFilters():void{this.scheduleSearch.set('');this.statusFilter.set('all');}
+  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.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{if(item.readOnly){this.message.set('这是教师端实时业务数据,目前可在后台查看;修改、取消和完课仍请在原业务端操作。');return;}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);}
+  openCreate():void{this.editing.set(null);this.studentId.set(0);this.courseId.set(0);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{if(item.readOnly){this.message.set('这是教师端实时业务数据,目前可在后台查看;修改、取消和完课仍请在原业务端操作。');return;}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.durationMinutes.set(item.durationMinutes||(item.classType===2||item.classType===3?60:30));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);}
+  async viewFeedback(item:ScheduleAppointment):Promise<void>{this.saving.set(true);this.error.set('');try{this.feedback.set(await this.operations.request<Record<string,unknown>>('ops/lesson-feedback/detail',{targetId:item.targetId}));}catch(error){this.error.set(error instanceof Error?error.message:'完课反馈加载失败');}finally{this.saving.set(false);}}
+  closeFeedback():void{this.feedback.set(null);}
   setMode(value:'coach'|'time'):void{this.mode.set(value);this.activeSearch.set(null);this.preview.set(null);}
   openSearch(field:SearchField):void{this.activeSearch.set(field);}
   updateMemberSearch(value:string):void{this.memberSearch.set(value);this.studentId.set(0);this.selectedMemberObjectId.set('');this.courseId.set(0);this.courseSearch.set('');this.preview.set(null);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{if(member.readOnly){this.error.set(member.readOnlyReason||'该会员尚未完成持续同步,当前只能查看实时资料,暂不能从新后台排课。');return;}const selectedCourse=this.data()?.courses.find(course=>course.courseId===this.courseId());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.courseSearch.set('');}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);}
+  chooseCourse(course:ScheduleCalendarData['courses'][number]):void{this.courseId.set(course.courseId);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);}
   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();}
   updateClassType(value:number):void{this.classType.set(Number(value));this.preview.set(null);}
+  updateDuration(value:number):void{this.durationMinutes.set(Number(value));this.preview.set(null);}
   updateStartTime(value:string):void{this.startTime.set(value);this.preview.set(null);}
-  coachBusyAtSelectedTime(coachId:number):boolean{return scheduleCoachBusy(this.data()?.appointments||[],coachId,`${this.date()}T${this.startTime()}:00+08:00`,this.classType()===1||this.classType()===4?30:60,this.editing()?.targetId||'');}
+  coachBusyAtSelectedTime(coachId:number):boolean{return scheduleCoachBusy(this.data()?.appointments||[],coachId,`${this.date()}T${this.startTime()}:00+08:00`,this.durationMinutes(),this.editing()?.targetId||'');}
   coachCandidateHint(coach:ScheduleCalendarData['coaches'][number]):string{return this.coachBusyAtSelectedTime(coach.userId)?'该时段已有排课':coach.availabilityConfigured?'该时段可继续预检':'未设置可用时间';}
   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()};}
+  payload():Record<string,unknown>{return{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>{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;}if(!this.preview()){this.error.set('请先检查冲突并确认逐项预检结果');return;}if(!this.canSavePreview()){this.error.set('当前没有可以提交的排课,请先调整冲突项');return;}this.saving.set(true);this.error.set('');try{const editing=this.editing(),skipConflicts=!editing&&this.previewConflictCount()>0&&this.previewCandidates().length>1;const result=await this.operations.mutate<{created?:number;skipped?:number}>(editing?'ops/appointments/update':'ops/appointments/create',editing?.targetId||'',this.reason(),{...this.payload(),skipConflicts},editing?.updatedAt||'');this.message.set(editing?'排课已更新':result.skipped?`已创建 ${result.created||0} 节排课,跳过 ${result.skipped} 节冲突项`:`已创建 ${result.created||1} 节排课`);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>{if(item.readOnly){this.message.set('实时业务数据暂不支持从新后台修改,请在原业务端完成操作。');return;}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);}}

+ 5 - 5
projects/xiaoshu-admin/src/app/pages/operations-users.component.html

@@ -1,6 +1,6 @@
 <header class="page-header">
   <div><p>MEMBER OPERATIONS</p><h1>会员管理</h1><span>按真实会员身份、注册时间和学习进度安排日常运营</span></div>
-  <div class="header-actions"><button type="button" (click)="exportCsv()" [disabled]="exporting() || loading()"><lucide-icon [img]="icons.Download" [size]="17" />{{ exporting() ? '导出中…' : '导出' }}</button>@if (canBulkImport()) { <button type="button" (click)="openImport()"><lucide-icon [img]="icons.FileSpreadsheet" [size]="18" />批量导入</button> }<button class="refresh-button" type="button" (click)="refresh()" [disabled]="loading()"><lucide-icon [class.spin]="loading()" [img]="icons.RefreshCw" [size]="17" />{{ loading() ? '正在刷新…' : '刷新数据' }}</button>@if (canCreate()) { <button class="primary" type="button" (click)="openCreate()"><lucide-icon [img]="icons.Plus" [size]="18" />新增会员</button> }</div>
+  <div class="header-actions"><button type="button" (click)="exportCsv()" [disabled]="exporting() || loading()"><lucide-icon [img]="icons.Download" [size]="17" />{{ exporting() ? '导出中…' : '导出' }}</button>@if (canBulkImport()) { <button type="button" (click)="openImport()"><lucide-icon [img]="icons.FileSpreadsheet" [size]="18" />批量导入</button> }<button class="refresh-button" type="button" (click)="refresh()" [disabled]="loading()"><lucide-icon [class.spin]="refreshing()" [img]="icons.RefreshCw" [size]="17" />{{ refreshing() ? '正在刷新…' : '刷新数据' }}</button>@if (canCreate()) { <button class="primary" type="button" (click)="openCreate()"><lucide-icon [img]="icons.Plus" [size]="18" />新增会员</button> }</div>
 </header>
 
 @if (message()) { <p class="notice" role="status">{{ message() }}</p> } @if (error()) { <p class="notice error" role="alert">{{ error() }}</p> }
@@ -14,15 +14,15 @@
 </section>
 
 <section class="filters" aria-label="会员筛选">
-  <label class="search-field"><span>搜索会员</span><div class="search-control"><lucide-icon [img]="icons.Search" [size]="18" /><input [ngModel]="query()" (ngModelChange)="query.set($event)" (keyup.enter)="load(1)" placeholder="姓名、昵称、手机号、会员 ID" /></div></label>
+  <label class="search-field"><span>搜索会员</span><div class="search-control"><lucide-icon [img]="icons.Search" [size]="18" /><input [ngModel]="query()" (ngModelChange)="query.set($event)" (keyup.enter)="searchMembers()" placeholder="姓名、昵称、手机号、会员 ID" /></div></label>
   <label><span>学习状态</span><select [ngModel]="learningStatus()" (ngModelChange)="learningStatus.set($event)"><option value="all">全部学习状态</option><option value="trial">体验用户</option><option value="studying">正在学习</option><option value="completed">已经学完</option><option value="not_started">尚未开始</option></select></label>
   <label><span>账号状态</span><select [ngModel]="status()" (ngModelChange)="status.set($event)"><option value="all">全部账号状态</option><option value="active">正常</option><option value="disabled">已停用</option></select></label>
   <label><span>风险状态</span><select [ngModel]="risk()" (ngModelChange)="risk.set($event)"><option value="all">全部风险</option><option value="待排课">待排课</option><option value="低课时">低课时</option><option value="长期未上课">长期未上课</option><option value="预约异常">预约异常</option><option value="none">无风险</option></select></label>
   <label><span>所属门店/代理</span><select [ngModel]="parentUserId()" (ngModelChange)="parentUserId.set(+$event)"><option [ngValue]="0">全部门店/代理</option>@for (agent of page()?.agents || []; track agent.userId) { <option [ngValue]="agent.userId">{{ agent.displayName }}</option> }</select></label>
-  <label><span>注册时间从</span><input type="date" [ngModel]="registeredFrom()" (ngModelChange)="registeredFrom.set($event)" /></label>
-  <label><span>注册时间至</span><input type="date" [ngModel]="registeredTo()" (ngModelChange)="registeredTo.set($event)" /></label>
+  <app-operations-date-filter label="注册时间从" [value]="registeredFrom()" (valueChange)="registeredFrom.set($event)" />
+  <app-operations-date-filter label="注册时间至" [value]="registeredTo()" (valueChange)="registeredTo.set($event)" />
   <label><span>排序方式</span><select [ngModel]="sortValue()" (ngModelChange)="sortValue.set($event)"><option value="registeredAt:desc">注册时间:最新优先</option><option value="registeredAt:asc">注册时间:最早优先</option><option value="lastLoginAt:desc">最近登录优先</option><option value="lastLessonAt:desc">最近上课优先</option><option value="nextAppointmentAt:asc">下次预约优先</option><option value="remainingPeriods:asc">剩余课时:从少到多</option></select></label>
-  <div class="filter-actions"><button class="primary" type="button" (click)="load(1)"><lucide-icon [img]="icons.Search" [size]="16" />查询</button><button type="button" (click)="reset()">重置</button></div>
+  <div class="filter-actions"><button class="primary" type="button" (click)="searchMembers()" [disabled]="searching() || loading()"><lucide-icon [class.spin]="searching()" [img]="searching() ? icons.LoaderCircle : icons.Search" [size]="16" />{{ searching() ? '正在查找' : '查找' }}</button><button type="button" (click)="reset()" [disabled]="searching() || loading()">重置</button></div>
 </section>
 
 <section class="table-card">

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
projects/xiaoshu-admin/src/app/pages/operations-users.component.scss


+ 89 - 0
projects/xiaoshu-admin/src/app/pages/operations-users.component.spec.ts

@@ -0,0 +1,89 @@
+import { signal } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { provideRouter } from '@angular/router';
+import { MemberPage } from '../admin.models';
+import { AdminSessionService } from '../admin-session.service';
+import { OperationsDateFilterComponent } from '../components/operations-date-filter.component';
+import { OperationsService } from '../operations.service';
+import { OperationsUsersComponent } from './operations-users.component';
+
+const memberPage: MemberPage = {
+  page: 1,
+  pageSize: 20,
+  total: 0,
+  items: [],
+  agents: [],
+  summary: { total: 0, todayNew: 0, sevenDayNew: 0, studying: 0, followUp: 0 },
+  refreshedAt: '2026-09-05T00:00:00.000Z',
+};
+
+describe('OperationsUsersComponent', () => {
+  let fixture: ComponentFixture<OperationsUsersComponent>;
+  let operations: jasmine.SpyObj<OperationsService>;
+
+  beforeEach(async () => {
+    operations = jasmine.createSpyObj<OperationsService>('OperationsService', ['users']);
+    operations.users.and.resolveTo(memberPage);
+    const session = signal({ objectId: 'admin-1', username: 'ops', sessionToken: 'session', operationsRole: 'ops-manager' as const });
+
+    await TestBed.configureTestingModule({
+      imports: [OperationsUsersComponent],
+      providers: [
+        provideRouter([]),
+        { provide: OperationsService, useValue: operations },
+        { provide: AdminSessionService, useValue: { session: session.asReadonly() } },
+      ],
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(OperationsUsersComponent);
+    fixture.detectChanges();
+    await fixture.whenStable();
+    fixture.detectChanges();
+  });
+
+  it('renders the two themed registration date pickers', () => {
+    const pickers = fixture.debugElement.queryAll(By.directive(OperationsDateFilterComponent));
+    expect(pickers.length).toBe(2);
+    expect((fixture.nativeElement as HTMLElement).textContent).toContain('注册时间从');
+    expect((fixture.nativeElement as HTMLElement).textContent).toContain('注册时间至');
+  });
+
+  it('shows an explicit searching state until the member query finishes', async () => {
+    const component = fixture.componentInstance;
+    let resolveRequest!: (value: MemberPage) => void;
+    operations.users.and.returnValue(new Promise((resolve) => { resolveRequest = resolve; }));
+    component.query.set('万宇昊');
+
+    const pending = component.searchMembers();
+    fixture.detectChanges();
+
+    const queryButton = (fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>('.filter-actions .primary');
+    expect(component.searching()).toBeTrue();
+    expect(queryButton?.disabled).toBeTrue();
+    expect(queryButton?.textContent).toContain('正在查找');
+
+    resolveRequest(memberPage);
+    await pending;
+    fixture.detectChanges();
+    expect(component.searching()).toBeFalse();
+    expect(queryButton?.textContent).toContain('查找');
+    expect(operations.users).toHaveBeenCalledWith(1, 20, '万宇昊', jasmine.any(Object), { field: 'registeredAt', direction: 'desc' }, false);
+  });
+
+  it('keeps refresh feedback separate from search feedback', async () => {
+    let resolveRequest!: (value: MemberPage) => void;
+    operations.users.and.returnValue(new Promise((resolve) => { resolveRequest = resolve; }));
+
+    const pending = fixture.componentInstance.refresh();
+    fixture.detectChanges();
+    expect(fixture.componentInstance.refreshing()).toBeTrue();
+    expect(fixture.componentInstance.searching()).toBeFalse();
+    expect((fixture.nativeElement as HTMLElement).textContent).toContain('正在刷新');
+
+    resolveRequest(memberPage);
+    await pending;
+    fixture.detectChanges();
+    expect(fixture.componentInstance.refreshing()).toBeFalse();
+  });
+});

+ 15 - 3
projects/xiaoshu-admin/src/app/pages/operations-users.component.ts

@@ -6,11 +6,12 @@ import { ChevronLeft, ChevronRight, Download, Eye, FileSpreadsheet, LoaderCircle
 import { read, utils, writeFile } from 'xlsx';
 import { MemberAgentOption, MemberImportPreview, MemberListSort, MemberPage } from '../admin.models';
 import { AdminSessionService } from '../admin-session.service';
+import { OperationsDateFilterComponent } from '../components/operations-date-filter.component';
 import { OperationsService } from '../operations.service';
 
 @Component({
   selector: 'app-operations-users', standalone: true,
-  imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule],
+  imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule, OperationsDateFilterComponent],
   templateUrl: './operations-users.component.html', styleUrl: './operations-users.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
@@ -18,7 +19,7 @@ export class OperationsUsersComponent implements OnInit {
   readonly page = signal<MemberPage | null>(null);
   readonly query = signal(''); readonly status = signal('all'); readonly learningStatus = signal('all'); readonly risk = signal('all');
   readonly parentUserId = signal(0); readonly registeredFrom = signal(''); readonly registeredTo = signal(''); readonly sortValue = signal('registeredAt:desc');
-  readonly loading = signal(true); readonly saving = signal(false); readonly exporting = signal(false); readonly error = signal(''); readonly message = signal(''); readonly lastRefreshedAt = signal('');
+  readonly loading = signal(true); readonly searching = signal(false); readonly refreshing = signal(false); readonly saving = signal(false); readonly exporting = signal(false); readonly error = signal(''); readonly message = signal(''); readonly lastRefreshedAt = signal('');
   readonly createOpen = signal(false); readonly createName = signal(''); readonly createNickname = signal(''); readonly createMobile = signal(''); readonly createGender = signal(''); readonly createGrade = signal(''); readonly createPassword = signal(''); readonly createParentUserId = signal(0); readonly createAgentSearch = signal(''); readonly createReason = signal('新增会员');
   readonly importOpen = signal(false); readonly importFileName = signal(''); readonly importRows = signal<Record<string, unknown>[]>([]); readonly importPreview = signal<MemberImportPreview | null>(null); readonly importReason = signal('批量导入会员'); readonly importing = signal(false); readonly importIdempotencyKey = signal('');
   readonly icons = { ChevronLeft, ChevronRight, Download, Eye, FileSpreadsheet, LoaderCircle, Plus, RefreshCw, Search, Upload, UserPlus, Users, X };
@@ -38,7 +39,18 @@ export class OperationsUsersComponent implements OnInit {
     catch (error) { this.error.set(error instanceof Error ? error.message : '会员列表加载失败'); }
     finally { this.loading.set(false); }
   }
-  refresh(): void { void this.load(this.page()?.page ?? 1, true); }
+  async searchMembers(): Promise<void> {
+    if (this.searching()) return;
+    this.searching.set(true);
+    try { await this.load(1); }
+    finally { this.searching.set(false); }
+  }
+  async refresh(): Promise<void> {
+    if (this.loading()) return;
+    this.refreshing.set(true);
+    try { await this.load(this.page()?.page ?? 1, true); }
+    finally { this.refreshing.set(false); }
+  }
   reset(): void { this.query.set(''); this.status.set('all'); this.learningStatus.set('all'); this.risk.set('all'); this.parentUserId.set(0); this.registeredFrom.set(''); this.registeredTo.set(''); this.sortValue.set('registeredAt:desc'); void this.load(1); }
   pageCount(): number { const value = this.page(); return Math.max(1, Math.ceil((value?.total ?? 0) / (value?.pageSize ?? 20))); }
   selectCreateAgent(agent: MemberAgentOption): void { this.createParentUserId.set(agent.userId); this.createAgentSearch.set(agent.displayName); }

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 6 - 0
scripts/deploy-admin-functions.mjs


+ 6 - 0
scripts/smoke-operations-functions.mjs

@@ -165,6 +165,12 @@ try {
   if (!Array.isArray(learningReports.items) || typeof learningReports.total !== 'number' || !learningReports.summary || !learningReports.sourceStatus) throw new Error('学习报表列表、摘要或同步状态结构无效');
   if (learningReports.sourceStatus.state === 'not_configured') throw new Error('学习记录持续同步桥尚未配置,无法保证报表包含最新数据');
   if (!learningReports.items.some((item) => Number(item.generalId) === 855751) || !learningReports.items.some((item) => String(item.studyDate || '') >= '2026-09-04')) throw new Error(`学习报表没有同步旧系统 2026-09-04 样本:${JSON.stringify({ total: learningReports.total, sourceStatus: learningReports.sourceStatus, ids: learningReports.items.map((item) => item.generalId) })}`);
+  const studentReports = (await ops({ operation: 'ops/learning-reports/list', page: 1, pageSize: 20, search: '熊诗琦', filters: { month: '2026-09' } })).data;
+  if (!studentReports.total || studentReports.items.some((item) => !String(item.studentName || '').includes('熊诗琦'))) throw new Error(`学习报表学员姓名筛选无效:${JSON.stringify({ total: studentReports.total, names: studentReports.items.map((item) => item.studentName) })}`);
+  const coachReports = (await ops({ operation: 'ops/learning-reports/list', page: 1, pageSize: 20, search: '', filters: { month: '2026-09', coachSearch: '樊颖' } })).data;
+  if (!coachReports.total || coachReports.items.some((item) => !String(item.coachName || '').includes('樊颖'))) throw new Error(`学习报表陪练筛选无效:${JSON.stringify({ total: coachReports.total, names: coachReports.items.map((item) => item.coachName) })}`);
+  const courseReports = (await ops({ operation: 'ops/learning-reports/list', page: 1, pageSize: 20, search: '', filters: { month: '2026-09', courseSearch: '人教版(PEP)' } })).data;
+  if (!courseReports.total || courseReports.items.some((item) => !String(item.courseName || '').includes('人教版(PEP)'))) throw new Error(`学习报表课程筛选无效:${JSON.stringify({ total: courseReports.total, names: courseReports.items.map((item) => item.courseName) })}`);
   const exportableReport = learningReports.items.find((item) => item.canExport);
   if (exportableReport) {
     const reportDetail = (await ops({ operation: 'ops/learning-reports/detail', targetId: exportableReport.targetId })).data;

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů