Procházet zdrojové kódy

统一陪练训练费用用词并增加老师月度费用汇总

彭峰 před 3 dny
rodič
revize
dd4d56ee67
53 změnil soubory, kde provedl 193 přidání a 168 odebrání
  1. 4 4
      projects/xiaoshu-admin/src/app/admin.constants.ts
  2. 2 2
      projects/xiaoshu-admin/src/app/admin.routes.ts
  3. 1 1
      projects/xiaoshu-admin/src/app/components/member-credit-account.component.html
  4. 1 1
      projects/xiaoshu-admin/src/app/components/store-operations-trend.component.html
  5. 4 4
      projects/xiaoshu-admin/src/app/pages/admin-dashboard.component.html
  6. 1 1
      projects/xiaoshu-admin/src/app/pages/admin-dashboard.component.spec.ts
  7. 1 1
      projects/xiaoshu-admin/src/app/pages/admin-login.component.html
  8. 3 3
      projects/xiaoshu-admin/src/app/pages/lesson-record-detail.component.html
  9. 5 5
      projects/xiaoshu-admin/src/app/pages/lesson-records.component.html
  10. 1 1
      projects/xiaoshu-admin/src/app/pages/lesson-records.component.spec.ts
  11. 1 1
      projects/xiaoshu-admin/src/app/pages/lesson-records.component.ts
  12. 17 17
      projects/xiaoshu-admin/src/app/pages/operations-coach-detail.component.html
  13. 1 1
      projects/xiaoshu-admin/src/app/pages/operations-coach-detail.component.spec.ts
  14. 7 7
      projects/xiaoshu-admin/src/app/pages/operations-coaches.component.html
  15. 1 1
      projects/xiaoshu-admin/src/app/pages/operations-coaches.component.ts
  16. 2 2
      projects/xiaoshu-admin/src/app/pages/operations-list.component.ts
  17. 2 2
      projects/xiaoshu-admin/src/app/pages/operations-payroll.component.html
  18. 6 6
      projects/xiaoshu-admin/src/app/pages/operations-payroll.component.spec.ts
  19. 1 1
      projects/xiaoshu-admin/src/app/pages/operations-payroll.component.ts
  20. 1 1
      projects/xiaoshu-admin/src/app/pages/operations-schedule-status.ts
  21. 10 10
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.html
  22. 7 7
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.spec.ts
  23. 1 1
      projects/xiaoshu-admin/src/app/pages/operations-schedule.component.ts
  24. 10 10
      projects/xiaoshu-admin/src/app/pages/operations-store-detail.component.html
  25. 3 3
      projects/xiaoshu-admin/src/app/pages/operations-stores.component.html
  26. 1 1
      projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.html
  27. 3 3
      projects/xiaoshu-admin/src/app/pages/operations-users.component.html
  28. 1 1
      projects/xiaoshu-admin/src/app/pages/operations-users.component.ts
  29. 2 2
      projects/xiaoshu-admin/src/app/pages/payroll-cutoff.component.ts
  30. 8 8
      projects/xiaoshu-admin/src/app/pages/review-rewards.component.html
  31. 10 10
      projects/xiaoshu-admin/src/app/pages/review-rewards.component.ts
  32. 1 1
      projects/xiaoshu-admin/src/app/pages/schedule-course-standards.ts
  33. 1 1
      projects/xiaoshu-admin/src/app/pages/store-home.component.ts
  34. 1 1
      projects/xiaoshu-admin/src/app/shared/dropdown.directive.spec.ts
  35. 9 9
      projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts
  36. 2 2
      projects/xiaoshu-mobile/src/app/core/page-catalog.ts
  37. 2 2
      projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.html
  38. 1 1
      projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts
  39. 1 1
      projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.spec.ts
  40. 7 6
      projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.html
  41. 5 2
      projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.ts
  42. 1 1
      projects/xiaoshu-mobile/src/app/features/home/account-membership.spec.ts
  43. 6 6
      projects/xiaoshu-mobile/src/app/features/home/account-page.component.html
  44. 1 1
      projects/xiaoshu-mobile/src/app/features/home/account-page.component.ts
  45. 7 7
      projects/xiaoshu-mobile/src/app/features/home/home-page.component.html
  46. 2 2
      projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html
  47. 1 1
      projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts
  48. 1 1
      projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.html
  49. 3 2
      projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.ts
  50. 1 1
      scripts/cloud/mobile-teaching.js
  51. 7 1
      scripts/deploy-admin-functions.mjs
  52. 5 1
      scripts/tests/review-student-names.test.mjs
  53. 10 0
      scripts/tests/teacher-earnings.test.mjs

+ 4 - 4
projects/xiaoshu-admin/src/app/admin.constants.ts

@@ -24,10 +24,10 @@ export const OPERATIONS_NAVIGATION: readonly AdminNavigationGroup[] = [
     { label: '门店管理', icon: 'store', route: '/admin/stores' },
     { label: '排课中心', icon: 'calendar-days', route: '/admin/schedule' },
     { label: '陪练老师', icon: 'graduation-cap', route: '/admin/coaches' },
-    { label: '工资结算', icon: 'wallet-cards', route: '/admin/payroll' },
+    { label: '费用结算', icon: 'wallet-cards', route: '/admin/payroll' },
   ] },
   { label: '记录管理', items: [
-    { label: '上课记录', icon: 'clipboard-list', route: '/admin/lessons' },
+    { label: '训练记录', icon: 'clipboard-list', route: '/admin/lessons' },
     { label: '学习报表', icon: 'file-chart-column', route: '/admin/learning-reports' },
     { label: '反馈与休假申请', icon: 'message-square-text', route: '/admin/support-requests' },
   ] },
@@ -45,9 +45,9 @@ export const STORE_NAVIGATION: readonly AdminNavigationGroup[] = [
   {label:'会员管理',icon:'users',route:'/admin/members'},
   {label:'排课中心',icon:'calendar-days',route:'/admin/schedule'},
   {label:'陪练老师',icon:'graduation-cap',route:'/admin/coaches'},
-  {label:'工资结算',icon:'wallet-cards',route:'/admin/payroll'}]},
+  {label:'费用结算',icon:'wallet-cards',route:'/admin/payroll'}]},
  {label:'记录管理',items:[
-  {label:'上课记录',icon:'clipboard-list',route:'/admin/lessons'},
+  {label:'训练记录',icon:'clipboard-list',route:'/admin/lessons'},
   {label:'学习报表',icon:'file-chart-column',route:'/admin/learning-reports'},
   {label:'反馈与休假申请',icon:'message-square-text',route:'/admin/support-requests'}]},
  {label:'个人设置',items:[{label:'修改密码',icon:'key-round',route:'/admin/change-password'}]}

+ 2 - 2
projects/xiaoshu-admin/src/app/admin.routes.ts

@@ -14,12 +14,12 @@ const operationsChildren: Routes = [
   { path: 'stores', data: { title: '门店管理', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-stores.component').then((module) => module.OperationsStoresComponent) },
   { path: 'stores/:objectId', data: { title: '门店详情', parentUrl: '/admin/stores', parentTitle: '门店管理' }, loadComponent: () => import('./pages/operations-store-detail.component').then((module) => module.OperationsStoreDetailComponent) },
   { path: 'schedule', data: { title: '排课中心', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-schedule.component').then((module) => module.OperationsScheduleComponent) },
-  { path: 'lessons', data: { title: '上课记录', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/lesson-records.component').then(m => m.LessonRecordsComponent) },
+  { path: 'lessons', data: { title: '训练记录', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/lesson-records.component').then(m => m.LessonRecordsComponent) },
   { path: 'learning-reports', data: { title: '学习报表', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-learning-reports.component').then((module) => module.OperationsLearningReportsComponent) },
   { path: 'appointments', pathMatch: 'full', redirectTo: 'schedule' },
   { path: 'coaches', data: { title: '陪练老师', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-coaches.component').then((module) => module.OperationsCoachesComponent) },
   { path: 'coaches/:objectId', data: { title: '老师详情', parentUrl: '/admin/coaches', parentTitle: '陪练老师' }, loadComponent: () => import('./pages/operations-coach-detail.component').then((module) => module.OperationsCoachDetailComponent) },
-  { path: 'payroll', data: { title: '工资结算', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-payroll.component').then((module) => module.OperationsPayrollComponent) },
+  { path: 'payroll', data: { title: '费用结算', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-payroll.component').then((module) => module.OperationsPayrollComponent) },
   { path: 'vocabulary', data: { title: '词库管理', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-vocabulary.component').then((module) => module.OperationsVocabularyComponent) },
   { path: 'special-training', data: { title: '专项训练', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-special-training.component').then((module) => module.OperationsSpecialTrainingComponent) },
   { path: 'operator-accounts', data: { title: '运营账号管理', parentUrl: '/admin/dashboard' }, canActivate: [operatorManagerGuard], loadComponent: () => import('./pages/operator-accounts.component').then(module => module.OperatorAccountsComponent) },

+ 1 - 1
projects/xiaoshu-admin/src/app/components/member-credit-account.component.html

@@ -30,7 +30,7 @@
 @if(!store){<section class="ledger-panel"><header class="section-header"><h3>全部课时流水</h3><button type="button" (click)="export()" [disabled]="exporting()">{{exporting()?'正在导出…':'导出 Excel'}}</button></header><p>时间均为北京时间;成套充值按一笔业务展示,可展开查看双方账户明细。</p>
 <div class="form-grid ledger-filters"><label>账户<select [(ngModel)]="filterAccount"><option value="">全部账户</option>@for(a of data()?.accounts||[];track a.account){<option [value]="a.account">{{a.label}}</option>}</select></label><label class="credit-direction">业务类型<select [(ngModel)]="filterKind"><option value="">全部业务</option>@for(k of ['transfer','refund','adjust','lesson','recharge','deduction'];track k){<option [value]="k">{{kindLabel(k)}}</option>}</select></label><app-operations-date-filter label="开始日期" [value]="dateFrom" (valueChange)="dateFrom=$event"/><app-operations-date-filter label="结束日期" [value]="dateTo" (valueChange)="dateTo=$event"/><div class="account-actions"><button type="button" (click)="query()">查询</button><button type="button" (click)="query(true)">重置</button></div></div>
 @if(ledgerError()){<p role="alert" class="error">{{ledgerError()}}</p>}@if(ledgerBusy()){<p role="status">正在加载流水…</p>}
-@for(row of ledger()?.items||[];track row.groupId){<details class="ledger-entry"><summary><time>{{row.at|date:'yyyy-MM-dd HH:mm':'+0800'}}</time><strong>{{kindLabel(row.lines[0].kind)}}</strong><span>@for(l of row.lines;track l.account){ {{label(l.account)}} {{l.delta>0?'+':''}}{{l.delta}} }</span><small>{{row.lines[0].reason||'未记录'}}</small></summary><div class="scroll"><table><thead><tr><th>账户</th><th>增减</th><th>调整前</th><th>调整后</th><th>操作人</th><th>来源与单据</th><th>门店</th></tr></thead><tbody>@for(l of row.lines;track l.account){<tr><td>{{label(l.account)}}</td><td>{{l.delta}}</td><td>{{l.before??'未记录'}}</td><td>{{l.after??'未记录'}}</td><td>{{l.operator||'未记录'}}</td><td>{{l.source==='legacy'?'旧系统':'新系统'}}<small>{{l.reference}}</small>@if(l.lessonId&&!isStore()){<button type="button" (click)="lessonId=l.lessonId">查看上课记录</button>}</td><td>{{l.storeId||'未记录'}}@if(l.balances){<small>余额 {{l.balances.storeBefore??'—'}} → {{l.balances.storeAfter??'—'}}</small>}</td></tr>}</tbody></table></div><p>原因:{{row.lines[0].reason||'未记录'}}</p></details>}@empty{ @if(!ledgerBusy()&&!ledgerError()){<p>暂无符合条件的流水</p>} }
+@for(row of ledger()?.items||[];track row.groupId){<details class="ledger-entry"><summary><time>{{row.at|date:'yyyy-MM-dd HH:mm':'+0800'}}</time><strong>{{kindLabel(row.lines[0].kind)}}</strong><span>@for(l of row.lines;track l.account){ {{label(l.account)}} {{l.delta>0?'+':''}}{{l.delta}} }</span><small>{{row.lines[0].reason||'未记录'}}</small></summary><div class="scroll"><table><thead><tr><th>账户</th><th>增减</th><th>调整前</th><th>调整后</th><th>操作人</th><th>来源与单据</th><th>门店</th></tr></thead><tbody>@for(l of row.lines;track l.account){<tr><td>{{label(l.account)}}</td><td>{{l.delta}}</td><td>{{l.before??'未记录'}}</td><td>{{l.after??'未记录'}}</td><td>{{l.operator||'未记录'}}</td><td>{{l.source==='legacy'?'旧系统':'新系统'}}<small>{{l.reference}}</small>@if(l.lessonId&&!isStore()){<button type="button" (click)="lessonId=l.lessonId">查看训练记录</button>}</td><td>{{l.storeId||'未记录'}}@if(l.balances){<small>余额 {{l.balances.storeBefore??'—'}} → {{l.balances.storeAfter??'—'}}</small>}</td></tr>}</tbody></table></div><p>原因:{{row.lines[0].reason||'未记录'}}</p></details>}@empty{ @if(!ledgerBusy()&&!ledgerError()){<p>暂无符合条件的流水</p>} }
 <div class="section-header"><span>共 {{ledger()?.total||0}} 笔业务</span><label>每页<select [(ngModel)]="pageSize" (ngModelChange)="query()"><option [ngValue]="20">20 条</option><option [ngValue]="50">50 条</option><option [ngValue]="100">100 条</option></select></label><div class="account-actions"><button type="button" (click)="turn(-1)" [disabled]="page<=1||ledgerBusy()">上一页</button><span>{{page}}</span><button type="button" (click)="turn(1)" [disabled]="page*pageSize>=(ledger()?.total||0)||ledgerBusy()">下一页</button></div></div></section>}
 </section>
 @if(lessonId){<app-lesson-record-detail [lessonId]="lessonId" (closed)="lessonId=''"/>}

+ 1 - 1
projects/xiaoshu-admin/src/app/components/store-operations-trend.component.html

@@ -20,7 +20,7 @@
     @if(trend();as chart){
       <div class="trend-summary overview-metrics">
         @if(selectedTrendPoint();as selected){
-          <div class="trend-stat courses"><span>{{selected.isToday?'今日排课':'当日排课'}}</span><strong>{{selected.courseCount | number}}</strong><small>节课程</small></div>
+          <div class="trend-stat courses"><span>{{selected.isToday?'今天训练':'当日排课'}}</span><strong>{{selected.courseCount | number}}</strong><small>节课程</small></div>
           <div class="trend-stat students"><span>{{selected.isToday?'今日学员':'当日学员'}}</span><strong>{{selected.studentCount | number}}</strong><small>位不同学员</small></div>
         }
 

+ 4 - 4
projects/xiaoshu-admin/src/app/pages/admin-dashboard.component.html

@@ -12,12 +12,12 @@
   @if (data(); as dashboard) {
   <section class="welcome">
     <div><span>{{ dashboard.date }}</span><h2>{{ dashboard.identity.displayName || dashboard.identity.username }},欢迎回来</h2><p>当前角色:{{ dashboard.identity.operationsRole === 'ops-manager' ? '运营管理者' : dashboard.identity.operationsRole === 'ops-staff' ? '普通运营' : '只读审计' }} · {{ dashboard.sync?.label || (dashboard.liveSourceAvailable?'实时排课已读取':'当前使用迁移库备用数据') }}</p></div>
-    <a routerLink="/admin/schedule">查看今日排课<lucide-icon [img]="icons.ArrowRight" [size]="18" /></a>
+    <a routerLink="/admin/schedule">查看今天训练<lucide-icon [img]="icons.ArrowRight" [size]="18" /></a>
   </section>
 
   <section class="trend-card" aria-labelledby="schedule-trend-title" aria-describedby="schedule-trend-description">
     <header>
-      <div><span>OPERATIONS PULSE</span><h2 id="schedule-trend-title">运营数据总览</h2><p id="schedule-trend-description">每日排课与学员趋势、陪练人力和预估工资统一查看</p></div>
+      <div><span>OPERATIONS PULSE</span><h2 id="schedule-trend-title">运营数据总览</h2><p id="schedule-trend-description">每日排课与学员趋势、陪练人力和预估费用统一查看</p></div>
       <div class="trend-header-actions">
         <div class="trend-periods" aria-label="排课趋势时间维度">
           <button type="button" [class.active]="trendPeriod()==='week'" (click)="selectTrendPeriod('week')">周</button>
@@ -37,14 +37,14 @@
     @if(trend();as chart){
       <div class="trend-summary overview-metrics">
         @if(selectedTrendPoint();as selected){
-          <div class="trend-stat courses"><span>{{selected.isToday?'今日排课':'当日排课'}}</span><strong>{{selected.courseCount | number}}</strong><small>节课程</small></div>
+          <div class="trend-stat courses"><span>{{selected.isToday?'今天训练':'当日排课'}}</span><strong>{{selected.courseCount | number}}</strong><small>节课程</small></div>
           <div class="trend-stat students"><span>{{selected.isToday?'今日学员':'当日学员'}}</span><strong>{{selected.studentCount | number}}</strong><small>位不同学员</small></div>
         }
         @if(dashboardMetric('coaches');as coaches){
           <a class="trend-stat coaches" [routerLink]="coaches.route" [attr.aria-label]="coaches.label+' '+coaches.value"><span>{{coaches.label}}</span><strong>{{coaches.value | number}}</strong><small>人力详情</small><lucide-icon [img]="icons.ArrowRight" [size]="16" /></a>
         }
         @if(dashboardMetric('pay');as pay){
-          <a class="trend-stat pay" [routerLink]="pay.route" [attr.aria-label]="pay.label+' '+pay.value+' 元'"><span>{{pay.label}}</span><strong><i>¥</i>{{pay.value | number:'1.0-2'}}</strong><small>{{dashboard.payroll?.sourceStatus?.mode==='live'?'与工资结算实时同步':'与工资结算缓存同步'}}</small><lucide-icon [img]="icons.ArrowRight" [size]="16" /></a>
+          <a class="trend-stat pay" [routerLink]="pay.route" [attr.aria-label]="pay.label+' '+pay.value+' 元'"><span>{{pay.label}}</span><strong><i>¥</i>{{pay.value | number:'1.0-2'}}</strong><small>{{dashboard.payroll?.sourceStatus?.mode==='live'?'与费用结算实时同步':'与费用结算缓存同步'}}</small><lucide-icon [img]="icons.ArrowRight" [size]="16" /></a>
         }
       </div>
       <div class="trend-chart-heading"><div><strong>每日排课趋势</strong><span>滑动图表或选择数据点查看每天数据</span></div><div class="trend-legend"><span class="courses">排课节数</span><span class="students">学员人数</span></div></div>

+ 1 - 1
projects/xiaoshu-admin/src/app/pages/admin-dashboard.component.spec.ts

@@ -19,7 +19,7 @@ describe('AdminDashboardComponent', () => {
         { key: 'members', label: '在学会员', value: 27, hint: '当前有正式学习任务', route: '/admin/members', tone: 'blue' },
         { key: 'today', label: '今日课程', value: 37, hint: '今天计划进行的课程', route: '/admin/schedule', tone: 'amber' },
         { key: 'coaches', label: '在岗陪练', value: 39, hint: '实时业务账号', route: '/admin/coaches', tone: 'green' },
-        { key: 'pay', label: '本月预估工资', value: 120, hint: '按有效上课记录估算', route: '/admin/payroll', tone: 'rose' }
+        { key: 'pay', label: '本月预估费用', value: 120, hint: '按有效训练记录估算', route: '/admin/payroll', tone: 'rose' }
       ], alerts: [], scheduleTrend: {
         period: 'week', periodLabel: '近 7 天', dateFrom: '2026-08-14', dateTo: '2026-08-20',
         points: Array.from({ length: 7 }, (_, index) => ({ key: `2026-08-${14 + index}`, label: `8月${14 + index}日`, shortLabel: `周${index}`, courseCount: index + 1, studentCount: index, completedCount: index, isToday: index === 6 })),

+ 1 - 1
projects/xiaoshu-admin/src/app/pages/admin-login.component.html

@@ -4,7 +4,7 @@
       <div class="story-brand"><img src="/static/images/logo.png" alt="" /><span>小树陪练</span></div>
       <p class="eyebrow">XIAOSHU PRACTICE</p>
       <h1>让每一次管理,<br />都服务于真实成长。</h1>
-      <p class="story-copy">在一个后台完成会员、陪练、课程与排课管理,清晰掌握上课记录和学习进度,让日常运营更简单、更高效。</p>
+      <p class="story-copy">在一个后台完成会员、陪练、课程与排课管理,清晰掌握训练记录和学习进度,让日常运营更简单、更高效。</p>
     </div>
   </section>
 

+ 3 - 3
projects/xiaoshu-admin/src/app/pages/lesson-record-detail.component.html

@@ -1,5 +1,5 @@
-<div class="backdrop" (click)="close()"></div><aside #detailDrawer class="drawer lesson-drawer" role="dialog" aria-modal="true" aria-label="上课记录详情" (keydown.escape)="close()">
-<header><div><h2>上课记录详情</h2><p class="muted">课堂反馈与抗遗忘安排</p></div><button class="ui-button secondary" [disabled]="busy()" (click)="close()">关闭</button></header>
+<div class="backdrop" (click)="close()"></div><aside #detailDrawer class="drawer lesson-drawer" role="dialog" aria-modal="true" aria-label="训练记录详情" (keydown.escape)="close()">
+<header><div><h2>训练记录详情</h2><p class="muted">课堂反馈与抗遗忘安排</p></div><button class="ui-button secondary" [disabled]="busy()" (click)="close()">关闭</button></header>
 @if(error()){<p class="notice error" role="alert">{{error()}}</p>}@if(message()){<p class="notice" role="status">{{message()}}</p>}
 @if(busy()){<p role="status">正在处理…</p>}
 @if(data();as d){
@@ -14,7 +14,7 @@
 <article class="wide"><h3>学生评价</h3><p>{{d.studentReview.score ? d.studentReview.score+' 分' : '尚未评分'}} · {{d.studentReview.comment||'暂无文字评价'}}</p></article>
 <article class="wide"><h3>备注</h3><p>{{report('notes')}}</p></article>
 </div>
-<section class="review-section"><header><h3>抗遗忘复习安排</h3>@if(d.canTransfer){<button class="ui-button secondary" [disabled]="busy()" (click)="beginTransfer()">更换抗遗忘陪练</button>}</header><p class="muted">已完成任务和工资归属保留原记录。</p>
+<section class="review-section"><header><h3>抗遗忘复习安排</h3>@if(d.canTransfer){<button class="ui-button secondary" [disabled]="busy()" (click)="beginTransfer()">更换抗遗忘陪练</button>}</header><p class="muted">已完成任务和费用归属保留原记录。</p>
 <div class="table-scroll"><table class="data-table review-table"><thead><tr><th>计划日期</th><th>复习陪练</th><th>状态</th></tr></thead><tbody>@for(t of d.tasks;track t.objectId){<tr><td>{{t.plannedDate}}</td><td>{{t.coachName}}</td><td>{{t.status==='1'?'已完成':'未完成'}}</td></tr>}@empty{<tr><td colspan="3">本课暂无关联复习任务</td></tr>}</tbody></table></div>
 </section>
 @if(transferring()){

+ 5 - 5
projects/xiaoshu-admin/src/app/pages/lesson-records.component.html

@@ -1,14 +1,14 @@
 <section class="lesson-history">
-<header class="page-header"><div><h1>{{embedded?'上课记录':'记录管理 · 上课记录'}}</h1><p>查询完整上课历史、查看课堂反馈与转交抗遗忘复习</p></div><button class="ui-button secondary" (click)="export()" [disabled]="exporting()||busy()">{{exporting()?'正在导出…':'导出 Excel'}}</button></header>
+<header class="page-header"><div><h1>{{embedded?'训练记录':'记录管理 · 训练记录'}}</h1><p>查询完整训练历史、查看课堂反馈与转交抗遗忘复习</p></div><button class="ui-button secondary" (click)="export()" [disabled]="exporting()||busy()">{{exporting()?'正在导出…':'导出 Excel'}}</button></header>
 <form class="lesson-filters" (ngSubmit)="query()">
 @if(!embedded){<label>学员姓名<input name="search" [(ngModel)]="search" placeholder="输入学员姓名"></label><label>会员 ID<input name="member" [(ngModel)]="memberId" inputmode="numeric" placeholder="精确查询"></label>}
 <label>授课老师<input name="coach" [(ngModel)]="coachName" placeholder="老师姓名"></label><label>门店<input name="store" [(ngModel)]="storeName" placeholder="门店名称"></label><label>课程<input name="course" [(ngModel)]="courseName" placeholder="课程名称"></label><app-operations-date-filter class="start-date-filter" label="开始日期" [value]="dateFrom" (valueChange)="dateFrom=$event" /><app-operations-date-filter label="结束日期" [value]="dateTo" (valueChange)="dateTo=$event" />
 <div class="filter-actions"><button type="submit" class="ui-button" [disabled]="busy()">查询</button><button type="button" class="ui-button secondary" [disabled]="busy()" (click)="reset()">重置</button></div></form>
-@if(error()){<p role="alert" class="notice error">{{error()}}</p>}@if(busy()){<p class="notice" role="status">正在查询上课记录…</p>}
-@if(data();as d){<div class="record-count"><strong>累计已上课 {{d.cumulativeTotal}} 节</strong><span>当前筛选 {{d.total}} 节 · 时间均为北京时间</span><label>每页<select [(ngModel)]="pageSize" (ngModelChange)="query()" [disabled]="busy()"><option [ngValue]="20">20 条</option><option [ngValue]="50">50 条</option><option [ngValue]="100">100 条</option></select></label>@if(studentId||memberId){<button class="ui-button secondary" [disabled]="busy()" (click)="showAll()">查看该学员全部结果</button>}</div>
-<section class="content-card"><div class="table-scroll"><table class="data-table lesson-table"><thead><tr><th>学员 / 课程</th><th>上课时间 ↓</th><th>授课老师 / 课型</th><th>反馈摘要</th><th>当前复习陪练</th><th>操作</th></tr></thead><tbody>
+@if(error()){<p role="alert" class="notice error">{{error()}}</p>}@if(busy()){<p class="notice" role="status">正在查询训练记录…</p>}
+@if(data();as d){<div class="record-count"><strong>累计已训练 {{d.cumulativeTotal}} 节</strong><span>当前筛选 {{d.total}} 节 · 时间均为北京时间</span><label>每页<select [(ngModel)]="pageSize" (ngModelChange)="query()" [disabled]="busy()"><option [ngValue]="20">20 条</option><option [ngValue]="50">50 条</option><option [ngValue]="100">100 条</option></select></label>@if(studentId||memberId){<button class="ui-button secondary" [disabled]="busy()" (click)="showAll()">查看该学员全部结果</button>}</div>
+<section class="content-card"><div class="table-scroll"><table class="data-table lesson-table"><thead><tr><th>学员 / 课程</th><th>训练时间 ↓</th><th>授课老师 / 课型</th><th>反馈摘要</th><th>当前复习陪练</th><th>操作</th></tr></thead><tbody>
 @for(row of d.items;track row.lessonId){<tr><td><strong>{{row.studentName}}</strong><small>ID {{row.studentId}} · {{row.storeName}}</small><p>{{row.courseName}}</p></td><td class="time-cell">{{row.lessonAt | date:'yyyy-MM-dd HH:mm':'+0800'}}</td><td>{{row.coachName}}<small>{{row.classTypeLabel}}</small></td><td><p class="feedback-excerpt">{{row.feedbackSummary||'未记录'}}</p>@if(row.duplicateCount>1){<small class="exception">同课次有重复来源,已合并计数</small>}</td><td>{{row.reviewCoachLabel}}<small>待复习 {{row.pendingReviews}} 次</small></td><td><button class="ui-button secondary compact" (click)="detailId.set(row.lessonId)">查看反馈</button></td></tr>}
-@empty{<tr><td colspan="6"><div class="empty">没有符合条件的上课记录,可调整姓名或日期后查询。</div></td></tr>}
+@empty{<tr><td colspan="6"><div class="empty">没有符合条件的训练记录,可调整姓名或日期后查询。</div></td></tr>}
 </tbody></table></div></section>
 <div class="lesson-pagination"><span>已显示 {{d.items.length}} 条 / 共 {{d.total}} 条</span>@if(!all){<div><button class="ui-button secondary" [disabled]="busy()||page<=1" (click)="turn(-1)">上一页</button><span>第 {{page}} 页</span><button class="ui-button secondary" [disabled]="busy()||page*pageSize>=d.total" (click)="turn(1)">下一页</button></div>}</div>}
 @if(detailId()){<app-lesson-record-detail [lessonId]="detailId()" (closed)="detailId.set('')" (changed)="load()" />}

+ 1 - 1
projects/xiaoshu-admin/src/app/pages/lesson-records.component.spec.ts

@@ -3,7 +3,7 @@ import {ActivatedRoute,convertToParamMap} from '@angular/router';
 import {OperationsService} from '../operations.service';
 import {LessonRecordsComponent} from './lesson-records.component';
 import {LessonRecordDetailComponent} from './lesson-record-detail.component';
-describe('上课记录与复习转交',()=>{
+describe('训练记录与复习转交',()=>{
  let ops:jasmine.SpyObj<OperationsService>;
  beforeEach(()=>{ops=jasmine.createSpyObj<OperationsService>('OperationsService',['request']);TestBed.configureTestingModule({providers:[{provide:OperationsService,useValue:ops},{provide:ActivatedRoute,useValue:{snapshot:{queryParamMap:convertToParamMap({})}}}]});});
  const page=(items:unknown[],total=2)=>({items,total,cumulativeTotal:51,page:1,pageSize:100,version:'snapshot1'});

+ 1 - 1
projects/xiaoshu-admin/src/app/pages/lesson-records.component.ts

@@ -20,5 +20,5 @@ export class LessonRecordsComponent implements OnInit,OnChanges {
  reset():void{this.search='';this.memberId='';this.coachName='';this.storeName='';this.courseName='';this.dateFrom='';this.dateTo='';this.query();}
  showAll():void{if(!(this.studentId||this.memberId))return;this.all=true;this.page=1;void this.load();}
  turn(delta:number):void{this.page+=delta;void this.load();}
- async export():Promise<void>{if(this.exporting())return;this.exporting.set(true);this.error.set('');const params=this.params();try{const first=await this.ops.request<LessonHistoryPage>('ops/lessons/list',{...params,page:1,pageSize:100});const rows:LessonHistoryRow[]=[...first.items];for(let page=2;rows.length<first.total;page++){const next=await this.ops.request<LessonHistoryPage>('ops/lessons/list',{...params,page,pageSize:100});if(!next.items.length||next.total!==first.total||next.version!==first.version)throw Error('导出期间记录发生变化,请重试');rows.push(...next.items);}if(new Set(rows.map(r=>r.lessonId)).size!==rows.length)throw Error('导出期间排序变化,请重试');const xlsx=await import('xlsx');const sheet=xlsx.utils.json_to_sheet(rows.map(r=>({'会员 ID':r.studentId,'学员':r.studentName,'课程':r.courseName,'门店':r.storeName,'授课老师':r.coachName,'上课时间(北京时间)':r.lessonAt?new Date(r.lessonAt).toLocaleString('sv-SE',{timeZone:'Asia/Shanghai'}):'未记录','课型':r.classTypeLabel,'老师点评':r.coachComment,'学生评分':r.score||'未评分','学生评价':r.studentComment,'反馈摘要':r.feedbackSummary,'复习陪练':r.reviewCoaches.map(c=>c.name).join('、'),'待复习次数':r.pendingReviews})));const book=xlsx.utils.book_new();xlsx.utils.book_append_sheet(book,sheet,'上课记录');xlsx.writeFile(book,'上课记录.xlsx');}catch(e){this.error.set(e instanceof Error?e.message:'导出失败');}finally{this.exporting.set(false);}}
+ async export():Promise<void>{if(this.exporting())return;this.exporting.set(true);this.error.set('');const params=this.params();try{const first=await this.ops.request<LessonHistoryPage>('ops/lessons/list',{...params,page:1,pageSize:100});const rows:LessonHistoryRow[]=[...first.items];for(let page=2;rows.length<first.total;page++){const next=await this.ops.request<LessonHistoryPage>('ops/lessons/list',{...params,page,pageSize:100});if(!next.items.length||next.total!==first.total||next.version!==first.version)throw Error('导出期间记录发生变化,请重试');rows.push(...next.items);}if(new Set(rows.map(r=>r.lessonId)).size!==rows.length)throw Error('导出期间排序变化,请重试');const xlsx=await import('xlsx');const sheet=xlsx.utils.json_to_sheet(rows.map(r=>({'会员 ID':r.studentId,'学员':r.studentName,'课程':r.courseName,'门店':r.storeName,'授课老师':r.coachName,'训练时间(北京时间)':r.lessonAt?new Date(r.lessonAt).toLocaleString('sv-SE',{timeZone:'Asia/Shanghai'}):'未记录','课型':r.classTypeLabel,'老师点评':r.coachComment,'学生评分':r.score||'未评分','学生评价':r.studentComment,'反馈摘要':r.feedbackSummary,'复习陪练':r.reviewCoaches.map(c=>c.name).join('、'),'待复习次数':r.pendingReviews})));const book=xlsx.utils.book_new();xlsx.utils.book_append_sheet(book,sheet,'训练记录');xlsx.writeFile(book,'训练记录.xlsx');}catch(e){this.error.set(e instanceof Error?e.message:'导出失败');}finally{this.exporting.set(false);}}
 }

+ 17 - 17
projects/xiaoshu-admin/src/app/pages/operations-coach-detail.component.html

@@ -8,7 +8,7 @@
       @if(data();as detail){<div class="coach-header-meta"><span>ID {{detail.coach.userId}}</span><span><lucide-icon [img]="icons.Building2" [size]="13"/>{{detail.profile.storeName||'未绑定门店'}}</span><strong>{{detail.profile.statusLabel}}</strong></div>}
     </div>
   </div>
-  @if(data();as detail){<a class="ui-button secondary" routerLink="/admin/payroll" [queryParams]="{tab:'rewards',coachId:detail.coach.userId}">抗遗忘工资明细</a>}
+  @if(data();as detail){<a class="ui-button secondary" routerLink="/admin/payroll" [queryParams]="{tab:'rewards',coachId:detail.coach.userId}">抗遗忘费用明细</a>}
   <button class="ui-button secondary refresh-action" type="button" (click)="load(true)" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="loading()"/>{{loading()?'正在刷新':'刷新实时数据'}}</button>
 </header>
 
@@ -16,7 +16,7 @@
 @if(error()){<p class="notice error" role="alert">{{error()}}</p>}
 
 @if(loading()&&!data()){
-  <div class="state">正在读取老师实时排课、上课记录和工资数据…</div>
+  <div class="state">正在读取老师实时排课、训练记录和费用数据…</div>
 }
 
 @if(data();as detail){
@@ -32,15 +32,15 @@
   <section class="source-strip" [class.cache]="detail.payrollSourceStatus.mode==='cache'" role="status">
     <span></span>
     <strong>{{detail.payrollMonth}} 收益数据</strong>
-    <em>{{detail.payrollSourceStatus.mode==='live'?'旧系统实时上课记录':'迁移库缓存记录'}}</em>
+    <em>{{detail.payrollSourceStatus.mode==='live'?'旧系统实时训练记录':'迁移库缓存记录'}}</em>
     <small>更新于 {{detail.refreshedAt|date:'MM-dd HH:mm:ss'}}</small>
   </section>
 
   <section class="summary-grid coach-summary">
-    <article class="summary-card"><span>今日排课</span><strong>{{detail.coach.todayAppointments}}</strong><small>未来待完成 {{upcomingCount()}} 节</small></article>
+    <article class="summary-card"><span>今天训练</span><strong>{{detail.coach.todayAppointments}}</strong><small>未来待完成 {{upcomingCount()}} 节</small></article>
     <article class="summary-card"><span>{{detail.payrollMonth}} 有效课次</span><strong>{{detail.currentPayroll.lessonCount}}</strong><small>30分钟 {{detail.currentPayroll.type30Count}} · 60分钟 {{detail.currentPayroll.type60Count}}</small></article>
-    <article class="summary-card"><span>{{detail.payrollMonth}} 工时</span><strong>{{detail.currentPayroll.totalHours|number:'1.1-1'}}h</strong><small>体验课 {{detail.currentPayroll.trialCount}} 节</small></article>
-    <article class="summary-card payroll-card"><span>{{detail.payrollMonth}} 预估工资</span><strong>¥{{detail.currentPayroll.payableAmount|number:'1.2-2'}}</strong><small>含抗遗忘工资 ¥{{detail.currentPayroll.reviewAmount||0|number:'1.2-2'}}</small></article>
+    <article class="summary-card"><span>{{detail.payrollMonth}} 工时</span><strong>{{detail.currentPayroll.totalHours|number:'1.1-1'}}h</strong><small>体验训练 {{detail.currentPayroll.trialCount}} 节</small></article>
+    <article class="summary-card payroll-card"><span>{{detail.payrollMonth}} 预估费用</span><strong>¥{{detail.currentPayroll.payableAmount|number:'1.2-2'}}</strong><small>含抗遗忘费用 ¥{{detail.currentPayroll.reviewAmount||0|number:'1.2-2'}}</small></article>
     <article class="summary-card"><span>平均评分</span><strong>{{detail.currentPayroll.averageScore?(detail.currentPayroll.averageScore|number:'1.1-1'):'—'}}</strong><small>{{detail.reviews.length}} 条有效评价</small></article>
     <article class="summary-card"><span>21天复习任务</span><strong>{{detail.reviewTaskCount}}</strong><small>原系统默认不计薪</small></article>
   </section>
@@ -48,26 +48,26 @@
   <section class="content-card coach-detail-content">
     <nav class="tabs coach-tabs" aria-label="陪练老师详情">
       <button type="button" [class.active]="tab()==='schedule'" (click)="tab.set('schedule')"><lucide-icon [img]="icons.CalendarDays" [size]="16"/>排课记录 <span class="tab-month">{{scheduleMonth()}}</span><em>{{detail.appointments.length}}</em></button>
-      <button type="button" [class.active]="tab()==='lessons'" (click)="tab.set('lessons')"><lucide-icon [img]="icons.GraduationCap" [size]="16"/>上课明细 <span class="tab-month">{{lessonsMonth()}}</span><em>{{detail.lessons.length}}</em></button>
+      <button type="button" [class.active]="tab()==='lessons'" (click)="tab.set('lessons')"><lucide-icon [img]="icons.GraduationCap" [size]="16"/>训练明细 <span class="tab-month">{{lessonsMonth()}}</span><em>{{detail.lessons.length}}</em></button>
       <button type="button" [class.active]="tab()==='reviews'" (click)="tab.set('reviews')"><lucide-icon [img]="icons.Star" [size]="16"/>学员评价 <em>{{detail.reviews.length}}</em></button>
       <button type="button" [class.active]="tab()==='availability'" (click)="tab.set('availability')"><lucide-icon [img]="icons.Clock3" [size]="16"/>可用时间</button>
-      <button type="button" [class.active]="tab()==='payroll'" (click)="tab.set('payroll')"><lucide-icon [img]="icons.WalletCards" [size]="16"/>工资记录 <span class="tab-month">{{payrollMonth()}}</span></button>
+      <button type="button" [class.active]="tab()==='payroll'" (click)="tab.set('payroll')"><lucide-icon [img]="icons.WalletCards" [size]="16"/>费用记录 <span class="tab-month">{{payrollMonth()}}</span></button>
     </nav>
 
     @if(tab()==='schedule'){
-      <div class="section-heading"><div><strong>{{scheduleMonth()}} 老师排课记录</strong><span>只筛选排课记录,不影响上课明细和工资记录</span></div><div class="section-month-filter"><span>排课月份</span><button class="month-picker-trigger" data-testid="schedule-month" type="button" aria-label="选择排课月份" aria-haspopup="dialog" [attr.aria-expanded]="activeMonthPicker()==='schedule'" [class.active]="activeMonthPicker()==='schedule'" (click)="toggleMonthPicker('schedule',$event)" [disabled]="loading()||isSectionLoading('schedule')"><lucide-icon [img]="icons.CalendarDays" [size]="16"/><strong>{{monthLabel(scheduleMonth())}}</strong><lucide-icon class="picker-chevron" [img]="icons.ChevronDown" [size]="15"/></button><small>{{isSectionLoading('schedule')?'正在读取…':(detail.appointments.length+' 条排课')}}</small>@if(activeMonthPicker()==='schedule'){<div class="month-calendar-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]="scheduleMonth()===monthOptionValue($index)" [attr.aria-pressed]="scheduleMonth()===monthOptionValue($index)" (click)="selectSectionMonth('schedule',$index)">{{name}}</button>}</div><footer><button type="button" (click)="selectCurrentMonth('schedule')">回到本月</button></footer></div>}</div></div>
-      <div class="table-scroll"><table class="data-table detail-table"><thead><tr><th>上课时间</th><th>学员</th><th>课程</th><th>课型</th><th>状态</th><th>数据来源</th></tr></thead><tbody>
+      <div class="section-heading"><div><strong>{{scheduleMonth()}} 老师排课记录</strong><span>只筛选排课记录,不影响训练明细和费用记录</span></div><div class="section-month-filter"><span>排课月份</span><button class="month-picker-trigger" data-testid="schedule-month" type="button" aria-label="选择排课月份" aria-haspopup="dialog" [attr.aria-expanded]="activeMonthPicker()==='schedule'" [class.active]="activeMonthPicker()==='schedule'" (click)="toggleMonthPicker('schedule',$event)" [disabled]="loading()||isSectionLoading('schedule')"><lucide-icon [img]="icons.CalendarDays" [size]="16"/><strong>{{monthLabel(scheduleMonth())}}</strong><lucide-icon class="picker-chevron" [img]="icons.ChevronDown" [size]="15"/></button><small>{{isSectionLoading('schedule')?'正在读取…':(detail.appointments.length+' 条排课')}}</small>@if(activeMonthPicker()==='schedule'){<div class="month-calendar-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]="scheduleMonth()===monthOptionValue($index)" [attr.aria-pressed]="scheduleMonth()===monthOptionValue($index)" (click)="selectSectionMonth('schedule',$index)">{{name}}</button>}</div><footer><button type="button" (click)="selectCurrentMonth('schedule')">回到本月</button></footer></div>}</div></div>
+      <div class="table-scroll"><table class="data-table detail-table"><thead><tr><th>训练时间</th><th>学员</th><th>课程</th><th>课型</th><th>状态</th><th>数据来源</th></tr></thead><tbody>
         @for(row of detail.appointments;track row.targetId){<tr><td><strong>{{row.startsAt|date:'yyyy-MM-dd HH:mm'}}</strong><small>{{row.endsAt|date:'HH:mm'}} 结束</small></td><td>{{row.studentName}}</td><td>{{row.courseName}}</td><td>{{row.classTypeLabel}}</td><td><span [class]="'appointment-status '+statusTone(row)">{{statusDisplayLabel(row)}}</span></td><td><small class="source-pill">{{row.sourceLabel}}</small></td></tr>}
         @empty{<tr><td colspan="6"><div class="empty">{{isSectionLoading('schedule')?'正在读取排课记录…':'该月没有可展示的排课记录'}}</div></td></tr>}
       </tbody></table></div>
     }@else if(tab()==='lessons'){
-      <div class="section-heading"><div><strong>{{lessonsMonth()}} 上课与计薪明细</strong><span>只筛选上课明细,不影响排课和工资记录,最多展示最近 300 条</span></div><div class="section-tools"><span class="total-chip">合计 ¥{{(lessonsPayroll()?.payableAmount||0)|number:'1.2-2'}}</span><div class="section-month-filter"><span>上课月份</span><button class="month-picker-trigger" data-testid="lessons-month" type="button" aria-label="选择上课月份" aria-haspopup="dialog" [attr.aria-expanded]="activeMonthPicker()==='lessons'" [class.active]="activeMonthPicker()==='lessons'" (click)="toggleMonthPicker('lessons',$event)" [disabled]="loading()||isSectionLoading('lessons')"><lucide-icon [img]="icons.CalendarDays" [size]="16"/><strong>{{monthLabel(lessonsMonth())}}</strong><lucide-icon class="picker-chevron" [img]="icons.ChevronDown" [size]="15"/></button><small>{{isSectionLoading('lessons')?'正在读取…':(detail.lessons.length+' 条明细')}}</small>@if(activeMonthPicker()==='lessons'){<div class="month-calendar-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]="lessonsMonth()===monthOptionValue($index)" [attr.aria-pressed]="lessonsMonth()===monthOptionValue($index)" (click)="selectSectionMonth('lessons',$index)">{{name}}</button>}</div><footer><button type="button" (click)="selectCurrentMonth('lessons')">回到本月</button></footer></div>}</div></div></div>
-      <div class="table-scroll"><table class="data-table detail-table"><thead><tr><th>上课时间</th><th>学员</th><th>课程</th><th>课型</th><th>工时</th><th>单价</th><th>金额</th><th>来源ID</th></tr></thead><tbody>
+      <div class="section-heading"><div><strong>{{lessonsMonth()}} 训练与计薪明细</strong><span>只筛选训练明细,不影响排课和费用记录,最多展示最近 300 条</span></div><div class="section-tools"><span class="total-chip">合计 ¥{{(lessonsPayroll()?.payableAmount||0)|number:'1.2-2'}}</span><div class="section-month-filter"><span>训练月份</span><button class="month-picker-trigger" data-testid="lessons-month" type="button" aria-label="选择训练月份" aria-haspopup="dialog" [attr.aria-expanded]="activeMonthPicker()==='lessons'" [class.active]="activeMonthPicker()==='lessons'" (click)="toggleMonthPicker('lessons',$event)" [disabled]="loading()||isSectionLoading('lessons')"><lucide-icon [img]="icons.CalendarDays" [size]="16"/><strong>{{monthLabel(lessonsMonth())}}</strong><lucide-icon class="picker-chevron" [img]="icons.ChevronDown" [size]="15"/></button><small>{{isSectionLoading('lessons')?'正在读取…':(detail.lessons.length+' 条明细')}}</small>@if(activeMonthPicker()==='lessons'){<div class="month-calendar-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]="lessonsMonth()===monthOptionValue($index)" [attr.aria-pressed]="lessonsMonth()===monthOptionValue($index)" (click)="selectSectionMonth('lessons',$index)">{{name}}</button>}</div><footer><button type="button" (click)="selectCurrentMonth('lessons')">回到本月</button></footer></div>}</div></div></div>
+      <div class="table-scroll"><table class="data-table detail-table"><thead><tr><th>训练时间</th><th>学员</th><th>课程</th><th>课型</th><th>工时</th><th>单价</th><th>金额</th><th>来源ID</th></tr></thead><tbody>
         @for(line of detail.lessons;track line.objectId){<tr><td>{{line.lessonAt|date:'yyyy-MM-dd HH:mm'}}</td><td>{{line.studentName}}</td><td>{{line.courseName}}</td><td>{{line.classTypeLabel}}</td><td>{{line.durationHours|number:'1.1-1'}}h</td><td>¥{{line.rateAmount|number:'1.2-2'}}</td><td class="money">¥{{line.amount|number:'1.2-2'}}</td><td>{{line.legacyGeneralId||'—'}}</td></tr>}
-        @empty{<tr><td colspan="8"><div class="empty">{{isSectionLoading('lessons')?'正在读取上课明细…':'该月没有有效上课记录'}}</div></td></tr>}
+        @empty{<tr><td colspan="8"><div class="empty">{{isSectionLoading('lessons')?'正在读取训练明细…':'该月没有有效训练记录'}}</div></td></tr>}
       </tbody></table></div>
     }@else if(tab()==='reviews'){
-      <div class="section-heading"><div><strong>{{lessonsMonth()}} 学员评价</strong><span>评价跟随“上课明细月份”,来自旧系统上课记录</span></div></div>
+      <div class="section-heading"><div><strong>{{lessonsMonth()}} 学员评价</strong><span>评价跟随“训练明细月份”,来自旧系统训练记录</span></div></div>
       <div class="review-list">
         @for(line of detail.reviews;track line.objectId){<article><header><div><strong>{{line.studentName}}</strong><span>{{line.courseName}} · {{line.lessonAt|date:'MM-dd HH:mm'}}</span></div><div class="score"><lucide-icon [img]="icons.Star" [size]="16"/>{{line.score||'未评分'}}</div></header><p>{{line.comment||'学员已评分,未填写文字评价'}}</p></article>}
         @empty{<div class="empty">本月暂时没有评分或文字评价</div>}
@@ -77,10 +77,10 @@
       <div class="availability-form form-grid"><label><span>规则类型</span><select [ngModel]="ruleType()" (ngModelChange)="ruleType.set($event)"><option value="weekly">每周规则</option><option value="date">指定日期</option></select></label>@if(ruleType()==='weekly'){<label><span>星期</span><select [ngModel]="weekday()" (ngModelChange)="weekday.set(+$event)">@for(day of ['日','一','二','三','四','五','六'];track $index){<option [ngValue]="$index">星期{{day}}</option>}</select></label>}@else{<label><span>日期</span><input type="date" [ngModel]="ruleDate()" (ngModelChange)="ruleDate.set($event)"/></label>}<label><span>开始时间</span><input type="time" [ngModel]="startTime()" (ngModelChange)="startTime.set($event)"/></label><label><span>结束时间</span><input type="time" [ngModel]="endTime()" (ngModelChange)="endTime.set($event)"/></label><label><span>状态</span><select [ngModel]="available()" (ngModelChange)="available.set($event)"><option [ngValue]="true">可排课</option><option [ngValue]="false">休息/请假</option></select></label><label><span>原因</span><input [ngModel]="ruleReason()" (ngModelChange)="ruleReason.set($event)"/></label><button class="ui-button primary" type="button" (click)="saveAvailability()" [disabled]="saving()"><lucide-icon [img]="icons.Plus" [size]="16"/>添加规则</button></div>
       <div class="table-scroll"><table class="data-table detail-table"><thead><tr><th>规则</th><th>日期/星期</th><th>时段</th><th>状态</th><th>原因</th><th>操作</th></tr></thead><tbody>@for(rule of detail.availability;track rule.objectId){<tr><td>{{rule.ruleType==='weekly'?'每周':'指定日期'}}</td><td>{{rule.ruleType==='weekly'?('星期'+['日','一','二','三','四','五','六'][rule.weekday]):rule.date}}</td><td>{{rule.startTime}}–{{rule.endTime}}</td><td><span class="badge">{{rule.available?'可排课':'休息/请假'}}</span></td><td>{{rule.reason}}</td><td><button class="icon-action danger" type="button" aria-label="删除可用时间规则" (click)="removeAvailability(rule.objectId,rule.updatedAt)"><lucide-icon [img]="icons.Trash2" [size]="16"/></button></td></tr>}@empty{<tr><td colspan="6"><div class="empty">尚未配置;排课时允许选择,但会提示未配置可用时间</div></td></tr>}</tbody></table></div>
     }@else{
-      <div class="section-heading"><div><strong>{{payrollMonth()}} 老师工资记录</strong><span>只筛选工资预估和已生成工资批次,不影响排课和上课明细</span></div><div class="section-month-filter"><span>工资月份</span><button class="month-picker-trigger" data-testid="payroll-month" type="button" aria-label="选择工资月份" aria-haspopup="dialog" [attr.aria-expanded]="activeMonthPicker()==='payroll'" [class.active]="activeMonthPicker()==='payroll'" (click)="toggleMonthPicker('payroll',$event)" [disabled]="loading()||isSectionLoading('payroll')"><lucide-icon [img]="icons.CalendarDays" [size]="16"/><strong>{{monthLabel(payrollMonth())}}</strong><lucide-icon class="picker-chevron" [img]="icons.ChevronDown" [size]="15"/></button><small>{{isSectionLoading('payroll')?'正在读取…':(detail.payrolls.length+' 条工资记录')}}</small>@if(activeMonthPicker()==='payroll'){<div class="month-calendar-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]="payrollMonth()===monthOptionValue($index)" [attr.aria-pressed]="payrollMonth()===monthOptionValue($index)" (click)="selectSectionMonth('payroll',$index)">{{name}}</button>}</div><footer><button type="button" (click)="selectCurrentMonth('payroll')">回到本月</button></footer></div>}</div></div>
-      <div class="table-scroll"><table class="data-table detail-table"><thead><tr><th>月份</th><th>状态</th><th>有效课次</th><th>工时</th><th>基础工资</th><th>抗遗忘工资</th><th>调整</th><th>应发金额</th></tr></thead><tbody>
+      <div class="section-heading"><div><strong>{{payrollMonth()}} 老师费用记录</strong><span>只筛选费用预估和已生成费用批次,不影响排课和训练明细</span></div><div class="section-month-filter"><span>费用月份</span><button class="month-picker-trigger" data-testid="payroll-month" type="button" aria-label="选择费用月份" aria-haspopup="dialog" [attr.aria-expanded]="activeMonthPicker()==='payroll'" [class.active]="activeMonthPicker()==='payroll'" (click)="toggleMonthPicker('payroll',$event)" [disabled]="loading()||isSectionLoading('payroll')"><lucide-icon [img]="icons.CalendarDays" [size]="16"/><strong>{{monthLabel(payrollMonth())}}</strong><lucide-icon class="picker-chevron" [img]="icons.ChevronDown" [size]="15"/></button><small>{{isSectionLoading('payroll')?'正在读取…':(detail.payrolls.length+' 条费用记录')}}</small>@if(activeMonthPicker()==='payroll'){<div class="month-calendar-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]="payrollMonth()===monthOptionValue($index)" [attr.aria-pressed]="payrollMonth()===monthOptionValue($index)" (click)="selectSectionMonth('payroll',$index)">{{name}}</button>}</div><footer><button type="button" (click)="selectCurrentMonth('payroll')">回到本月</button></footer></div>}</div></div>
+      <div class="table-scroll"><table class="data-table detail-table"><thead><tr><th>月份</th><th>状态</th><th>有效课次</th><th>工时</th><th>基础费用</th><th>抗遗忘费用</th><th>调整</th><th>应发金额</th></tr></thead><tbody>
         @for(row of detail.payrolls;track row.objectId){<tr><td><strong>{{row.month}}</strong></td><td><span class="badge" [class.warn]="row.objectId.startsWith('preview:')">{{row.statusLabel}}</span></td><td>{{row.lessonCount}}</td><td>{{row.totalHours|number:'1.1-1'}}h</td><td>¥{{row.baseAmount|number:'1.2-2'}}</td><td>¥{{row.reviewAmount||0|number:'1.2-2'}}</td><td>¥{{row.adjustmentAmount|number:'1.2-2'}}</td><td class="money">¥{{row.payableAmount|number:'1.2-2'}}</td></tr>}
-        @empty{<tr><td colspan="8"><div class="empty">{{isSectionLoading('payroll')?'正在读取工资记录…':'该月暂无工资记录'}}</div></td></tr>}
+        @empty{<tr><td colspan="8"><div class="empty">{{isSectionLoading('payroll')?'正在读取费用记录…':'该月暂无费用记录'}}</div></td></tr>}
       </tbody></table></div>
     }
   </section>

+ 1 - 1
projects/xiaoshu-admin/src/app/pages/operations-coach-detail.component.spec.ts

@@ -143,7 +143,7 @@ describe('OperationsCoachDetailComponent month filters', () => {
     const expired = {
       cancelled: false,
       statusCode: 10,
-      statusLabel: '上课中',
+      statusLabel: '训练中',
       endsAt: '2020-01-01T01:00:00.000Z',
     } as ScheduleAppointment;
     const completed = { cancelled: false, statusCode: 30, statusLabel: '已归档', endsAt: '' } as ScheduleAppointment;

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

@@ -1,5 +1,5 @@
 <header class="page-header">
-  <div><p>COACH OPERATIONS</p><h1>陪练老师</h1><span>查看老师排课、完成课次、工时、评分与预估工资</span></div>
+  <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,'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 coach-filters" aria-label="陪练老师筛选" [attr.aria-busy]="querying()">
@@ -12,12 +12,12 @@
 @if(page()?.summary){<section class="coach-summary overview-metrics" aria-label="陪练老师统计">
   <article><span>陪练总数</span><strong>{{page()?.summary?.total||0}}</strong><small>按历史排课与授课证据识别</small></article>
   <article><span>正常账号</span><strong>{{page()?.summary?.active||0}}</strong><small>旧系统账号当前可用</small></article>
-  <article><span>本月有课</span><strong>{{page()?.summary?.monthActive||0}}</strong><small>本月已产生上课记录</small></article>
-  <article><span>已停用</span><strong>{{page()?.summary?.disabled||0}}</strong><small>保留历史排课和工资</small></article>
+  <article><span>本月有课</span><strong>{{page()?.summary?.monthActive||0}}</strong><small>本月已产生训练记录</small></article>
+  <article><span>已停用</span><strong>{{page()?.summary?.disabled||0}}</strong><small>保留历史排课和费用</small></article>
 </section>}
 <section class="content-card">
-  <header><div><lucide-icon [img]="icons.GraduationCap" [size]="19"/><strong>陪练名单</strong></div><span>{{page()?.total||0}} 位 · 按未来排课和最近排课排序 · {{ page()?.sync?.label || (page()?.liveSourceAvailable?'实时账号已读取':'迁移库备用数据') }} · {{page()?.payrollSourceStatus?.mode==='live'?'本月工资已实时同步':'本月工资显示缓存'}}</span></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><th>最近上课</th><th>状态</th><th>操作</th></tr></thead><tbody>
+  <header><div><lucide-icon [img]="icons.GraduationCap" [size]="19"/><strong>陪练名单</strong></div><span>{{page()?.total||0}} 位 · 按未来排课和最近排课排序 · {{ page()?.sync?.label || (page()?.liveSourceAvailable?'实时账号已读取':'迁移库备用数据') }} · {{page()?.payrollSourceStatus?.mode==='live'?'本月费用已实时同步':'本月费用显示缓存'}}</span></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><th>最近训练</th><th>状态</th><th>操作</th></tr></thead><tbody>
     @for(row of page()?.items||[];track row.objectId){<tr><td><strong>{{row.displayName}}</strong><small>{{row.username?row.username+' · ':''}}ID {{row.userId}} · {{row.mobile||row.mobileMasked||'未留手机号'}}</small><small class="source-note">{{row.sourceLabel||'新系统账号'}}</small></td><td>{{row.storeName||'未绑定'}}</td><td>{{row.todayAppointments}}</td><td><strong>{{row.totalAppointments}}</strong></td><td>@if(row.nextAppointmentAt){<span class="schedule-date future">下次 {{row.nextAppointmentAt|date:'yyyy-MM-dd'}}</span>}@else if(row.lastAppointmentAt){<span class="schedule-date">最近 {{row.lastAppointmentAt|date:'yyyy-MM-dd'}}</span>}@else{暂无}</td><td>{{row.pendingAppointments}}</td><td>{{row.monthLessons}}</td><td>{{row.monthHours|number:'1.1-1'}}h</td><td class="money">¥{{row.estimatedPay|number:'1.2-2'}}</td><td>{{row.averageScore? (row.averageScore|number:'1.1-1'):'暂无'}}</td><td>{{row.lastLessonAt?(row.lastLessonAt|date:'MM-dd HH:mm'):'暂无'}}</td><td><span class="badge" [class.muted]="row.status==='disabled'">{{row.statusLabel}}</span>@if(!row.availabilityConfigured&&!row.readOnly){<small class="exception">未设置可用时间</small>}</td><td><div class="row-actions"><a class="icon-action" [routerLink]="['/admin/coaches',row.objectId]" aria-label="查看陪练详情" title="查看"><lucide-icon [img]="icons.Eye" [size]="16"/></a>@if(!row.readOnly&&page()?.canManage){<button class="icon-action" type="button" (click)="openEdit(row)" aria-label="编辑陪练" title="编辑"><lucide-icon [img]="icons.Pencil" [size]="16"/></button><button class="icon-action danger" type="button" (click)="openEdit(row,true)" aria-label="删除陪练" title="删除"><lucide-icon [img]="icons.Trash2" [size]="16"/></button>}@if(row.readOnly){<span class="sync-only">详情只读</span>}</div></td></tr>}
     @empty{<tr><td colspan="13"><div class="empty">没有符合条件的陪练老师</div></td></tr>}
   </tbody></table></div>
@@ -32,13 +32,13 @@
     <div class="form-grid coach-form">
       <label><span>登录账号 <em>*</em></span><input autocomplete="username" [ngModel]="username()" (ngModelChange)="username.set($event)" placeholder="至少 3 位,例如 coach_wang"/></label>
       <label><span>{{editing()?'重置密码':'初始密码'}} <em [class.optional]="editing()">{{editing()?'选填':'*'}}</em></span><input type="password" autocomplete="new-password" [ngModel]="password()" (ngModelChange)="password.set($event)" [placeholder]="editing()?'留空则不修改密码':'至少 8 位'"/></label>
-      <label><span>老师姓名 <em>*</em></span><input [ngModel]="displayName()" (ngModelChange)="displayName.set($event)" placeholder="用于排课和工资单显示"/></label>
+      <label><span>老师姓名 <em>*</em></span><input [ngModel]="displayName()" (ngModelChange)="displayName.set($event)" placeholder="用于排课和费用单显示"/></label>
       <label><span>手机号码</span><input inputmode="numeric" maxlength="11" [ngModel]="mobile()" (ngModelChange)="mobile.set($event)" placeholder="11 位手机号码"/></label>
       <label><span>所属门店</span><select [ngModel]="parentUserId()" (ngModelChange)="parentUserId.set(+$event)">@if(!isStore()){<option [ngValue]="0">暂不绑定门店</option>}@for(store of page()?.stores||[];track store.userId){<option [ngValue]="store.userId">{{store.displayName}}</option>}</select></label>
       <label><span>账号状态</span><select [ngModel]="disabled()" (ngModelChange)="disabled.set($event)"><option [ngValue]="false">正常使用</option><option [ngValue]="true">停用账号</option></select></label>
       <label class="wide"><span>操作原因 <em>*</em></span><textarea rows="3" [ngModel]="reason()" (ngModelChange)="reason.set($event)" placeholder="用于安全审计,例如:新员工入职、资料更正或离职"></textarea></label>
     </div>
-    @if(deleteArmed()){<p class="delete-warning">删除后老师将不能登录,历史排课和工资记录会保留。有未完成排课时系统会阻止删除。</p>}
+    @if(deleteArmed()){<p class="delete-warning">删除后老师将不能登录,历史排课和费用记录会保留。有未完成排课时系统会阻止删除。</p>}
     <footer>@if(editing()){<button class="ui-button danger push-left" type="button" (click)="remove()" [disabled]="saving()"><lucide-icon [img]="icons.Trash2" [size]="16"/>{{deleteArmed()?'确认删除老师':'删除老师'}}</button>}<button class="ui-button secondary" type="button" (click)="closeDrawer()">取消</button><button class="ui-button primary" type="button" (click)="save()" [disabled]="saving()">{{saving()?'保存中…':'保存资料'}}</button></footer>
   </aside>
 }

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

@@ -23,5 +23,5 @@ export class OperationsCoachesComponent implements OnInit,OnDestroy{
  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);}
  async save():Promise<void>{const editing=this.editing();if(!this.username().trim()||!this.displayName().trim()||!this.reason().trim()){this.error.set('登录账号、老师姓名和操作原因必填');return;}if(!editing&&!this.password()){this.error.set('新增老师必须设置初始密码');return;}this.saving.set(true);this.error.set('');try{await this.operations.mutate(editing?'ops/coaches/update':'ops/coaches/create',editing?.objectId||'',this.reason(),{username:this.username(),password:this.password(),displayName:this.displayName(),mobile:this.mobile(),parentUserId:this.parentUserId(),disabled:this.disabled()},editing?.updatedAt||'');this.message.set(editing?'陪练资料已更新':'陪练老师已新增');this.closeDrawer();await this.load(editing?this.page()?.page||1:1,true);}catch(error){this.error.set(error instanceof Error?error.message:'陪练资料保存失败');}finally{this.saving.set(false);}}
- async remove():Promise<void>{const editing=this.editing();if(!editing)return;if(!this.reason().trim()){this.error.set('删除前必须填写操作原因');return;}if(!this.deleteArmed()){this.deleteArmed.set(true);return;}this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/coaches/delete',editing.objectId,this.reason(),{},editing.updatedAt);this.message.set('陪练老师已删除;历史排课和工资记录仍保留');this.closeDrawer();await this.load(1,true);}catch(error){this.error.set(error instanceof Error?error.message:'删除失败');}finally{this.saving.set(false);}}
+ async remove():Promise<void>{const editing=this.editing();if(!editing)return;if(!this.reason().trim()){this.error.set('删除前必须填写操作原因');return;}if(!this.deleteArmed()){this.deleteArmed.set(true);return;}this.saving.set(true);this.error.set('');try{await this.operations.mutate('ops/coaches/delete',editing.objectId,this.reason(),{},editing.updatedAt);this.message.set('陪练老师已删除;历史排课和费用记录仍保留');this.closeDrawer();await this.load(1,true);}catch(error){this.error.set(error instanceof Error?error.message:'删除失败');}finally{this.saving.set(false);}}
 }

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

@@ -14,7 +14,7 @@ const CONFIGS: Record<OperationsListKind, ListConfig> = {
   relations: { title: '陪练与代理关系', eyebrow: 'USER RELATIONS', description: '核对用户、所属代理及直属关系', operationLabel: '关系记录', rowKey: 'userObjectId', columns: [{ key: 'displayName', label: '用户' }, { key: 'userId', label: '用户 ID' }, { key: 'groupName', label: '用户组' }, { key: 'parentDisplayName', label: '所属代理' }, { key: 'parentUserId', label: '代理 ID' }, { key: 'registeredAt', label: '注册时间', format: 'date' }] },
   'course-bindings': { title: '用户课程绑定', eyebrow: 'COURSE BINDINGS', description: '查看学员课程、学习进度和绑定状态', operationLabel: '绑定记录', rowKey: 'targetId', columns: [{ key: 'studentName', label: '学员' }, { key: 'studentId', label: '学员 ID' }, { key: 'courseName', label: '课程' }, { key: 'learnedCount', label: '已学', format: 'number' }, { key: 'openedCount', label: '课程内容量', format: 'number' }, { key: 'progressStage', label: '进度' }, { key: 'statusLabel', label: '状态', format: 'status' }, { key: 'createdAt', label: '绑定时间', format: 'date' }] },
   appointments: { title: '预约排课', eyebrow: 'APPOINTMENTS', description: '按学员、陪练、课程和状态核对预约', operationLabel: '预约记录', rowKey: 'targetId', columns: [{ key: 'studentName', label: '学员' }, { key: 'coachName', label: '陪练' }, { key: 'courseName', label: '课程' }, { key: 'appointmentAt', label: '预约时间', format: 'date' }, { key: 'classTypeLabel', label: '课程类型' }, { key: 'billingTypeLabel', label: '计费方式' }, { key: 'statusLabel', label: '状态', format: 'status' }] },
-  lessons: { title: '上课记录', eyebrow: 'LESSON RECORDS', description: '查看学员、陪练、课程、评价和上课时间', operationLabel: '上课记录', rowKey: 'targetId', columns: [{ key: 'studentName', label: '学员' }, { key: 'coachName', label: '陪练' }, { key: 'courseName', label: '课程' }, { key: 'classTypeLabel', label: '课程类型' }, { key: 'score', label: '评分', format: 'number' }, { key: 'comment', label: '评价' }, { key: 'lessonAt', label: '上课时间', format: 'date' }] },
+  lessons: { title: '训练记录', eyebrow: 'LESSON RECORDS', description: '查看学员、陪练、课程、评价和训练时间', operationLabel: '训练记录', rowKey: 'targetId', columns: [{ key: 'studentName', label: '学员' }, { key: 'coachName', label: '陪练' }, { key: 'courseName', label: '课程' }, { key: 'classTypeLabel', label: '课程类型' }, { key: 'score', label: '评分', format: 'number' }, { key: 'comment', label: '评价' }, { key: 'lessonAt', label: '训练时间', format: 'date' }] },
   'learning-records': { title: '每日学习记录', eyebrow: 'LEARNING RECORDS', description: '跟踪学员学习、掌握和待复习情况', operationLabel: '学习记录', rowKey: 'targetId', columns: [{ key: 'studentName', label: '学员' }, { key: 'courseName', label: '课程' }, { key: 'studyDate', label: '学习日期', format: 'date' }, { key: 'learned', label: '学习数量', format: 'number' }, { key: 'mastered', label: '已掌握', format: 'number' }, { key: 'reviewDue', label: '待复习', format: 'number' }, { key: 'statusLabel', label: '状态', format: 'status' }] },
   finance: { title: '用户课时与余额', eyebrow: 'PERIODS & BALANCES', description: '核对用户 30/60 分钟课时、经验和积分', operationLabel: '用户余额', rowKey: 'userObjectId', columns: [{ key: 'displayName', label: '用户' }, { key: 'userId', label: '用户 ID' }, { key: 'periods30', label: '30 分钟', format: 'number' }, { key: 'periods60', label: '60 分钟', format: 'number' }, { key: 'trialPeriods', label: '体验/经验', format: 'number' }, { key: 'userPoint', label: '积分', format: 'number' }, { key: 'statusLabel', label: '账号状态', format: 'status' }] },
   'money-logs': { title: '资金积分流水', eyebrow: 'FINANCE LEDGER', description: '查看课时、余额、经验和积分变动记录', operationLabel: '流水记录', rowKey: 'targetId', columns: [{ key: 'userName', label: '用户' }, { key: 'userId', label: '用户 ID' }, { key: 'moneyTypeLabel', label: '项目' }, { key: 'score', label: '变动', format: 'number' }, { key: 'scoreBefore', label: '变动前', format: 'number' }, { key: 'detail', label: '说明' }, { key: 'occurredAt', label: '时间', format: 'date' }] },
@@ -74,7 +74,7 @@ export class OperationsListComponent implements OnInit {
   }
 
   nextAppointmentStatus(status: number): number | null { return status === 0 ? 10 : status === 10 ? 11 : status === 11 ? 20 : status === 20 ? 30 : null; }
-  nextAppointmentLabel(status: number): string { return status === 0 ? '开始上课' : status === 10 ? '完成课程' : status === 11 ? '确认完成' : status === 20 ? '归档' : ''; }
+  nextAppointmentLabel(status: number): string { return status === 0 ? '开始训练' : status === 10 ? '完成课程' : status === 11 ? '确认完成' : status === 20 ? '归档' : ''; }
 
   private async mutate(operation: string, targetId: string, payload: Record<string, unknown>, success: string, expectedUpdatedAt = ''): Promise<void> {
     this.saving.set(true); this.error.set(''); this.message.set('');

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


+ 6 - 6
projects/xiaoshu-admin/src/app/pages/operations-payroll.component.spec.ts

@@ -8,7 +8,7 @@ describe('operations payroll teaching standards', () => {
 
   it('builds the current online and offline price matrix without using an older version', () => {
     const categories = [
-      { key: 'trial', name: '体验课', sortOrder: 10, active: true },
+      { key: 'trial', name: '体验训练', sortOrder: 10, active: true },
       { key: 'word_self_study', name: '单词自学课', sortOrder: 20, active: true },
     ] as TeachingCourseCategory[];
     const rule = (categoryKey: string, deliveryMode: 'online' | 'offline', teacherPay: number, effectiveFrom: string): TeachingRule => ({
@@ -33,7 +33,7 @@ describe('operations payroll teaching standards', () => {
     ], categories, '2026-09-09');
 
     expect(matrix.map((row) => [row.categoryName, row.durationMinutes, row.online?.teacherPay, row.offline?.teacherPay])).toEqual([
-      ['体验课', 60, 30, 40],
+      ['体验训练', 60, 30, 40],
       ['单词自学课', 60, 20, 30],
     ]);
   });
@@ -64,10 +64,10 @@ describe('payroll interactions', () => {
   component.month.set('2026-08');component.storeId.set(10);component.search.set('老师');component.rewardCoachId.set(30);component.openRewards();await component.resetFilters();expect(component.rewardFilters()).toEqual(jasmine.objectContaining({month:'2026-08',storeId:0,search:'',coachId:0,resetRevision:1}));
  });
  it('selects payroll months through the shared calendar-style picker',()=>{
-  component.month.set('2026-09');component.message.set('2026年9月全公司工资草稿已生成');component.detail.set({objectId:'old-month'} as never);const event=new Event('click');spyOn(event,'stopPropagation');component.toggleMonthPicker(event);expect(component.monthPickerOpen()).toBeTrue();expect(component.monthPickerYear()).toBe(2026);component.shiftMonthPickerYear(1,event);component.selectMonth(0);expect(component.month()).toBe('2027-01');expect(component.monthLabel()).toBe('2027年1月');expect(component.monthPickerOpen()).toBeFalse();expect(component.message()).toBe('');expect(component.detail()).toBeNull();
+  component.month.set('2026-09');component.message.set('2026年9月全公司费用草稿已生成');component.detail.set({objectId:'old-month'} as never);const event=new Event('click');spyOn(event,'stopPropagation');component.toggleMonthPicker(event);expect(component.monthPickerOpen()).toBeTrue();expect(component.monthPickerYear()).toBe(2026);component.shiftMonthPickerYear(1,event);component.selectMonth(0);expect(component.month()).toBe('2027-01');expect(component.monthLabel()).toBe('2027年1月');expect(component.monthPickerOpen()).toBeFalse();expect(component.message()).toBe('');expect(component.detail()).toBeNull();
  });
  it('retains adjustment request identity after a lost response and sends batch version',async()=>{
-  component.detail.set({objectId:'batch',version:3,status:'draft'} as never);component.adjustmentCoachId.set(30);component.adjustmentAmount.set(10);component.adjustmentReason.set('补计工资');service.mutate.and.rejectWith(new Error('网络中断'));
+  component.detail.set({objectId:'batch',version:3,status:'draft'} as never);component.adjustmentCoachId.set(30);component.adjustmentAmount.set(10);component.adjustmentReason.set('补计费用');service.mutate.and.rejectWith(new Error('网络中断'));
   await component.addAdjustment();await component.addAdjustment();const calls=service.mutate.calls.allArgs();expect(calls.length).toBe(2);expect(calls[0][5]).toBe(calls[1][5]);expect(calls[0][3]).toEqual(jasmine.objectContaining({version:3,amount:10,coachId:30}));
  });
  it('does not submit an adjustment twice while saving',async()=>{
@@ -78,7 +78,7 @@ describe('payroll interactions', () => {
  });
  it('keeps successful regions when a secondary request fails',async()=>{
   service.teachingStandards.and.rejectWith(new Error('标准服务暂不可用'));
-  await component.load();expect(component.overview()?.month).toBe('2026-09');expect(component.error()).toContain('工资标准');expect(component.error()).toContain('保留上次成功结果');expect(component.loading()).toBeFalse();
+  await component.load();expect(component.overview()?.month).toBe('2026-09');expect(component.error()).toContain('费用标准');expect(component.error()).toContain('保留上次成功结果');expect(component.loading()).toBeFalse();
  });
  it('explains missing writing evidence before submitting a mapping',async()=>{
   const row={appointmentId:'appointment',appointmentGeneralId:1,studentName:'灵灵',courseName:'阅读写作',startsAt:'2026-09-02 19:30',grade:'',categoryKey:'primary_writing',suggestedCategoryKey:'primary_writing',evidence:'',updatedAt:'2026-09-02T12:00:00.000Z'};
@@ -93,7 +93,7 @@ describe('payroll interactions', () => {
   expect(service.mutate).toHaveBeenCalledWith('ops/course-mappings/student-commit','appointment','按学员记录核对语法写作分类',{categoryKey:'primary_writing',evidence:'已核实为小学'},'2026-09-02T12:00:00.000Z');
   expect(service.request).toHaveBeenCalledWith('ops/course-mappings/students',{month:component.month()});
   expect(service.payrollOverview).toHaveBeenCalled();
-  expect(component.message()).toBe('该预约的学段分类已确认,工资统计已刷新');
+  expect(component.message()).toBe('该预约的学段分类已确认,费用统计已刷新');
  });
  it('ignores an older batch page response',async()=>{
   let resolveOld!:(v:unknown)=>void;service.payrolls.and.returnValues(new Promise(r=>resolveOld=r) as never,Promise.resolve({items:[],total:50,page:3,pageSize:20}));

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


+ 1 - 1
projects/xiaoshu-admin/src/app/pages/operations-schedule-status.ts

@@ -27,7 +27,7 @@ export type ScheduleStatusFilter =
 
 const scheduleStatusLabels: Partial<Record<number, string>> = {
   0: '待开始',
-  10: '上课中',
+  10: '训练中',
   11: '陪练已结束',
   20: '学员已确认',
   30: '已完课',

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

@@ -46,7 +46,7 @@
 <section class="schedule-filterbar" aria-label="排课记录筛选">
   <div class="planner-filter-fields">
   <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="unverified">状态待核对</option><option value="cancelled">已取消</option><option value="unassigned">待分配 / 账号异常</option></select></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="unverified">状态待核对</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>
@@ -59,13 +59,13 @@
 @if(message()){<p class="notice" role="status">{{message()}}</p>}
 @if(error()){<p class="notice error" role="alert">{{error()}}</p>}
 @if(data()&&!data()?.liveSourceAvailable){<p class="notice error" role="status">{{data()?.liveSourceMessage}}</p>}
-@if(data()?.source!=='database'&&data()?.sync?.state!=='synced'&&data()){<p class="notice error" role="status">旧系统持久化同步尚未追平。{{data()?.liveSourceAvailable?'本页已读取旧系统当前排课':'本页暂时无法核对旧系统当前排课'}};刷新页面不等于新数据库已同步,未核实记录不能据此认定缺课或结算工资。</p>}
+@if(data()?.source!=='database'&&data()?.sync?.state!=='synced'&&data()){<p class="notice error" role="status">旧系统持久化同步尚未追平。{{data()?.liveSourceAvailable?'本页已读取旧系统当前排课':'本页暂时无法核对旧系统当前排课'}};刷新页面不等于新数据库已同步,未核实记录不能据此认定缺课或结算费用。</p>}
 
 <details class="schedule-workflow">
-  <summary><span><strong>预约到完课</strong><small>创建预约 → 开始上课 → 结课生成报告 → 学生评价</small></span><span class="workflow-toggle">查看流程</span></summary>
+  <summary><span><strong>预约到完课</strong><small>创建预约 → 开始训练 → 结课生成报告 → 学生评价</small></span><span class="workflow-toggle">查看流程</span></summary>
   <ol>
     <li><i>1</i><div><strong>运营创建预约</strong><span>选择学员已绑定课程、时间和陪练</span></div><em>状态 0</em></li>
-    <li><i>2</i><div><strong>陪练开始上课</strong><span>预约当天均可开始,记录实际上课时间</span></div><em>上课中</em></li>
+    <li><i>2</i><div><strong>陪练开始训练</strong><span>预约当天均可开始,记录实际训练时间</span></div><em>训练中</em></li>
     <li><i>3</i><div><strong>老师结课并生成报告</strong><span>课堂结果、反馈与课时扣减一次完成</span></div><em>已结课</em></li>
     <li><i>4</i><div><strong>学生评价</strong><span>学生查看报告后评分;未评价不阻塞结课</span></div><em>评价独立</em></li>
   </ol>
@@ -76,7 +76,7 @@
     <div><strong>{{view()==='list'?'预约明细':'陪练排课泳道'}}</strong><span>{{loading()?'加载中…':'点击排课查看明细与可用操作'}}</span></div>
     <div class="status-legend" aria-label="排课状态图例">
       <span class="status-upcoming"><i></i>待开始</span>
-      <span class="status-live"><i></i>上课中</span>
+      <span class="status-live"><i></i>训练中</span>
       <span class="status-coach-ended"><i></i>陪练已结束</span>
       <span class="status-confirmed"><i></i>学员已确认</span>
       <span class="status-completed"><i></i>已完课</span>
@@ -109,7 +109,7 @@
       <div><dt>学员</dt><dd>{{item.studentName}} <small>ID {{item.studentId||'—'}}</small></dd></div>
       <div><dt>陪练</dt><dd>{{item.coachName}} <small>ID {{item.coachId||'未分配'}}</small></dd></div>
       <div><dt>课程</dt><dd>{{item.courseName}} <small>ID {{item.courseId||'—'}}</small></dd></div>
-      <div><dt>上课时间</dt><dd>{{item.startsAt|date:'yyyy-MM-dd HH:mm':'+0800'}} – {{item.endsAt|date:'HH:mm':'+0800'}}</dd></div>
+      <div><dt>训练时间</dt><dd>{{item.startsAt|date:'yyyy-MM-dd HH:mm':'+0800'}} – {{item.endsAt|date:'HH:mm':'+0800'}}</dd></div>
       <div><dt>课程标准</dt><dd>{{item.classTypeLabel}} · {{item.durationMinutes||'—'}} 分钟</dd></div>
       <div><dt>授课方式</dt><dd>{{item.deliveryMethod||'—'}}</dd></div>
       @if(item.cancelled&&item.cancellationReason){<div class="wide"><dt>取消原因</dt><dd>{{item.cancellationReason}}</dd></div>}
@@ -131,9 +131,9 @@
       <div class="enrollment-return"><strong>为 {{memberSearch()}} 开通课型教材</strong><button type="button" (click)="closeEnrollment()" [disabled]="enrollment?.saving()||loading()">返回排课</button></div>
       <app-member-enrollment [studentId]="studentId()" [initialCategory]="categoryKey()" (changed)="enrollmentChanged()" />
     } @else {
-    <p class="notice">课程在上课当天 00:00 至 24:00 有效,可当天提前或延后上课;预约时间和时长用于安排课程与检查冲突。</p>
+    <p class="notice">课程在训练当天 00:00 至 24:00 有效,可当天提前或延后训练;预约时间和时长用于安排课程与检查冲突。</p>
     <div class="segmented"><button type="button" [class.active]="mode()==='coach'" (click)="setMode('coach')">陪练优先</button><button type="button" [class.active]="mode()==='time'" (click)="setMode('time')">时间优先</button></div>
-    <p class="mode-guidance"><strong>{{mode()==='coach'?'先确定老师':'先确定课程时间'}}</strong><span>{{mode()==='coach'?'先选日期和陪练,再选学员与课型;只有一门已开教材时会自动带出':'先选学员、课型时长和上课时间;单词类课程需选择教材,无冲突的陪练排在前面'}}</span></p>
+    <p class="mode-guidance"><strong>{{mode()==='coach'?'先确定老师':'先确定课程时间'}}</strong><span>{{mode()==='coach'?'先选日期和陪练,再选学员与课型;只有一门已开教材时会自动带出':'先选学员、课型时长和训练时间;单词类课程需选择教材,无冲突的陪练排在前面'}}</span></p>
     <div class="form-grid" [class.coach-first]="mode()==='coach'" [class.time-first]="mode()==='time'">
       <label class="search-field member-step">
         <span>选择学员 <small>可搜索姓名、手机号或用户ID</small></span>
@@ -176,7 +176,7 @@
         <section class="wide course-guidance" aria-live="polite">
           @if(enrollmentFreeTextbook()){<p>直接选择本次使用的教材即可排课,无需提前开课。排课仍按当前课型校验并预留可用课时。</p>}
           @else if(!categoryCourses().length){<p>该学员尚未开通「{{standardLabel()}}」教材。先开课,再排课;开课本身不扣课时。</p>}
-          @else if(selectedCourse()&&!courseSelectionValid()){<p class="exception">所选教材没有当前课型、时长的有效规则,请调整标准或在“排课与工资标准”中配置。</p>}
+          @else if(selectedCourse()&&!courseSelectionValid()){<p class="exception">所选教材没有当前课型、时长的有效规则,请调整标准或在“排课与费用标准”中配置。</p>}
           @else{<p>当前课型:{{standardLabel()}} · {{durationMinutes()}}分钟。教材用于确定本次学习内容。</p>}
           @if(!enrollmentFreeTextbook()){<button type="button" (click)="openEnrollment()">{{categoryCourses().length?'开通其他教材':'为该学员开课'}}</button>}
         </section>
@@ -221,7 +221,7 @@
   <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>{{report['plannedAt']}}</strong></article><article><span>实际上课</span><strong>{{$any(report['actualStartAt'])|date:'yyyy-MM-dd HH:mm'}} – {{$any(report['actualEndAt'])|date:'HH:mm'}}</strong></article><article><span>课程标准</span><strong>{{report['courseCategoryName']||'未映射'}} · {{report['durationMinutes']||'—'}}分钟 · {{report['deliveryLabel']}}</strong></article><article><span>生词总量</span><strong>{{report['vocabularyCount']||0}}</strong></article></section>
+    <section class="feedback-facts"><article><span>计划训练</span><strong>{{report['plannedAt']}}</strong></article><article><span>实际训练</span><strong>{{$any(report['actualStartAt'])|date:'yyyy-MM-dd HH:mm'}} – {{$any(report['actualEndAt'])|date:'HH:mm'}}</strong></article><article><span>课程标准</span><strong>{{report['courseCategoryName']||'未映射'}} · {{report['durationMinutes']||'—'}}分钟 · {{report['deliveryLabel']}}</strong></article><article><span>生词总量</span><strong>{{report['vocabularyCount']||0}}</strong></article></section>
     <section class="feedback-sections"><article><span>预约编号</span><p>{{report['appointmentGeneralId']}}</p></article><article><span>课时处理</span><p>{{report['creditState']}} · 有效记录 {{report['lessonCount']}} 条</p></article><article><span>学生评价</span><p>{{$any(report['studentReview'])?.score ? $any(report['studentReview']).score+' 分' : '尚未评价'}} {{$any(report['studentReview'])?.comment}}</p></article>@if(report['issue']){<article role="alert"><span>待核对异常</span><p>{{report['issue']}}</p></article>}@if(!report['hasReport']){<article><p>尚未生成完课报告</p></article>}<article><span>1. 学习内容</span><p>{{report['contentSummary']||'—'}}</p></article><article><span>2. 复习 / 生词本</span><strong>{{report['reviewStatus']}}</strong><p>{{report['reviewSummary']||'无补充说明'}}</p></article><article><span>3. 训练进程</span><strong>{{report['trainingStatus']}}</strong><p>{{report['trainingSummary']}}</p></article><article><span>4. 当日抗遗忘</span><p>应复习 {{$any(report['antiForgetSummary'])?.total||0}} · 已完成 {{$any(report['antiForgetSummary'])?.completed||0}} · 待完成 {{$any(report['antiForgetSummary'])?.pending||0}}</p></article><article><span>5. 作业安排</span><p>{{report['homework']||'无作业'}}</p></article><article><span>老师点评</span><p>{{report['coachComment']||'无'}}</p></article><article><span>6. 备注</span><p>{{report['notes']||'无'}}</p></article></section>
   </aside>
 }

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

@@ -50,10 +50,10 @@ describe('排课状态视觉分类', () => {
     expect(scheduleStatusLabel(item, now)).toBe('待开始');
   });
 
-  it('已开始的课程当天保持上课中', () => {
-    const item = { cancelled: false, statusCode: 10, statusLabel: '上课中', endsAt: '2026-08-20T01:00:00.000Z' };
+  it('已开始的课程当天保持训练中', () => {
+    const item = { cancelled: false, statusCode: 10, statusLabel: '训练中', endsAt: '2026-08-20T01:00:00.000Z' };
     expect(scheduleStatusTone(item, now)).toBe('status-live');
-    expect(scheduleStatusLabel(item, now)).toBe('上课中');
+    expect(scheduleStatusLabel(item, now)).toBe('训练中');
   });
 
   it('北京时间次日零点才过期,跨午夜课程仍按预约开始日期计算', () => {
@@ -100,9 +100,9 @@ describe('排课运营操作职责', () => {
     });
   });
 
-  it('只在上课中或陪练已结束时提供补录入口', () => {
+  it('只在训练中或陪练已结束时提供补录入口', () => {
     expect(scheduleRowActions({ cancelled: false, statusCode: 10, readOnly: false })).toEqual(jasmine.objectContaining({ canComplete: true, completeLabel: '补录结束' }));
-    expect(scheduleRowActions({ cancelled: false, statusCode: 11, readOnly: false })).toEqual(jasmine.objectContaining({ canComplete: true, completeLabel: '补录上课记录' }));
+    expect(scheduleRowActions({ cancelled: false, statusCode: 11, readOnly: false })).toEqual(jasmine.objectContaining({ canComplete: true, completeLabel: '补录训练记录' }));
     expect(scheduleRowActions({ cancelled: false, statusCode: 20, readOnly: false }).canComplete).toBeFalse();
     expect(scheduleRowActions({ cancelled: false, statusCode: 30, readOnly: false }).canComplete).toBeFalse();
   });
@@ -358,11 +358,11 @@ describe('排课完整课型选择与教材联动',()=>{
     const options=Array.from(fixture.nativeElement.querySelectorAll('select[aria-label="课型与时长"] option')) as HTMLOptionElement[];
     expect(options.length).toBe(10);
     expect(options.map(row=>row.textContent?.split(' · 待开课')[0].split(' · 已开')[0].split(' · 无需开课')[0])).toEqual([
-      '单词课 · 30分钟','单词课 · 60分钟','体验课 · 60分钟','小学语法写作 · 30分钟','初中语法写作 · 30分钟','高中语法写作 · 30分钟','小学语法写作 · 60分钟','初中语法写作 · 60分钟','高中语法写作 · 60分钟','单词自学课 · 60分钟',
+      '单词课 · 30分钟','单词课 · 60分钟','体验训练 · 60分钟','小学语法写作 · 30分钟','初中语法写作 · 30分钟','高中语法写作 · 30分钟','小学语法写作 · 60分钟','初中语法写作 · 60分钟','高中语法写作 · 60分钟','单词自学课 · 60分钟',
     ]);
     expect(options.find(row=>row.value==='word_self_study:60')?.disabled).toBeFalse();
   });
-  it('同一教材切换体验课使用对应 bindingId 与60分钟,旧预检失效',async()=>{
+  it('同一教材切换体验训练使用对应 bindingId 与60分钟,旧预检失效',async()=>{
     await page.preflight();expect(page.canSavePreview()).toBeTrue();
     page.selectStandard('trial:60');expect(page.bindingId()).toBe('trial');expect(page.durationMinutes()).toBe(60);expect(page.preview()).toBeNull();
     await page.preflight();expect(request.calls.mostRecent().args[1].payload).toEqual(jasmine.objectContaining({bindingId:'trial',courseId:100,durationMinutes:60}));

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

@@ -35,7 +35,7 @@ export function scheduleRowActions(item:Pick<ScheduleAppointment,'cancelled'|'st
     canCancel:!item.cancelled&&(status===0||status===10),
     canRestore:item.cancelled&&(status===0||status===10),
     canComplete,
-    completeLabel:canComplete?(status===11?'补录上课记录':'补录结束'):'',
+    completeLabel:canComplete?(status===11?'补录训练记录':'补录结束'):'',
   };
 }
 

+ 10 - 10
projects/xiaoshu-admin/src/app/pages/operations-store-detail.component.html

@@ -2,7 +2,7 @@
   <div>
     <p>STORE OPERATING PROFILE</p>
     <h1>{{data()?.store?.storeName||'门店经营详情'}}</h1>
-    <span>集中查看门店归属会员、陪练、排课、课程履约和陪练工资成本</span>
+    <span>集中查看门店归属会员、陪练、排课、课程履约和陪练费用成本</span>
   </div>
   <div class="header-actions">
     <div class="month-control"><span>经营月份</span><button class="month-picker-trigger" type="button" aria-label="选择经营月份" aria-haspopup="dialog" [attr.aria-expanded]="monthPickerOpen()" [class.active]="monthPickerOpen()" (click)="toggleMonthPicker($event)" [disabled]="loading()"><lucide-icon [img]="icons.CalendarDays" [size]="17"/><strong>{{monthLabel()}}</strong><lucide-icon class="picker-chevron" [img]="icons.ChevronDown" [size]="16"/></button>@if(monthPickerOpen()){<div class="month-calendar-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)" [attr.aria-pressed]="month()===monthOptionValue($index)" (click)="selectMonth($index)">{{name}}</button>}</div><footer><button type="button" (click)="selectCurrentMonth()">回到本月</button></footer></div>}</div>
@@ -13,12 +13,12 @@
 <app-store-credit-account [objectId]="objectId" />
 @if(error()){<p class="notice error" role="alert">{{error()}}</p>}
 @if(loading()&&!data()){
-  <section class="loading-card" aria-live="polite"><lucide-icon [img]="icons.RefreshCw" [size]="22" class="spin"/><strong>正在归集门店经营数据</strong><span>会员、排课和工资来自不同业务投影,首次读取可能需要数秒</span></section>
+  <section class="loading-card" aria-live="polite"><lucide-icon [img]="icons.RefreshCw" [size]="22" class="spin"/><strong>正在归集门店经营数据</strong><span>会员、排课和费用来自不同业务投影,首次读取可能需要数秒</span></section>
 }
 @if(data(); as detail){
   <section class="source-strip" role="status">
     <div><strong>{{detail.store.storeName}}</strong><span>ID {{detail.store.userId}} · {{detail.store.parentStoreName||'总店/未归属'}} · {{detail.store.statusLabel}}</span></div>
-    <div class="source-meta"><span>{{detail.payrollSourceStatus.mode==='live'?'工资使用旧系统实时上课记录':'工资使用最近缓存'}}</span><small>更新于 {{detail.refreshedAt|date:'MM-dd HH:mm:ss'}}</small></div>
+    <div class="source-meta"><span>{{detail.payrollSourceStatus.mode==='live'?'费用使用旧系统实时训练记录':'费用使用最近缓存'}}</span><small>更新于 {{detail.refreshedAt|date:'MM-dd HH:mm:ss'}}</small></div>
   </section>
 
   <section class="store-profile">
@@ -33,9 +33,9 @@
     <article><span>{{detail.month}} 排课</span><strong>{{detail.summary.monthAppointments}}</strong><small>含不同课型</small></article>
     <article><span>已完成排课</span><strong>{{detail.summary.completedAppointments}}</strong><small>状态 30/40</small></article>
     <article><span>有课学员</span><strong>{{detail.summary.activeStudents}}</strong><small>本月去重人数</small></article>
-    <article><span>有效上课记录</span><strong>{{detail.summary.lessonCount}}</strong><small>工资计薪来源</small></article>
-    <article><span>陪练工时</span><strong>{{detail.summary.totalHours|number:'1.1-1'}}h</strong><small>按有效上课记录</small></article>
-    <article class="pay-card"><span>预估陪练工资</span><strong>¥{{detail.summary.estimatedCoachPay|number:'1.2-2'}}</strong><small>与工资结算一致</small></article>
+    <article><span>有效训练记录</span><strong>{{detail.summary.lessonCount}}</strong><small>费用计薪来源</small></article>
+    <article><span>陪练工时</span><strong>{{detail.summary.totalHours|number:'1.1-1'}}h</strong><small>按有效训练记录</small></article>
+    <article class="pay-card"><span>预估陪练费用</span><strong>¥{{detail.summary.estimatedCoachPay|number:'1.2-2'}}</strong><small>与费用结算一致</small></article>
   </section>
 
   @if(!detail.commissionStatus.available){<p class="commission-note"><lucide-icon [img]="icons.WalletCards" [size]="18"/><span><strong>门店佣金暂不估算</strong>{{detail.commissionStatus.message}}</span></p>}
@@ -50,17 +50,17 @@
 
     @if(tab()==='members'){
       <div class="section-heading"><div><strong>归属会员</strong><span>按会员所属门店关系归集,陪练和管理员不会混入</span></div><label class="inline-search"><lucide-icon [img]="icons.Search" [size]="16"/><input [ngModel]="memberSearch()" (ngModelChange)="memberSearch.set($event)" placeholder="搜索姓名、昵称、手机号或ID"/></label></div>
-      <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>
+      <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(row of members();track row.objectId){<tr><td><strong>{{row.displayName}}</strong><small>{{row.mobile||row.mobileMasked||row.username}} · ID {{row.userId}}</small></td><td><span class="badge" [class.warn]="row.learningStatus==='trial'||row.learningStatus==='not_started'">{{row.learningStatusLabel}}</span><small>{{row.learningStatusReason}}</small></td><td>30分 {{row.periods30}} · 60分 {{row.periods60}}<small>体验 {{row.trialPeriods}}</small></td><td>{{row.lastLessonAt?(row.lastLessonAt|date:'yyyy-MM-dd'):'暂无'}}</td><td>{{row.nextAppointmentAt?(row.nextAppointmentAt|date:'yyyy-MM-dd HH:mm'):'待排课'}}</td><td><span class="badge" [class.muted]="row.status==='disabled'">{{row.statusLabel}}</span></td><td><a class="detail-link" [routerLink]="['/admin/members',row.objectId]">查看会员</a></td></tr>}@empty{<tr><td colspan="7"><div class="empty">没有符合条件的会员</div></td></tr>}
       </tbody></table></div>
     } @else if(tab()==='coaches'){
-      <div class="section-heading"><div><strong>归属陪练</strong><span>本月课次、工时和工资均使用工资结算相同口径</span></div><label class="inline-search"><lucide-icon [img]="icons.Search" [size]="16"/><input [ngModel]="coachSearch()" (ngModelChange)="coachSearch.set($event)" placeholder="搜索陪练姓名、账号、手机号或ID"/></label></div>
-      <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></tr></thead><tbody>
+      <div class="section-heading"><div><strong>归属陪练</strong><span>本月课次、工时和费用均使用费用结算相同口径</span></div><label class="inline-search"><lucide-icon [img]="icons.Search" [size]="16"/><input [ngModel]="coachSearch()" (ngModelChange)="coachSearch.set($event)" placeholder="搜索陪练姓名、账号、手机号或ID"/></label></div>
+      <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></tr></thead><tbody>
         @for(row of coaches();track row.objectId){<tr><td><strong>{{row.displayName}}</strong><small>{{row.mobile||row.mobileMasked||row.username||'历史账号未同步'}} · ID {{row.userId}}</small></td><td>{{row.todayAppointments}} 节</td><td>{{row.pendingAppointments}} 节</td><td>{{row.monthLessons}} 节</td><td>{{row.monthHours|number:'1.1-1'}}h</td><td class="money">¥{{row.estimatedPay|number:'1.2-2'}}</td><td><span class="badge" [class.muted]="row.status==='disabled'">{{row.statusLabel}}</span></td><td><a class="detail-link" [routerLink]="['/admin/coaches',row.objectId]">查看陪练</a></td></tr>}@empty{<tr><td colspan="8"><div class="empty">没有符合条件的陪练</div></td></tr>}
       </tbody></table></div>
     } @else if(tab()==='schedule'){
       <div class="section-heading"><div><strong>{{detail.month}} 排课记录</strong><span>优先按学员所属门店归集;学员关系缺失时才使用陪练所属门店</span></div><label class="inline-search"><lucide-icon [img]="icons.Search" [size]="16"/><input [ngModel]="scheduleSearch()" (ngModelChange)="scheduleSearch.set($event)" placeholder="搜索学员、陪练、课程或状态"/></label></div>
-      <div class="table-scroll"><table class="data-table schedule-table"><thead><tr><th>上课时间</th><th>学员</th><th>陪练</th><th>课程</th><th>课型</th><th>状态</th><th>来源</th></tr></thead><tbody>
+      <div class="table-scroll"><table class="data-table schedule-table"><thead><tr><th>训练时间</th><th>学员</th><th>陪练</th><th>课程</th><th>课型</th><th>状态</th><th>来源</th></tr></thead><tbody>
         @for(row of appointments();track row.targetId){<tr><td><strong>{{row.startsAt|date:'yyyy-MM-dd HH:mm'}}</strong><small><lucide-icon [img]="icons.Clock3" [size]="12"/>{{row.endsAt|date:'HH:mm'}} 结束</small></td><td>{{row.studentName||'未识别学员'}}</td><td>{{row.coachName||'待分配'}}</td><td>{{row.courseName||'未命名课程'}}</td><td>{{row.classTypeLabel}}</td><td><span class="status-pill" [attr.data-tone]="statusTone(row)">{{row.statusLabel}}</span></td><td>{{row.sourceLabel}}</td></tr>}@empty{<tr><td colspan="7"><div class="empty">{{detail.month}} 暂无排课记录</div></td></tr>}
       </tbody></table></div>
     } @else {

+ 3 - 3
projects/xiaoshu-admin/src/app/pages/operations-stores.component.html

@@ -9,8 +9,8 @@
 <section class="summary-grid overview-metrics" aria-label="门店运营摘要">
   <article><span>门店总数</span><strong>{{page()?.summary?.total||0}}</strong><small>{{page()?.summary?.active||0}} 家正常运营@if(page()?.summary?.liveOnly){ · {{page()?.summary?.liveOnly}} 家待同步}</small></article>
   <article><span>归属会员</span><strong>{{page()?.summary?.members||0}}</strong><small>按门店关系归集</small></article>
-  <article><span>归属陪练</span><strong>{{page()?.summary?.coaches||0}}</strong><small>可用于排课与工资统计</small></article>
-  <article><span>今日排课</span><strong>{{page()?.summary?.todayAppointments||0}}</strong><small>已归属门店的课程</small></article>
+  <article><span>归属陪练</span><strong>{{page()?.summary?.coaches||0}}</strong><small>可用于排课与费用统计</small></article>
+  <article><span>今天训练</span><strong>{{page()?.summary?.todayAppointments||0}}</strong><small>已归属门店的课程</small></article>
   <article><span>已停用</span><strong>{{page()?.summary?.disabled||0}}</strong><small>停用后无法登录</small></article>
 </section>
 
@@ -31,7 +31,7 @@
   <header><div><lucide-icon [img]="icons.Building2" [size]="19"/><strong>门店名单</strong></div><span>{{page()?.total||0}} 家 · {{page()?.sync?.label||'已读取当前数据'}}</span></header>
   <div class="table-scroll"><table class="data-table">
     <colgroup><col class="store-col"/><col class="parent-col"/><col class="contact-col"/><col class="count-col"/><col class="studying-col"/><col class="count-col"/><col class="schedule-col"/><col class="schedule-col"/><col class="date-col"/><col class="date-col"/><col class="status-col"/><col class="actions-col"/></colgroup>
-    <thead><tr><th>门店</th><th>上级门店</th><th>联系方式</th><th>会员</th><th title="学习状态为正在学习,且账号状态为正常">在学正常学员</th><th>陪练</th><th>今日排课</th><th>历史排课</th><th>最近排课</th><th>最后登录</th><th>状态</th><th>操作</th></tr></thead><tbody>
+    <thead><tr><th>门店</th><th>上级门店</th><th>联系方式</th><th>会员</th><th title="学习状态为正在学习,且账号状态为正常">在学正常学员</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.storeName}}</strong><small>{{row.username}} · ID {{row.userId}}</small>@if(row.sourceLabel){<small class="source-note">{{row.sourceLabel}}</small>}</td>
       <td>{{row.parentStoreName||'总店/未归属'}}</td>

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


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

@@ -17,11 +17,11 @@
   <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]="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>
   <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>
+  <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)="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>
 
@@ -29,7 +29,7 @@
   <header><div><lucide-icon [img]="icons.Users" [size]="19" /><strong>正式会员</strong><span>{{ page()?.total || 0 }} 人</span></div><small>{{ page()?.sourceStatus?.message || page()?.sync?.label || '同步状态未知' }} · 最后刷新:{{ lastRefreshedAt() | date:'HH:mm:ss' }} · 注册时间倒序</small></header>
   @if (loading() && !page()) { <div class="state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="26" />正在加载会员…</div> }
   @else {
-    <div class="desktop-table"><table><colgroup><col class="col-member"/><col class="col-phone"/><col class="col-learning"/><col class="col-registered"/><col class="col-store"/><col class="col-balance"/><col class="col-time"/><col class="col-time"/><col class="col-risk"/><col class="col-status"/><col class="col-actions"/></colgroup><thead><tr><th>会员</th><th>手机号</th><th>学习状态</th><th>注册时间</th><th>所属门店/代理</th><th>剩余课时</th><th>最近上课</th><th>下次预约</th><th>风险</th><th>账号</th><th class="actions-column">操作</th></tr></thead><tbody>
+    <div class="desktop-table"><table><colgroup><col class="col-member"/><col class="col-phone"/><col class="col-learning"/><col class="col-registered"/><col class="col-store"/><col class="col-balance"/><col class="col-time"/><col class="col-time"/><col class="col-risk"/><col class="col-status"/><col class="col-actions"/></colgroup><thead><tr><th>会员</th><th>手机号</th><th>学习状态</th><th>注册时间</th><th>所属门店/代理</th><th>剩余课时</th><th>最近训练</th><th>下次预约</th><th>风险</th><th>账号</th><th class="actions-column">操作</th></tr></thead><tbody>
       @for (row of page()?.items || []; track row.objectId) { <tr><td><div class="user-cell"><strong>{{ row.displayName }}</strong><span>ID {{ row.userId || '—' }} · {{ row.nickname || row.username }}</span></div></td><td>{{ row.mobile || row.mobileMasked || '—' }}</td><td><span class="learning-status" [attr.data-status]="row.learningStatus">{{ row.learningStatusLabel }}</span><small class="status-reason">{{ row.learningStatusReason }}</small></td><td><time>{{ row.registeredAt | date:'yyyy-MM-dd HH:mm' }}</time></td><td>{{ row.parentDisplayName || '未绑定' }}</td><td><strong>{{ row.periods30 | number }}</strong> × 30分<br/><strong>{{ row.periods60 | number }}</strong> × 60分<br/><small>{{ row.trialPeriods | number }} × 体验</small></td><td>{{ row.lastLessonAt ? (row.lastLessonAt | date:'MM-dd HH:mm') : '暂无' }}</td><td>{{ row.nextAppointmentAt ? (row.nextAppointmentAt | date:'MM-dd HH:mm') : '待排课' }}</td><td><div class="risk-list">@for (item of row.riskFlags || []; track item) { <span>{{ item }}</span> } @empty { <small>正常</small> }</div></td><td><span class="status" [class.disabled]="row.status === 'disabled'">{{ row.statusLabel }}</span></td><td class="actions-column"><div class="member-actions"><a class="credit-action" [routerLink]="['/admin/members', row.objectId]" [queryParams]="{tab:'balances'}">充值/扣回</a><a class="detail-action" [routerLink]="['/admin/members', row.objectId]" aria-label="查看会员详情" title="查看会员详情"><lucide-icon [img]="icons.Eye" [size]="17" /></a></div></td></tr> }
       @empty { <tr><td colspan="11"><div class="state">没有符合条件的会员</div></td></tr> }
     </tbody></table></div>

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

@@ -130,7 +130,7 @@ export class OperationsUsersComponent implements OnInit, OnDestroy {
     this.exporting.set(true); this.error.set('');
     try {
       const result = await this.operations.exportMembers(this.query(), this.filters(), this.sort());
-      const columns: Array<[string, string]> = [['userId', '会员ID'], ['displayName', '姓名'], ['nickname', '昵称'], ['mobile', '手机号'], ['learningStatus', '学习状态'], ['registeredAt', '注册时间'], ['parentDisplayName', '所属门店/代理'], ['periods30', '30分钟课时'], ['periods60', '60分钟课时'], ['trialPeriods', '体验课时'], ['lastLessonAt', '最近上课'], ['nextAppointmentAt', '下次预约'], ['riskFlags', '风险提示'], ['status', '账号状态']];
+      const columns: Array<[string, string]> = [['userId', '会员ID'], ['displayName', '姓名'], ['nickname', '昵称'], ['mobile', '手机号'], ['learningStatus', '学习状态'], ['registeredAt', '注册时间'], ['parentDisplayName', '所属门店/代理'], ['periods30', '30分钟课时'], ['periods60', '60分钟课时'], ['trialPeriods', '体验训练时'], ['lastLessonAt', '最近训练'], ['nextAppointmentAt', '下次预约'], ['riskFlags', '风险提示'], ['status', '账号状态']];
       const quote = (value: unknown) => `"${String(value ?? '').replaceAll('"', '""')}"`; const csv = '\ufeff' + [columns.map(([, label]) => label), ...result.items.map(row => columns.map(([key]) => row[key]))].map(row => row.map(quote).join(',')).join('\n');
       const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' })); const anchor = document.createElement('a'); anchor.href = url; anchor.download = result.fileName; anchor.click(); URL.revokeObjectURL(url);
     } catch (error) { this.error.set(error instanceof Error ? error.message : '会员数据导出失败'); }

+ 2 - 2
projects/xiaoshu-admin/src/app/pages/payroll-cutoff.component.ts

@@ -7,7 +7,7 @@ interface Cutoff {month:string;version:string;runId:string;cutoffAt:string;compl
 @Component({selector:'app-payroll-cutoff',standalone:true,imports:[CommonModule,FormsModule],host:{'[class.expanded]':'data() !== null || error().length > 0'},template:`
 <section class="cutoff" [class.is-collapsed]="!data()&&!error()"><button type="button" (click)="load()" [disabled]="busy()">{{busy()?'核验中…':'核验 '+month()+' 结算来源'}}</button>
 @if(error()){<p role="alert">{{error()}}</p>}
-@if(data();as d){<h3>{{d.month}} · 工资来源核验</h3><p>旧接口同步截止:{{d.cutoffAt|date:'yyyy-MM-dd HH:mm':'+0800'}}。{{d.monthClosed?'已覆盖整月':'当前仅可生成截止时间内的草稿,整月同步后才能审核'}}</p>
+@if(data();as d){<h3>{{d.month}} · 费用来源核验</h3><p>旧接口同步截止:{{d.cutoffAt|date:'yyyy-MM-dd HH:mm':'+0800'}}。{{d.monthClosed?'已覆盖整月':'当前仅可生成截止时间内的草稿,整月同步后才能审核'}}</p>
 <p>业务编号:{{d.complete?'对账一致':'凭据缺失或记录缺失,暂不能确认'}} · 待核对 {{d.pending}} 条</p>
 <p>姓名或录入人文字差异单独标为“不影响计薪”;其余差异逐条核实后才能确认保留新系统。</p>
 @for(source of d.datasets;track source.model){<span class="source">{{source.model===54?'预约':source.model===59?'完课':'复习'}} {{source.matched}} / {{source.source}}</span>}
@@ -18,7 +18,7 @@ interface Cutoff {month:string;version:string;runId:string;cutoffAt:string;compl
 export class PayrollCutoffComponent {
  readonly month=input.required<string>();readonly canConfirm=input(false);readonly changed=output<void>();readonly busy=signal(false);readonly error=signal('');readonly data=signal<Cutoff|null>(null);reason='';private readonly ops=inject(OperationsService);
  sourceName(value:string):string{return ({'54':'预约','59':'完课','60':'复习'} as Record<string,string>)[value]||'来源记录';}
- fieldName(value:string):string{return ({title:'学员姓名',inputer:'录入人',subtitle:'课程名称',teacherPaySnapshot:'课酬快照',dslx:'课时类型',dszt:'课程状态',yysj:'预约时间',szyh:'学员编号',pl:'陪练编号',fxpl:'复习老师编号',wcsj:'完成时间',kywrq:'复习日期',pldp:'陪练点评',plpjsj:'点评时间',yyds:'预约编号',lessonAt:'上课时间',durationMinutes:'上课分钟数',jffs:'交付方式'} as Record<string,string>)[value]||value;}
+ fieldName(value:string):string{return ({title:'学员姓名',inputer:'录入人',subtitle:'课程名称',teacherPaySnapshot:'课酬快照',dslx:'课时类型',dszt:'课程状态',yysj:'预约时间',szyh:'学员编号',pl:'陪练编号',fxpl:'复习老师编号',wcsj:'完成时间',kywrq:'复习日期',pldp:'陪练点评',plpjsj:'点评时间',yyds:'预约编号',lessonAt:'训练时间',durationMinutes:'训练分钟数',jffs:'交付方式'} as Record<string,string>)[value]||value;}
  fieldValue(row:Difference,field:string):string{const value=field==='title'?row.student:field==='subtitle'?row.course:(row.current as Record<string,unknown>|null)?.[field];return value==null||value===''?'未记录':typeof value==='object'?JSON.stringify(value):String(value);}
  async load():Promise<void>{this.busy.set(true);this.error.set('');try{this.data.set(await this.ops.request<Cutoff>('ops/payroll/cutoff',{month:this.month()}));}catch(e){this.error.set(e instanceof Error?e.message:'来源核验加载失败');}finally{this.busy.set(false);}}
  async confirm(row?:Difference):Promise<void>{const d=this.data();if(!d||this.busy()||d.month!==this.month())return;this.busy.set(true);this.error.set('');try{this.data.set(await this.ops.mutate<Cutoff>('ops/payroll/cutoff-confirm','',this.reason,{month:d.month,version:d.version,conflictId:row?.id||'',fingerprint:row?.fingerprint||''}));this.changed.emit();}catch(e){this.error.set(e instanceof Error?e.message:'确认失败,请重新核验');}finally{this.busy.set(false);}}

+ 8 - 8
projects/xiaoshu-admin/src/app/pages/review-rewards.component.html

@@ -1,21 +1,21 @@
-@if(correction();as row){<section class="content-card reward-confirm" aria-label="核对逾期工资">
-  <h3>核对 {{row.coachName}} 的逾期复习工资</h3><p>计划 {{row.plannedDate}},完成 {{row.completedAt|date:'yyyy-MM-dd HH:mm':'+0800'}},原工资 ¥{{row.amount}},纠正后净收入 ¥0。</p>
+@if(correction();as row){<section class="content-card reward-confirm" aria-label="核对逾期费用">
+  <h3>核对 {{row.coachName}} 的逾期复习费用</h3><p>计划 {{row.plannedDate}},完成 {{row.completedAt|date:'yyyy-MM-dd HH:mm':'+0800'}},原费用 ¥{{row.amount}},纠正后净收入 ¥0。</p>
   <label>核对依据<input [ngModel]="correctionReason()" (ngModelChange)="correctionReason.set($event)" /></label>
-  @if(row.batchStatus==='reviewed'||row.batchStatus==='paid'){<label>承接扣减的草稿工资单<select [ngModel]="correctionBatch()" (ngModelChange)="correctionBatch.set($event)"><option value="">请选择</option>@for(batch of correctionBatches();track batch.objectId){<option [value]="batch.objectId">{{batch.month}}</option>}</select></label><p>原工资单保持不变,在选中草稿记录 -¥{{row.amount}} 调整。</p>}
-  @else{<p>冲销未结算的错误收入;已有草稿同步移除该笔工资。</p>}
+  @if(row.batchStatus==='reviewed'||row.batchStatus==='paid'){<label>承接扣减的草稿费用单<select [ngModel]="correctionBatch()" (ngModelChange)="correctionBatch.set($event)"><option value="">请选择</option>@for(batch of correctionBatches();track batch.objectId){<option [value]="batch.objectId">{{batch.month}}</option>}</select></label><p>原费用单保持不变,在选中草稿记录 -¥{{row.amount}} 调整。</p>}
+  @else{<p>冲销未结算的错误收入;已有草稿同步移除该笔费用。</p>}
   <button type="button" class="ui-button primary" [disabled]="confirming()" (click)="saveCorrection()">确认本条纠正</button><button type="button" class="ui-button secondary" [disabled]="confirming()" (click)="correction.set(null)">取消</button>
 </section>}
 @if(error()){<p class="notice error" role="alert">{{error()}} <button class="ui-button secondary compact" (click)="load()">重新加载</button></p>}
 @if(message()){<p class="notice" role="status">{{message()}}</p>}
 <section class="content-card reward-card" [attr.aria-busy]="loading()">
-  <header><div><strong>抗遗忘工资</strong><span>老师在计划当天首次完成复习,来源为30分钟课计0.5元/条,60分钟课计1元/条;学生自习、逾期补做不计薪,重复复习不重复计薪。旧系统完成记录须人工核对后才计薪;9 月 1 日前不自动补发。</span></div><div class="reward-actions"><label>记录类型<select [ngModel]="status()" (ngModelChange)="status.set($event);load()"><option value="">全部记录</option><option value="incorrect">逾期错计待核对</option><option value="ineligible">不计薪记录</option><option value="earned">有效计薪</option><option value="historical">历史待核对</option><option value="exception">异常待核对</option></select></label><button class="ui-button secondary" (click)="export()" [disabled]="loading()||exporting()||!data()">{{exporting()?'正在导出':'导出工资明细'}}</button></div></header>
+  <header><div><strong>抗遗忘费用</strong><span>老师在计划当天首次完成复习,来源为30分钟课计0.5元/条,60分钟课计1元/条;学生自习、逾期补做不计薪,重复复习不重复计薪。旧系统完成记录须人工核对后才计薪;9 月 1 日前不自动补发。</span></div><div class="reward-actions"><label>记录类型<select [ngModel]="status()" (ngModelChange)="status.set($event);load()"><option value="">全部记录</option><option value="incorrect">逾期错计待核对</option><option value="ineligible">不计薪记录</option><option value="earned">有效计薪</option><option value="historical">历史待核对</option><option value="exception">异常待核对</option></select></label><button class="ui-button secondary" (click)="export()" [disabled]="loading()||exporting()||!data()">{{exporting()?'正在导出':'导出费用明细'}}</button></div></header>
   @if(data();as result){
-    <div class="reward-summary"><span>有效复习 <strong>{{result.summary.reviewCount}}</strong> 次</span><span>工资合计 <strong>¥{{result.summary.reviewAmount|number:'1.2-2'}}</strong></span><span>历史待核对 {{result.summary.historicalCount}} 条</span><span>异常待核对 {{result.summary.exceptionCount}} 条</span></div>
+    <div class="reward-summary"><span>有效复习 <strong>{{result.summary.reviewCount}}</strong> 次</span><span>费用合计 <strong>¥{{result.summary.reviewAmount|number:'1.2-2'}}</strong></span><span>历史待核对 {{result.summary.historicalCount}} 条</span><span>异常待核对 {{result.summary.exceptionCount}} 条</span></div>
     @if(canManage&&hasConfirmable()){<div class="reward-confirm"><label>人工核对依据<input [ngModel]="confirmReason()" (ngModelChange)="confirmReason.set($event)" placeholder="例如:已核对旧系统完成与历史发放记录,确认未发放" /></label><button class="ui-button primary" type="button" (click)="confirmSelected()" [disabled]="confirming()||!selectedIds().length">{{confirming()?'核对中…':'确认选中 '+selectedIds().length+' 条,按来源课时时长计薪'}}</button><small>仅可勾选 2026-09-01 起的旧系统完成记录;服务端再次校验来源、身份、时间和重复计薪。</small></div>}
     <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></tr></thead><tbody>
       @for(row of result.items;track row.objectId){<tr><td>@if(canManage&&row.canConfirm){<input type="checkbox" [attr.aria-label]="'核对复习凭据 '+row.taskId" [checked]="selectedIds().includes(row.objectId)" (change)="toggleSelection(row.objectId,$any($event.target).checked)" />}@else{—}@if(canManage&&row.canCorrect){<button class="ui-button secondary compact" type="button" (click)="openCorrection(row)">核对纠正</button>}</td><td><strong>{{row.coachName}}</strong><small>{{row.storeName}}</small></td><td>{{row.studentName}}<small>{{row.courseName||'未记录课程'}}</small></td><td>{{row.plannedDate||'未记录'}}</td><td>{{row.completedAt?(row.completedAt|date:'yyyy-MM-dd HH:mm':'+0800'):'待核对'}}</td><td class="money">¥{{row.amount|number:'1.2-2'}}</td><td><span class="badge" [class.warn]="row.status!=='earned'">{{row.statusLabel}}</span><small>{{row.settlementMonth}}</small></td><td>{{row.exception|| (row.status==='ineligible'?(row.ineligibleReason==='self_study'?'学生自习,不计薪':row.ineligibleReason==='duplicate_review'?'重复复习,不重复计薪':'逾期补做,不计薪'):row.status==='historical'?'需核对历史发放情况':'当天首次完成,仅计薪一次')}}<small>{{row.correctionReason}}</small>@if(row.correctionId){<small>原金额 ¥{{row.originalAmount}} · 调整 ¥{{row.correctionAmount}}</small>}<small>{{row.taskId}}</small></td></tr>}
-      @empty{<tr><td colspan="8"><div class="empty">{{loading()?'正在加载…':'当前筛选范围暂无抗遗忘工资记录'}}</div></td></tr>}
+      @empty{<tr><td colspan="8"><div class="empty">{{loading()?'正在加载…':'当前筛选范围暂无抗遗忘费用记录'}}</div></td></tr>}
     </tbody></table></div>
     <footer class="reward-pagination"><span>共 {{result.total}} 条 · 第 {{result.page}} 页</span><button class="ui-button secondary compact" (click)="load(result.page-1)" [disabled]="loading()||result.page<=1">上一页</button><button class="ui-button secondary compact" (click)="load(result.page+1)" [disabled]="loading()||result.page*result.pageSize>=result.total">下一页</button></footer>
-  }@else if(loading()){<p class="empty" role="status">正在加载抗遗忘工资…</p>}
+  }@else if(loading()){<p class="empty" role="status">正在加载抗遗忘费用…</p>}
 </section>

+ 10 - 10
projects/xiaoshu-admin/src/app/pages/review-rewards.component.ts

@@ -17,17 +17,17 @@ export class ReviewRewardsComponent implements OnChanges {
   async load(page=1):Promise<void> {
     const requestId=++this.requestId;this.loading.set(true);this.error.set('');
     try { const data=await this.operations.reviewRewards(this.filters,page,this.status());if(requestId===this.requestId){this.data.set(data);this.selectedIds.set([]);} }
-    catch(error){if(requestId===this.requestId){this.data.set(null);this.error.set(error instanceof Error?error.message:'抗遗忘工资加载失败');}}
+    catch(error){if(requestId===this.requestId){this.data.set(null);this.error.set(error instanceof Error?error.message:'抗遗忘费用加载失败');}}
     finally{if(requestId===this.requestId)this.loading.set(false);}
   }
   toggleSelection(id:string,checked:boolean):void{this.selectedIds.update(ids=>checked?[...new Set([...ids,id])]:ids.filter(value=>value!==id));}
   hasConfirmable():boolean{return Boolean(this.data()?.items.some(row=>row.canConfirm));}
   async confirmSelected():Promise<void>{
     if(!this.canManage||this.confirming()||!this.selectedIds().length)return;
-    const reason=this.confirmReason().trim();if(reason.length<2){this.error.set('请填写至少两个字的核对依据,并确认旧系统记录尚未发放工资');return;}
+    const reason=this.confirmReason().trim();if(reason.length<2){this.error.set('请填写至少两个字的核对依据,并确认旧系统记录尚未发放费用');return;}
     this.confirming.set(true);this.error.set('');this.message.set('');
-    try{const result=await this.operations.confirmLegacyReviewRewards(this.selectedIds(),reason);this.message.set(`已核对 ${result.confirmed} 条抗遗忘工资凭据;重复确认 ${result.alreadyConfirmed} 条。`);this.confirmReason.set('');await this.load(this.data()?.page||1);}
-    catch(error){this.error.set(error instanceof Error?error.message:'历史复习工资核对失败');}
+    try{const result=await this.operations.confirmLegacyReviewRewards(this.selectedIds(),reason);this.message.set(`已核对 ${result.confirmed} 条抗遗忘费用凭据;重复确认 ${result.alreadyConfirmed} 条。`);this.confirmReason.set('');await this.load(this.data()?.page||1);}
+    catch(error){this.error.set(error instanceof Error?error.message:'历史复习费用核对失败');}
     finally{this.confirming.set(false);}
   }
   readonly correction=signal<ReviewReward|null>(null);
@@ -39,16 +39,16 @@ export class ReviewRewardsComponent implements OnChanges {
     this.correction.set(row);this.correctionReason.set('');this.correctionBatch.set('');this.correctionBatches.set([]);
     if(['reviewed','paid'].includes(row.batchStatus||'')){
       try{const all=[];let page=1;while(true){const data=await this.operations.payrolls(page,100);all.push(...data.items.filter(b=>b.status==='draft'));if(page*100>=data.total)break;page++;}if(this.correction()?.objectId===row.objectId)this.correctionBatches.set(all);}
-      catch(e){this.error.set(e instanceof Error?e.message:'草稿工资单加载失败');}
+      catch(e){this.error.set(e instanceof Error?e.message:'草稿费用单加载失败');}
     }
   }
   async saveCorrection():Promise<void>{
     const row=this.correction(),reason=this.correctionReason().trim();if(!this.canManage||!row||this.confirming())return;
     const settled=['reviewed','paid'].includes(row.batchStatus||''),batch=this.correctionBatches().find(b=>b.objectId===this.correctionBatch());
-    if(reason.length<2||settled&&!batch){this.error.set('请填写核对依据;已审核或已发放记录还须选择承接调整的草稿工资单');return;}
+    if(reason.length<2||settled&&!batch){this.error.set('请填写核对依据;已审核或已发放记录还须选择承接调整的草稿费用单');return;}
     this.confirming.set(true);this.error.set('');
-    try{await this.operations.correctReviewReward(row.objectId,reason,settled?batch!.objectId:'',batch?.version);this.correction.set(null);this.message.set('已记录工资纠正,原凭据和已审核工资单保留。');await this.load(this.data()?.page||1);}
-    catch(e){this.error.set(e instanceof Error?e.message:'工资纠正失败');}finally{this.confirming.set(false);}
+    try{await this.operations.correctReviewReward(row.objectId,reason,settled?batch!.objectId:'',batch?.version);this.correction.set(null);this.message.set('已记录费用纠正,原凭据和已审核费用单保留。');await this.load(this.data()?.page||1);}
+    catch(e){this.error.set(e instanceof Error?e.message:'费用纠正失败');}finally{this.confirming.set(false);}
   }
   async export():Promise<void> {
     if(this.exporting())return;this.exporting.set(true);this.error.set('');
@@ -56,10 +56,10 @@ export class ReviewRewardsComponent implements OnChanges {
     try {
       const rows:Record<string,unknown>[]=[];let page=1,total=0;const seen=new Set<string>();
       do {const data=await this.operations.reviewRewards(filters,page,status,100);total=data.total;if(page===1)filters.snapshot=data.snapshot||'';if(!filters.snapshot)throw new Error('导出快照未返回,请刷新重试');for(const item of data.items){if(seen.has(item.objectId))throw new Error('导出记录重复,请重新导出');seen.add(item.objectId);}
-        rows.push(...data.items.map(row=>({'计薪凭据':row.objectId,'复习任务':row.taskId,'老师':row.coachName,'门店':row.storeName,'学生':row.studentName,'课程':row.courseName,'计划日期':row.plannedDate,'首次完成时间':row.completedAt?new Date(row.completedAt).toLocaleString('sv-SE',{timeZone:'Asia/Shanghai'}):'','收入月份':row.incomeMonth,'原金额':row.originalAmount??row.amount,'调整金额':row.correctionAmount??0,'工资金额':row.amount,'纠正依据':row.correctionReason||'','调整工资单':row.adjustmentBatchId||'','结算状态':row.statusLabel,'结算月份':row.settlementMonth||'','异常原因':row.exception})));
+        rows.push(...data.items.map(row=>({'计薪凭据':row.objectId,'复习任务':row.taskId,'老师':row.coachName,'门店':row.storeName,'学生':row.studentName,'课程':row.courseName,'计划日期':row.plannedDate,'首次完成时间':row.completedAt?new Date(row.completedAt).toLocaleString('sv-SE',{timeZone:'Asia/Shanghai'}):'','收入月份':row.incomeMonth,'原金额':row.originalAmount??row.amount,'调整金额':row.correctionAmount??0,'费用金额':row.amount,'纠正依据':row.correctionReason||'','调整费用单':row.adjustmentBatchId||'','结算状态':row.statusLabel,'结算月份':row.settlementMonth||'','异常原因':row.exception})));
         filters.afterId=data.nextCursor||'';if(!filters.afterId)break;page++;
       }while(filters.afterId);if(rows.length!==total)throw new Error('导出记录不完整,请重新导出');
-      const {utils,writeFile}=await import('xlsx');const book=utils.book_new();utils.book_append_sheet(book,utils.json_to_sheet(rows),'抗遗忘工资');writeFile(book,`抗遗忘工资_${filters.month}.xlsx`);
+      const {utils,writeFile}=await import('xlsx');const book=utils.book_new();utils.book_append_sheet(book,utils.json_to_sheet(rows),'抗遗忘费用');writeFile(book,`抗遗忘费用_${filters.month}.xlsx`);
     }catch(error){this.error.set(error instanceof Error?error.message:'导出失败');}finally{this.exporting.set(false);}
   }
 }

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

@@ -3,7 +3,7 @@
 export const SCHEDULE_COURSE_STANDARDS = [
   { key: 'word:30', categoryKey: 'word', label: '单词课', duration: 30 },
   { key: 'word:60', categoryKey: 'word', label: '单词课', duration: 60 },
-  { key: 'trial:60', categoryKey: 'trial', label: '体验课', duration: 60 },
+  { key: 'trial:60', categoryKey: 'trial', label: '体验训练', duration: 60 },
   { key: 'primary_writing:30', categoryKey: 'primary_writing', label: '小学语法写作', duration: 30 },
   { key: 'middle_writing:30', categoryKey: 'middle_writing', label: '初中语法写作', duration: 30 },
   { key: 'high_writing:30', categoryKey: 'high_writing', label: '高中语法写作', duration: 30 },

+ 1 - 1
projects/xiaoshu-admin/src/app/pages/store-home.component.ts

@@ -4,7 +4,7 @@ import {StoreOperationsTrendComponent} from '../components/store-operations-tren
 import {StoreCreditAccountComponent} from '../components/store-credit-account.component';
 interface StoreHome {storeId:number;storeObjectId:string;storeName:string;summary:{total:number;todayNew:number;studying:number};}
 @Component({selector:'app-store-home',standalone:true,imports:[StoreCreditAccountComponent,StoreOperationsTrendComponent],template:`
-<header><div><p>门店工作台</p><h1>{{data()?.storeName||'我的门店'}}</h1><span>本店会员、排课、陪练、工资与学习记录</span></div><button type="button" (click)="load()" [disabled]="loading()">刷新</button></header>
+<header><div><p>门店工作台</p><h1>{{data()?.storeName||'我的门店'}}</h1><span>本店会员、排课、陪练、费用与学习记录</span></div><button type="button" (click)="load()" [disabled]="loading()">刷新</button></header>
 @if(error()){<p class="error" role="alert">{{error()}}</p>}
 @if(loading()&&!data()){<p>正在读取本店数据…</p>}
 @if(data();as d){<section class="summary"><article><span>本店学生</span><strong>{{d.summary.total}}</strong></article><article><span>学习中</span><strong>{{d.summary.studying}}</strong></article><article><span>今日新增</span><strong>{{d.summary.todayNew}}</strong></article></section><app-store-operations-trend [refreshVersion]="refreshVersion()"/><app-store-credit-account [objectId]="d.storeObjectId"/>}

+ 1 - 1
projects/xiaoshu-admin/src/app/shared/dropdown.directive.spec.ts

@@ -4,7 +4,7 @@ import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
 import { DropdownDirective } from './dropdown.directive';
 import { AnchoredMenuDirective } from './anchored-menu.directive';
 
-@Component({standalone:true,imports:[ReactiveFormsModule,DropdownDirective],template:`<label>老师<select [formControl]="teacher"><option [ngValue]="null">全部老师</option>@for(person of people;track person.id){<option [ngValue]="person">{{person.name}}</option>}</select></label><label>工资标准<select [formControl]="rate"><option [ngValue]="0">零元</option><option [ngValue]="40">40 元</option></select></label>`})
+@Component({standalone:true,imports:[ReactiveFormsModule,DropdownDirective],template:`<label>老师<select [formControl]="teacher"><option [ngValue]="null">全部老师</option>@for(person of people;track person.id){<option [ngValue]="person">{{person.name}}</option>}</select></label><label>费用标准<select [formControl]="rate"><option [ngValue]="0">零元</option><option [ngValue]="40">40 元</option></select></label>`})
 class SelectHost {people=[{id:1,name:'张老师'},{id:2,name:'李老师'}];teacher=new FormControl<{id:number;name:string}|null>(null);rate=new FormControl(0);}
 @Component({standalone:true,imports:[FormsModule,AnchoredMenuDirective],template:`<section style="height:60px;overflow:hidden"><label><span>搜索学员</span><span><input #anchor [(ngModel)]="query" (focus)="open=true" /></span>@if(open){<span role="listbox" [xsAnchoredMenu]="anchor" (xsDismiss)="open=false">@for(item of items;track item){@if(!query||item.includes(query)){<button role="option" (click)="selected=item;open=false">{{item}}</button>}}</span>}</label></section>`})
 class SearchHost {open=false;query='';selected='';items=['张同学','李同学'];}

+ 9 - 9
projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts

@@ -454,12 +454,12 @@ describe('home learning modules', () => {
     harness.fixture.detectChanges();
     expect(states()).toEqual(['逾期补做','待复习','未到期','已完成']);
     expect(harness.routeNativeElement!.querySelector('.memory-date-tasks h2')?.textContent).toBe('逾期补做');
-    expect(harness.routeNativeElement!.textContent).not.toMatch(/计薪|记薪|工资|薪酬|收入/);
+    expect(harness.routeNativeElement!.textContent).not.toMatch(/计薪|记薪|费用|薪酬|收入/);
     user.set({userId:300,groupId:3});session.groupId.set(3);harness.fixture.detectChanges();
     expect(states()).toEqual(['逾期补做 · 不计薪','待复习 · 当天完成计薪','未到期','已完成']);
     expect(harness.routeNativeElement!.querySelector('.memory-date-tasks h2')?.textContent).toBe('逾期补做 · 不计薪');
     user.set({userId:100,groupId:1});session.groupId.set(1);harness.fixture.detectChanges();
-    expect(harness.routeNativeElement!.textContent).not.toMatch(/计薪|记薪|工资|薪酬|收入/);
+    expect(harness.routeNativeElement!.textContent).not.toMatch(/计薪|记薪|费用|薪酬|收入/);
   });
 
   it('loads the review calendar once per login and refreshes only on an explicit request', async () => {
@@ -1481,7 +1481,7 @@ describe('home learning modules', () => {
     await harness.navigateByUrl('/pages/member/ke_xiao');
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();
-    const monthFilter = harness.routeNativeElement?.querySelector('[aria-label="按月份筛选销课记录"]') as HTMLSelectElement;
+    const monthFilter = harness.routeNativeElement?.querySelector('[aria-label="按月份筛选陪练记录"]') as HTMLSelectElement;
     const monthPicker = harness.routeNativeElement?.querySelector('.month-control .xs-dropdown-trigger') as HTMLButtonElement;
     expect(monthFilter.value).toBe('all');
     expect(monthFilter.options.length).toBe(4);
@@ -1491,7 +1491,7 @@ describe('home learning modules', () => {
     expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(5);
     monthFilter.value = '2026-03';
     monthFilter.dispatchEvent(new Event('change'));
-    const studentSearch = harness.routeNativeElement?.querySelector('[aria-label="按学生姓名筛选销课记录"]') as HTMLInputElement;
+    const studentSearch = harness.routeNativeElement?.querySelector('[aria-label="按学生姓名筛选陪练记录"]') as HTMLInputElement;
     studentSearch.value = '花';
     studentSearch.dispatchEvent(new Event('input'));
     harness.fixture.detectChanges();
@@ -1533,7 +1533,7 @@ describe('home learning modules', () => {
     harness.fixture.detectChanges();
     const teacherServices = Array.from(harness.routeNativeElement?.querySelectorAll('.service-grid > a') ?? []).map((link) => link.getAttribute('href'));
     expect(teacherServices).toEqual(['/pages/home/shop', '/pages/member/ke_xiao', '/pages/his/his', '/pages/member/feedback']);
-    expect(harness.routeNativeElement?.querySelector('.coach-wallet-strip')?.textContent).toContain('累计复习工资');
+    expect(harness.routeNativeElement?.querySelector('.coach-wallet-strip')?.textContent).toContain('累计抗遗忘费用');
 
     await harness.navigateByUrl('/pages/stu/stu_list?nid=801&bindingId=701');
     await harness.fixture.whenStable();
@@ -1664,7 +1664,7 @@ describe('home learning modules', () => {
     const component=harness.fixture.debugElement.query(By.directive(AppointmentPageComponent)).componentInstance as AppointmentPageComponent;
     await component.endLesson();expect(api.completionRequests.length).toBe(1);expect(api.completionRequests[0]['feedback']).toBeUndefined();
     api.appointmentStatus=30;api.completionResponse.next({retcode:0,result:{status:30,teacherPay:45,payStatus:'earned',feedbackStatus:'pending'}});api.completionResponse.complete();
-    harness.fixture.detectChanges();expect(harness.routeNativeElement?.textContent).toContain('已结课');expect(harness.routeNativeElement?.textContent).not.toMatch(/工资|¥45/);expect(harness.routeNativeElement?.textContent).toContain('反馈待补');
+    harness.fixture.detectChanges();expect(harness.routeNativeElement?.textContent).toContain('已结课');expect(harness.routeNativeElement?.textContent).not.toMatch(/费用|¥45/);expect(harness.routeNativeElement?.textContent).toContain('反馈待补');
   });
 
   it('retains the optional feedback-and-complete dialog and submits once after validation', async () => {
@@ -1994,13 +1994,13 @@ describe('home learning modules', () => {
       component.reviewCompletion.set(result);harness.fixture.detectChanges();
       const completion=harness.routeNativeElement?.querySelector('.review-complete');
       expect(completion?.textContent).toContain('复习已完成');
-      expect(completion?.textContent).not.toMatch(/收入|工资|计薪|记账|¥/);
+      expect(completion?.textContent).not.toMatch(/收入|费用|计薪|记账|¥/);
       expect(completion?.textContent).toContain('继续今日任务');
       expect(completion?.textContent).toContain('返回原任务列表');
     }
     user.set({userId:100,groupId:1,honeyName:'学生'});session.groupId.set(1);harness.fixture.detectChanges();
     expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).toContain('复习已完成');
-    expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).not.toMatch(/收入|工资|计薪|记账|¥/);
+    expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).not.toMatch(/收入|费用|计薪|记账|¥/);
   });
 
   it('waits for server completion and blocks duplicate clicks for overdue work', async()=>{
@@ -2012,7 +2012,7 @@ describe('home learning modules', () => {
     component.endRecord();component.endRecord();harness.fixture.detectChanges();expect(component.reviewCompletion()).toBeNull();expect(component.canDeactivate()).toBeFalse();
     expect(submit.calls.allArgs().filter(a=>a[0]==='app_review_complete').length).toBe(1);
     reply.next({retcode:1,result:{amount:0,rewardStatus:'ineligible',completed:true}});reply.complete();harness.fixture.detectChanges();
-    expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).toContain('复习已完成');expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).not.toMatch(/收入|工资|计薪|¥/);expect(component.canDeactivate()).toBeTrue();
+    expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).toContain('复习已完成');expect(harness.routeNativeElement?.querySelector('.review-complete')?.textContent).not.toMatch(/收入|费用|计薪|¥/);expect(component.canDeactivate()).toBeTrue();
   });
 
 });

+ 2 - 2
projects/xiaoshu-mobile/src/app/core/page-catalog.ts

@@ -109,8 +109,8 @@ export const PAGE_CATALOG: PageMeta[] = [
   { path: 'pages/member/coin', title: '60 分钟课时', domain: 'member', kind: 'finance', auth: true },
   { path: 'pages/member/ghis', title: '抗遗忘历史', domain: 'member', kind: 'studyRecords', auth: true },
   { path: 'pages/member/myke', title: '我的练习进度', domain: 'member', kind: 'studyRecords', auth: true },
-  { path: 'pages/member/ke_xiao', title: '销课记录', domain: 'member', kind: 'courseCenter', auth: true },
-  { path: 'pages/member/ke_fuxi', title: '抗遗忘工资', domain: 'member', kind: 'studyRecords', auth: true },
+  { path: 'pages/member/ke_xiao', title: '陪练记录', domain: 'member', kind: 'courseCenter', auth: true },
+  { path: 'pages/member/ke_fuxi', title: '抗遗忘费用', domain: 'member', kind: 'studyRecords', auth: true },
 ];
 
 export const PAGE_BY_PATH = new Map(PAGE_CATALOG.map((page) => [page.path, page]));

+ 2 - 2
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.html

@@ -13,7 +13,7 @@
     @if (detailWarning()) { <p class="detail-warning" role="alert">{{ detailWarning() }}</p> }
     @if(actionError()){<p class="detail-warning" role="alert">{{actionError()}}</p>}
     @if(order()['blockedReason']){<p class="detail-warning">{{order()['blockedReason']}}</p>}
-    @if (can('start')) { <p class="schedule-hint"><lucide-icon [img]="icons.Clock3" [size]="16" aria-hidden="true" /><span>预约当天(北京时间)均可上课,可提前开始,也可在计划时间之后开始。</span></p> }
+    @if (can('start')) { <p class="schedule-hint"><lucide-icon [img]="icons.Clock3" [size]="16" aria-hidden="true" /><span>预约当天(北京时间)均可训练,可提前开始,也可在计划时间之后开始。</span></p> }
 
     <dl class="appointment-info">
       <div><dt>学生姓名</dt><dd>{{ field('student') || '-' }}</dd></div>
@@ -55,7 +55,7 @@
         </label>
         @if(coursesError()){<p class="detail-warning" role="alert">{{coursesError()}}</p><button type="button" class="secondary-button" (click)="load()">重新加载课程</button>}
         @else if(!coursesLoading() && !courses().length){<p>该学员暂无已开通课程,请联系管理员开通。</p>}
-        @if(isWriting){<p class="schedule-hint">当前选择语法写作课程,可开始上课并在结束时填写学习内容。</p>}
+        @if(isWriting){<p class="schedule-hint">当前选择语法写作课程,可开始训练并在结束时填写学习内容。</p>}
         @else {
         <div class="unit-heading"><strong>选择学习单元</strong><span>可多选,右侧按钮可标记已学完</span></div>
         <div class="unit-grid">

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts

@@ -193,7 +193,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
       delivery: ['jffs'],
       coach: ['plxm', 'plName'],
     };
-    if (label === 'type') return String(this.order()['courseCategoryName'] || ({1:'30分钟课',2:'60分钟课',3:'体验课',4:'课型4'} as Record<string,string>)[String(this.order()['dslx'])] || this.order()['dslx_t'] || '课程');
+    if (label === 'type') return String(this.order()['courseCategoryName'] || ({1:'30分钟课',2:'60分钟课',3:'体验训练',4:'课型4'} as Record<string,string>)[String(this.order()['dslx'])] || this.order()['dslx_t'] || '课程');
     for (const key of aliases[label] ?? []) if (this.order()[key] !== undefined) return String(this.order()[key]);
     return '';
   }

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/appointment/lesson-report.component.spec.ts

@@ -48,7 +48,7 @@ describe('teacher learning report PDF download', () => {
       expect(element.textContent).toContain('n.灯笼;提灯');
       expect(element.textContent).toContain('已掌握');expect(element.textContent).toContain('待复习');expect(element.textContent).toContain('未标记');
       expect(element.textContent).toContain('21天复习计划');expect(element.textContent).toContain('完成课后复习');
-      expect(element.textContent).not.toMatch(/工资|收入|¥/);
+      expect(element.textContent).not.toMatch(/费用|收入|¥/);
       return new Blob(['%PDF-test']);
     });
     await fixture.componentInstance.downloadPdf();fixture.detectChanges();

+ 7 - 6
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.html

@@ -15,16 +15,17 @@
     }
 
     <section class="schedule-band">
-      <header><lucide-icon [img]="isCoach ? icons.GraduationCap : icons.CalendarDays" [size]="21" /><h2>{{ isHistory ? '销课记录' : isCoach ? '陪练排课' : '伴学列表' }}</h2><span>{{ (loading()||error())&&!schedules().length ? '—' : isHistory || hasScheduleCalendar ? visibleSchedules().length + ' / ' + schedules().length : schedules().length }} 节</span></header>
+      <header><lucide-icon [img]="isCoach ? icons.GraduationCap : icons.CalendarDays" [size]="21" /><h2>{{ isHistory ? '陪练记录' : isCoach ? '陪练排课' : '伴学列表' }}</h2><span>{{ (loading()||error())&&!schedules().length ? '—' : isHistory || hasScheduleCalendar ? visibleSchedules().length + ' / ' + schedules().length : schedules().length }} 节</span></header>
       <nav class="range-tabs schedule-actions" aria-label="课表操作"><button type="button" (click)="load(true)" [disabled]="loading()">刷新课表</button><button type="button" (click)="clearScheduleFilters()">清除筛选</button>@if (nextScheduleDate() && !isHistory) { <button type="button" (click)="showNextSchedule()">最近一节 · {{nextScheduleDate()}}</button> }</nav>
       @if (isHistory) {
-        <section class="history-filter-panel" aria-label="销课记录筛选">
+        <div class="history-filter-panel" aria-live="polite"><strong>{{historyMonth()==='all'?'全部月份':historyMonth()}} · 陪练费用合计:{{loading() || error() || !earningsSummary() ? '—' : '¥' + (earningsSummary()!.amount | number:'1.2-2')}}</strong><p>按当前筛选全部 {{listTotal()}} 条记录汇总,包含所有分页;已取消记录不计费用。</p>@if(earningsSummary()?.pendingCount){<small>另有 {{earningsSummary()!.pendingCount}} 条费用待核对,未计入合计。</small>}</div>
+        <section class="history-filter-panel" aria-label="陪练记录筛选">
           <div class="history-filter-grid">
             <label>
               <span>月份</span>
               <div class="history-filter-control month-control">
                 <lucide-icon [img]="icons.CalendarDays" [size]="18" />
-                <select [ngModel]="historyMonth()" (ngModelChange)="setHistoryMonth($event)" aria-label="按月份筛选销课记录">
+                <select [ngModel]="historyMonth()" (ngModelChange)="setHistoryMonth($event)" aria-label="按月份筛选陪练记录">
                   <option value="all">全部月份</option>
                   @for (month of historyMonthOptions(); track month.value) { <option [value]="month.value">{{ month.label }}({{ month.count }})</option> }
                 </select>
@@ -34,7 +35,7 @@
               <span>学生姓名</span>
               <div class="history-filter-control name-control">
                 <lucide-icon [img]="icons.Search" [size]="18" />
-                <input type="search" [ngModel]="historyStudentQuery()" (ngModelChange)="setHistoryStudentQuery($event)" placeholder="输入姓名查找" autocomplete="off" aria-label="按学生姓名筛选销课记录" />
+                <input type="search" [ngModel]="historyStudentQuery()" (ngModelChange)="setHistoryStudentQuery($event)" placeholder="输入姓名查找" autocomplete="off" aria-label="按学生姓名筛选陪练记录" />
               </div>
             </label>
           </div>
@@ -116,7 +117,7 @@
               <article [class.has-lesson-pay]="isHistory && isCoach && !item['cancelled']" [attr.data-schedule-id]="scheduleId(item)" (click)="open(item, $event)">
                 <time>{{ scheduleTime(item) }}</time>
                 <div class="schedule-copy"><strong>{{ scheduleTitle(item) }}</strong>@if (scheduleSubtitle(item)) { <span>{{ scheduleSubtitle(item) }}</span> }</div>
-                @if (isHistory && isCoach && !item['cancelled']) { <div class="lesson-pay"><strong>{{ item['payStatus'] === 'earned' ? '带课工资 ¥' + ($any(item['teacherPay']) | number:'1.2-2') : '工资待核对' }}</strong><small [class.feedback-pending]="item['feedbackStatus'] !== 'submitted'">{{item['feedbackStatus']==='submitted'?'反馈已提交':'反馈待补'}}</small></div> }
+                @if (isHistory && isCoach && !item['cancelled']) { <div class="lesson-pay"><strong>{{ item['payStatus'] === 'earned' ? '陪练费用 ¥' + ($any(item['teacherPay']) | number:'1.2-2') : '费用待核对' }}</strong><small [class.feedback-pending]="item['feedbackStatus'] !== 'submitted'">{{item['feedbackStatus']==='submitted'?'反馈已提交':'反馈待补'}}</small></div> }
                 <span class="status" [class]="'status ' + statusClass(item)">{{ statusText(item) }}</span>
                 @if (isCoach) { <button type="button" class="report-button" (click)="report(item, $event)" aria-label="学习报表" title="学习报表"><lucide-icon [img]="icons.BarChart3" [size]="18" /></button> }
                 <lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" />
@@ -124,7 +125,7 @@
             }
           </div>
         }
-      } @else { <p class="band-empty">{{ error() ? '课表暂时无法加载,请重试' : loading() ? '正在读取排课数据…' : (isHistory && schedules().length && historyFilterActive() ? '没有符合筛选条件的销课记录' : (hasScheduleCalendar ? scheduleEmptyText() : (schedules().length ? '该分类暂无伴学记录' : (isCoach ? '暂无销课记录' : '暂无伴学记录')))) }}</p> }
+      } @else { <p class="band-empty">{{ error() ? '课表暂时无法加载,请重试' : loading() ? '正在读取排课数据…' : (isHistory && schedules().length && historyFilterActive() ? '没有符合筛选条件的陪练记录' : (hasScheduleCalendar ? scheduleEmptyText() : (schedules().length ? '该分类暂无伴学记录' : (isCoach ? '暂无陪练记录' : '暂无伴学记录')))) }}</p> }
     </section>
 
     @if (error()) {

+ 5 - 2
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.ts

@@ -42,6 +42,7 @@ interface MonthOption {
 }
 
 interface CompanionOverview {
+  earningsSummary?: {amount:number;pendingCount:number};
   page?: {itemCount:number;pageCount:number}; dates?: Array<{date:string;finished:number;planned:number}>; studentOptions?:StudentOption[];
   courses: Item[];
   schedules: Item[];
@@ -79,6 +80,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   readonly selectedDate = signal(this.today());
   readonly selectedStudent = signal('all');
   readonly allStudentCount = computed(() => this.studentOptions().reduce((sum, student) => sum + student.count, 0));
+  readonly earningsSummary = signal<{amount:number;pendingCount:number}|null>(null);
   readonly historyMonth = signal('all');
   readonly historyStudentQuery = signal('');
   readonly calendarMonth = signal(this.firstOfMonth(new Date()));
@@ -185,6 +187,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
     })).subscribe({
       next: (result) => {
         if(requestId!==this.loadId)return;this.listPages.set(result.page?.pageCount||1);this.listTotal.set(result.page?.itemCount||0);this.serverDates.set(result.dates||[]);this.serverStudents.set(result.studentOptions||[]);
+        this.earningsSummary.set(result.earningsSummary ?? null);
         this.courses.set(Array.isArray(result?.courses) ? result.courses : []);
         this.schedules.set(Array.isArray(result?.schedules) ? result.schedules : []);
         this.refreshedAt.set(result.refreshedAt || new Date().toISOString());
@@ -245,7 +248,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
     return Array.from(grouped, ([date, items]) => ({ date, items }));
   }
 
-  courseCategory(item: Item): string { return String(item['categoryName'] || ({word:'单词课',trial:'体验课',primary_writing:'小学语法写作课',middle_writing:'中学语法写作课',high_writing:'高中语法写作课',word_self_study:'单词自学课'} as Record<string,string>)[String(item['categoryKey'])] || ''); }
+  courseCategory(item: Item): string { return String(item['categoryName'] || ({word:'单词课',trial:'体验训练',primary_writing:'小学语法写作课',middle_writing:'中学语法写作课',high_writing:'高中语法写作课',word_self_study:'单词自学课'} as Record<string,string>)[String(item['categoryKey'])] || ''); }
   courseTitle(item: Item): string {
     const sourceTitle = String(item['Title'] ?? '').trim();
     if (sourceTitle.includes('@')) {
@@ -316,7 +319,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   statusText(item: Item): string {
     if(item['cancelled']===true)return '已取消';
     if (this.isOverdueUnfinished(item)) return '计划未完成';
-    return ({ 0: '未开始', 10: '已开始', 11: '已上课', 20: '已评价', 30: '已结课' } as Record<number, string>)[this.rawStatus(item)] ?? '--';
+    return ({ 0: '未开始', 10: '已开始', 11: '已训练', 20: '已评价', 30: '已结课' } as Record<number, string>)[this.rawStatus(item)] ?? '--';
   }
 
   statusClass(item: Item): string { return this.isOverdueUnfinished(item) ? 'status-11' : `status-${this.rawStatus(item)}`; }

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/home/account-membership.spec.ts

@@ -36,7 +36,7 @@ describe('buildAccountMembership', () => {
     const summary = summarizeCompanionLearning([
       { dszt: 30, dslx: 2, Title: '60分钟练习' },
       { dszt: 30, dslx: 1, Title: '30分钟练习' },
-      { dszt: 30, dslx: 3, Title: '体验课' },
+      { dszt: 30, dslx: 3, Title: '体验训练' },
       { dszt: 0, dslx: 2, Title: '未开始课程' },
     ]);
 

+ 6 - 6
projects/xiaoshu-mobile/src/app/features/home/account-page.component.html

@@ -19,15 +19,15 @@
       <span>累计陪练</span>
       @if (session.groupId() === 3 && coachPanelLoading()) { <strong>正在同步...</strong> }
       @else if (!refreshedAt()) { <strong>—</strong><em>统计暂不可用</em> }
-      @else if (session.groupId() === 3) { <strong>{{ coachPanel().totalHours | number:'1.0-1' }} 小时</strong><em>上课 {{ coachPanel().totalLessons }} 次</em> }
+      @else if (session.groupId() === 3) { <strong>{{ coachPanel().totalHours | number:'1.0-1' }} 小时</strong><em>训练 {{ coachPanel().totalLessons }} 次</em> }
       @else if (companionLoading()) { <strong>正在同步...</strong> }
       @else { <strong>{{ companionSummary().durationLabel }}</strong><em>已结束 {{ companionSummary().completedSessions }} 次</em> }
     </div>
     @if (session.groupId() === 3) {
       <div class="learning-rights">
-        <div><strong>{{ refreshedAt()?coachPanel().course30:'—' }}</strong><span>30分钟课程</span></div>
-        <div><strong>{{ refreshedAt()?coachPanel().course60:'—' }}</strong><span>60分钟课程</span></div>
-        <div><strong>{{ refreshedAt()?coachPanel().trialLessons:'—' }}</strong><span>体验课</span></div>
+        <div><strong>{{ refreshedAt()?coachPanel().course30:'—' }}</strong><span>30分钟训练</span></div>
+        <div><strong>{{ refreshedAt()?coachPanel().course60:'—' }}</strong><span>60分钟训练</span></div>
+        <div><strong>{{ refreshedAt()?coachPanel().trialLessons:'—' }}</strong><span>体验训练</span></div>
       </div>
     } @else {
       <div class="learning-rights">
@@ -38,12 +38,12 @@
     }
   </section>
   @if (session.groupId() === 3) {
-    <section class="wallet-strip coach-wallet-strip"><a routerLink="/pages/member/ke_xiao"><strong>{{refreshedAt() && coachPanel().lessonIncome != null ? '¥' + (coachPanel().lessonIncome | number:'1.2-2') : '—'}}</strong><span>累计带课工资</span></a><div><strong>{{refreshedAt() && coachPanel().totalIncome != null ? '¥' + (coachPanel().totalIncome | number:'1.2-2') : '—'}}</strong><span>累计总收入</span></div><a routerLink="/pages/member/ke_fuxi"><strong>¥{{ refreshedAt()?(coachPanel().reviewIncome | number:'1.0-2'):'—' }}</strong><span>累计复习工资</span></a><a routerLink="/pages/member/feedback"><strong>申请</strong><span>休息时间</span></a></section>
+    <section class="wallet-strip coach-wallet-strip"><a routerLink="/pages/member/ke_xiao"><strong>{{refreshedAt() && coachPanel().lessonIncome != null ? '¥' + (coachPanel().lessonIncome | number:'1.2-2') : '—'}}</strong><span>累计陪练费用</span></a><div><strong>{{refreshedAt() && coachPanel().totalIncome != null ? '¥' + (coachPanel().totalIncome | number:'1.2-2') : '—'}}</strong><span>累计总收入</span></div><a routerLink="/pages/member/ke_fuxi"><strong>¥{{ refreshedAt()?(coachPanel().reviewIncome | number:'1.0-2'):'—' }}</strong><span>累计抗遗忘费用</span></a><a routerLink="/pages/member/feedback"><strong>申请</strong><span>休息时间</span></a></section>
   } @else {
     <section class="wallet-strip"><a routerLink="/pages/member/purse"><strong>查看</strong><span>课时余额与预留</span></a><a routerLink="/pages/member/point"><strong>{{ refreshedAt()?(session.user()?.['UserPoint']??'—'):'—' }}</strong><span>可查陪练时长</span></a></section>
   }
   @if (session.groupId() === 3) {
-    <section class="role-panel"><header><h2>陪练专用</h2></header><div class="order-strip coach-strip"><a routerLink="/pages/home/shop"><strong>{{ refreshedAt()?coachPanel().todaySchedules:'—' }}</strong><span>今日排课</span></a><a routerLink="/pages/member/ke_xiao"><strong>{{ refreshedAt()?coachPanel().completedLessons:'—' }}</strong><span>销课记录</span></a><a routerLink="/pages/member/ke_fuxi"><strong>¥{{ refreshedAt()?(coachPanel().reviewIncome | number:'1.0-2'):'—' }}</strong><span>抗遗忘工资</span></a></div></section>
+    <section class="role-panel"><header><h2>陪练专用</h2></header><div class="order-strip coach-strip"><a routerLink="/pages/home/shop"><strong>{{ refreshedAt()?coachPanel().todaySchedules:'—' }}</strong><span>今天训练</span></a><a routerLink="/pages/member/ke_xiao"><strong>{{ refreshedAt()?coachPanel().completedLessons:'—' }}</strong><span>陪练记录</span></a><a routerLink="/pages/member/ke_fuxi"><strong>¥{{ refreshedAt()?(coachPanel().reviewIncome | number:'1.0-2'):'—' }}</strong><span>抗遗忘费用</span></a></div></section>
   } @else {
     <section class="role-panel course-memory">
       <header><h2>我的课程</h2><a routerLink="/pages/stu/stu_calendar">科学抗遗忘</a></header>

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/home/account-page.component.ts

@@ -65,7 +65,7 @@ export class AccountPageComponent implements OnInit, OnDestroy {
   readonly icons = { Award, BookMarked, CalendarDays, CheckCircle2, ChevronRight, CircleHelp, History, LoaderCircle, LogOut, MessageSquare, RefreshCw, Settings, ShieldCheck, UserRound, UsersRound };
   readonly services = computed(() => Number(this.session.groupId()) === 3 ? [
     { path: '/pages/home/shop', label: '陪练排课', icon: CalendarDays },
-    { path: '/pages/member/ke_xiao', label: '销课记录', icon: CheckCircle2 },
+    { path: '/pages/member/ke_xiao', label: '陪练记录', icon: CheckCircle2 },
     { path: '/pages/his/his', label: '学员记录', icon: UsersRound },
     { path: '/pages/member/feedback', label: '意见反馈', icon: MessageSquare },
   ] : [

+ 7 - 7
projects/xiaoshu-mobile/src/app/features/home/home-page.component.html

@@ -11,10 +11,10 @@
         <div class="coach-hero-error"><lucide-icon [img]="icons.AlertCircle" [size]="22" /><span>{{ coachStatsError() }}</span><button type="button" (click)="retryCoachStats()">重新加载</button></div>
       } @else {
         <div class="coach-grid" [class.loading]="statsLoading()">
-          <div><strong>{{ statsLoading() ? '—' : (stats()['t30'] || 0) }}</strong><span>30分钟课程</span></div>
-          <div><strong>{{ statsLoading() ? '—' : (stats()['t60'] || 0) }}</strong><span>60分钟课程</span></div>
-          <div><strong>{{ statsLoading() ? '—' : (stats()['t_tiyan'] || 0) }}</strong><span>体验课</span></div>
-          <div><strong>{{ statsLoading() ? '—' : (stats()['t_total'] || 0) }}</strong><span>上课总次数</span></div>
+          <div><strong>{{ statsLoading() ? '—' : (stats()['t30'] || 0) }}</strong><span>30分钟训练</span></div>
+          <div><strong>{{ statsLoading() ? '—' : (stats()['t60'] || 0) }}</strong><span>60分钟训练</span></div>
+          <div><strong>{{ statsLoading() ? '—' : (stats()['t_tiyan'] || 0) }}</strong><span>体验训练</span></div>
+          <div><strong>{{ statsLoading() ? '—' : (stats()['t_total'] || 0) }}</strong><span>训练总次数</span></div>
         </div>
         <div class="coach-footer"><span>累计时长 {{ statsLoading() ? '—' : (stats()['t_shichang'] || 0) }}</span><span class="commission">累计收入 <span class="commission-amount">{{ commissionHidden() ? '***' : (statsLoading() ? '加载中' : totalIncome() === null ? '暂不可用' : '¥' + (totalIncome() | number:'1.2-2')) }}</span><button type="button" (click)="commissionHidden.set(!commissionHidden())" [attr.aria-label]="commissionHidden() ? '显示累计收入' : '隐藏累计收入'"><lucide-icon [img]="commissionHidden() ? icons.EyeOff : icons.Eye" [size]="20" /></button></span></div>
       }
@@ -24,7 +24,7 @@
       <header>
         <div class="coach-memory-heading">
           <span class="coach-memory-icon"><lucide-icon [img]="icons.Clock3" [size]="22" /></span>
-          <div><span>当天完成才计复习工资</span><h2 id="coach-memory-title">今日抗遗忘带练</h2></div>
+          <div><span>当天完成才计复习费用</span><h2 id="coach-memory-title">今日抗遗忘带练</h2></div>
         </div>
         <button type="button" class="coach-memory-refresh" [disabled]="antiForgetLoading()" (click)="retryAntiForget()" aria-label="刷新抗遗忘任务"><lucide-icon [img]="icons.RefreshCw" [size]="18" [class.spin]="antiForgetLoading()" /></button>
       </header>
@@ -106,9 +106,9 @@
   @if (isCoach()) {
     <section class="quick-grid coach-quick-grid" aria-label="老师工作入口">
       <a routerLink="/pages/home/shop"><span class="quick-icon learn"><lucide-icon [img]="icons.CalendarDays" [size]="25" /></span><span><strong>陪练排课</strong><small>查看今天与未来课程</small></span><lucide-icon class="quick-arrow" [img]="icons.ChevronRight" [size]="19" /></a>
-      <a routerLink="/pages/member/ke_xiao"><span class="quick-icon review"><lucide-icon [img]="icons.Check" [size]="25" /></span><span><strong>销课记录</strong><small>核对已完成课程</small></span><lucide-icon class="quick-arrow" [img]="icons.ChevronRight" [size]="19" /></a>
+      <a routerLink="/pages/member/ke_xiao"><span class="quick-icon review"><lucide-icon [img]="icons.Check" [size]="25" /></span><span><strong>陪练记录</strong><small>核对已完成课程</small></span><lucide-icon class="quick-arrow" [img]="icons.ChevronRight" [size]="19" /></a>
       <a routerLink="/pages/his/his"><span class="quick-icon history"><lucide-icon [img]="icons.BookOpen" [size]="25" /></span><span><strong>学员记录</strong><small>按学员查看学习成果</small></span><lucide-icon class="quick-arrow" [img]="icons.ChevronRight" [size]="19" /></a>
-      <a routerLink="/pages/member/ke_fuxi"><span class="quick-icon exam"><lucide-icon [img]="icons.BarChart3" [size]="25" /></span><span><strong>抗遗忘工资</strong><small>查看抗遗忘复习工资</small></span><lucide-icon class="quick-arrow" [img]="icons.ChevronRight" [size]="19" /></a>
+      <a routerLink="/pages/member/ke_fuxi"><span class="quick-icon exam"><lucide-icon [img]="icons.BarChart3" [size]="25" /></span><span><strong>抗遗忘费用</strong><small>查看抗遗忘复习费用</small></span><lucide-icon class="quick-arrow" [img]="icons.ChevronRight" [size]="19" /></a>
     </section>
   } @else {
     <section class="quick-grid" aria-label="学习入口">

+ 2 - 2
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html

@@ -99,7 +99,7 @@
           <span>{{ wordPreviewPage() }} / {{ wordPreviewPageCount() }} 页</span>
         </nav>
       }
-      <p class="readonly-word-note">{{ isHistoryWordView ? '点击单词或喇叭可听读音,复习不会改动原学习结果' : '学生可查看老师分配的单词,点读、跟读和学习记录由陪练老师在上课时操作' }}</p>
+      <p class="readonly-word-note">{{ isHistoryWordView ? '点击单词或喇叭可听读音,复习不会改动原学习结果' : '学生可查看老师分配的单词,点读、跟读和学习记录由陪练老师在训练时操作' }}</p>
     </section>
   } @else if (isSelectionPage) {
     <header class="selection-header" [class.refine-selection-header]="isRefineSelectionPage">
@@ -435,7 +435,7 @@
         <a class="secondary-button" routerLink="/pages/home/index">返回首页</a>
       </section>
     } @else {
-    @if(canStartPointReading && overdueReview){<p class="record-tip" role="status">这是逾期任务,可以补做,但不计复习工资。</p>}
+    @if(canStartPointReading && overdueReview){<p class="record-tip" role="status">这是逾期任务,可以补做,但不计复习费用。</p>}
 
     <section class="record-overview">
       <header><div><span>单词复习</span><strong>先回忆,再核对</strong></div><b>{{ seen().size }} / {{ words().length }}</b></header>

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -327,7 +327,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
 
   ngOnInit(): void {
     if (isTeacherLedLearningPath(this.meta.path) && !this.isReadOnlyWordView && !this.isReadOnlySelectionPage && !this.canStartPointReading && !this.isNewWordSelfStudyStage) {
-      this.platform.toast('点读学习由陪练老师在上课时发起', 'error');
+      this.platform.toast('点读学习由陪练老师在训练时发起', 'error');
       void this.router.navigateByUrl('/pages/home/cart');
       return;
     }

+ 1 - 1
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.html

@@ -37,7 +37,7 @@
     }
   }
   @if (meta.path === 'pages/member/myorder_appeal') { <div class="record-tabs"><button type="button" [class.active]="activeTab() === 0" (click)="selectTab(0)">我的退货</button><button type="button" [class.active]="activeTab() === 1" (click)="selectTab(1)">我的换货</button></div> }
-  @if (meta.path === 'pages/member/ke_fuxi') { <div class="income-summary"><span>抗遗忘工资合计</span><strong>{{ loading() || error() || totalIncome() == null ? '—' : '¥' + (totalIncome() | number:'1.2-2') }}</strong><small>仅计划当天首次完成计薪,逾期补做不计薪</small></div> }
+  @if (meta.path === 'pages/member/ke_fuxi') { <div class="income-summary"><label>月份 <input type="month" [(ngModel)]="incomeMonth" (ngModelChange)="recordPage.set(1);load()" /></label><button type="button" (click)="incomeMonth='';recordPage.set(1);load()">全部月份</button><span>{{incomeMonth || '全部月份'}} · 抗遗忘费用合计</span><strong>{{ loading() || error() || totalIncome() == null ? '—' : '¥' + (totalIncome() | number:'1.2-2') }}</strong><small>按计划复习月份汇总全部分页,共 {{serverTotal()}} 条;仅计划当天首次完成计费,逾期补做不计费</small></div> }
   @if (meta.path === 'pages/member/myorder' && items().length) { <section class="mastery"><h2>当前已掌握</h2><div><span>{{ items()[0]['learned'] || 0 }}<small>学习单词</small></span><span>{{ items()[0]['ygg'] || 0 }}<small>抗遗忘单词</small></span><span>{{ items()[0]['djq'] || 0 }}<small>待抗遗忘</small></span></div><footer><a routerLink="/pages/stu/index">继续学习</a><a routerLink="/pages/stu/stu_calendar">科学抗遗忘</a></footer></section> }
 
   @if (meta.path === 'pages/his/his' && session.groupId() === 3 && !studentId) {

+ 3 - 2
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.ts

@@ -63,6 +63,7 @@ export class StudyRecordsPageComponent implements OnInit, OnDestroy {
   readonly refreshedAt = signal('');
   readonly liveSyncStatus = signal<LiveSyncStatus>({ state: 'current' });
   readonly error = signal('');
+  incomeMonth = '';
   readonly totalIncome = signal<number|null>(null);
   readonly activeTab = signal(0);
   readonly datePickerOpen = signal(false);
@@ -260,7 +261,7 @@ export class StudyRecordsPageComponent implements OnInit, OnDestroy {
     return Boolean(this.courseId(item) || this.courseBindingId(item) || this.assignedUnits(item).length);
   }
 
-  courseCategory(item: Item): string { return String(item['categoryName'] || ({word:'单词课',trial:'体验课',primary_writing:'小学语法写作课',middle_writing:'中学语法写作课',high_writing:'高中语法写作课',word_self_study:'单词自学课'} as Record<string,string>)[String(item['categoryKey'])] || ''); }
+  courseCategory(item: Item): string { return String(item['categoryName'] || ({word:'单词课',trial:'体验训练',primary_writing:'小学语法写作课',middle_writing:'中学语法写作课',high_writing:'高中语法写作课',word_self_study:'单词自学课'} as Record<string,string>)[String(item['categoryKey'])] || ''); }
   courseTitle(item: Item): string {
     const sourceTitle = String(item['Title'] ?? '').trim();
     if (sourceTitle.includes('@')) {
@@ -616,7 +617,7 @@ export class StudyRecordsPageComponent implements OnInit, OnDestroy {
 
   private loadIncome(uid: number): void {
     this.historyRequest?.unsubscribe();const requestId=++this.historyRequestId;
-    this.historyRequest=this.api.post<ReviewPage>('app_review_page',{uid,page:this.recordPage(),pageSize:20,status:this.meta.path==='pages/member/ke_fuxi'?'completed':undefined}).pipe(finalize(()=>{if(requestId===this.historyRequestId)this.loading.set(false);})).subscribe({next:({result})=>{if(requestId!==this.historyRequestId)return;this.items.set(result.items);this.serverTotal.set(result.page.itemCount);this.totalIncome.set(result.summary.income);},error:(e:ApiError)=>this.error.set(e.message)});
+    this.historyRequest=this.api.post<ReviewPage>('app_review_page',{uid,month:this.incomeMonth || undefined,page:this.recordPage(),pageSize:20,status:this.meta.path==='pages/member/ke_fuxi'?'completed':undefined}).pipe(finalize(()=>{if(requestId===this.historyRequestId)this.loading.set(false);})).subscribe({next:({result})=>{if(requestId!==this.historyRequestId)return;this.items.set(result.items);this.serverTotal.set(result.page.itemCount);this.totalIncome.set(result.summary.income);},error:(e:ApiError)=>this.error.set(e.message)});
   }
 
   private asList(value: unknown): Item[] {

+ 1 - 1
scripts/cloud/mobile-teaching.js

@@ -46,7 +46,7 @@ async function appReviewPage(input,current) {
   const where=clauses.join(' AND '),countPromise=Psql.one('SELECT COUNT(*)::int total FROM "MemoryPracticeRecord" m WHERE '+where,args);
   const rowsPromise=Psql.query('SELECT c.*,row_to_json(m) AS "__addon",n."nodeName" AS "kc_title",d."dqrq" AS "learned_date",d."createdAt" AS "learned_at",r."amount" AS money,r."status" AS "rewardStatus",r."ineligibleReason" AS "ineligibleReason",r."completedAt" AS "completedAt",r."originalAmount" AS "originalAmount",r."correctionReason" AS "correctionReason",r."correctionAmount" AS "correctionAmount",'+reviewStudentNameSql()+' AS "honeyName",(SELECT COUNT(*) FROM "MemoryPracticeRecord" sibling WHERE sibling."company"=m."company" AND sibling."yhid"=m."yhid" AND sibling."xxjlid"=m."xxjlid" AND (sibling."kywsj",sibling."id")<=(m."kywsj",m."id"))::int AS "reviewNo" FROM "MemoryPracticeRecord" m JOIN "CommonModel" c ON c."company"=m."company" AND c."modelId"=60 AND c."itemId"=m."id" LEFT JOIN "Node" n ON n."company"=m."company" AND n."nodeId"::text=m."kcid"::text LEFT JOIN "CommonModel" dc ON dc."company"=m."company" AND dc."modelId"=56 AND dc."generalId"::text=m."xxjlid"::text LEFT JOIN "DailyStudyRecord" d ON d."company"=m."company" AND d."id"=dc."itemId" LEFT JOIN "ReviewRewardIncome" r ON r."company"=m."company" AND r."taskId"=m."objectId" LEFT JOIN "_User" u ON u."company"=m."company" AND u."legacyUserId"=m."yhid" WHERE '+where+(input.scope==='today'?' ORDER BY CASE WHEN m."fxzt"=\'1\' THEN 1 ELSE 0 END,m."kywsj",m."id"':' ORDER BY m."kywsj" DESC,m."id" DESC')+' LIMIT $'+(args.length+1)+' OFFSET $'+(args.length+2),[...args,p.size,(p.index-1)*p.size]);
   const versionTarget=coach&&number(input.studentId)?number(input.studentId):uid;
-  const [students,summary,dates,count,rows,income,versions]=await Promise.all([studentsPromise,summaryPromise,datesPromise,countPromise,rowsPromise,coach&&input.includeIncome!==false?reviewIncomeTotal(DEFAULT_COMPANY_ID,uid):0,appLiveVersionSnapshot(versionTarget,['review','account'],current)]);
+  const [students,summary,dates,count,rows,income,versions]=await Promise.all([studentsPromise,summaryPromise,datesPromise,countPromise,rowsPromise,coach&&input.includeIncome!==false?Psql.one('SELECT COALESCE(SUM(r."amount"),0) AS income FROM "MemoryPracticeRecord" m JOIN "ReviewRewardIncome" r ON r."company"=m."company" AND r."taskId"=m."objectId" WHERE '+where,args).then(row=>number(row.income)):0,appLiveVersionSnapshot(versionTarget,['review','account'],current)]);
   return {items:rows.map(row=>{const addon=row.__addon;delete row.__addon;return{...legacyAliases({...row,...addon},'CommonModel'),money:number(row.money),rewardStatus:row.rewardStatus||'pending'};}),page:mobilePageMeta(count.total,p),summary:{...summary,todayTotal:number(summary.today),todayCompleted:number(summary.todayCompleted),todayPending:number(summary.today)-number(summary.todayCompleted),overduePending:number(summary.overduePending),income},dates,students,legacyReviewSync,serverToday:today,...mobileSource(),...versions};
 }
 

+ 7 - 1
scripts/deploy-admin-functions.mjs

@@ -2883,7 +2883,13 @@ async function appCompanionOverview(input,current) {
   const where=clauses.join(' AND '),count=await Psql.one('SELECT COUNT(*)::int total'+join+' WHERE '+where,args);
   const rows=await Psql.query('SELECT c."generalId",c."title",c."createTime",c."status" AS "commonStatus",a.*,n."nodeName" AS "courseTitle"'+join+' LEFT JOIN "Node" n ON n."company"=a."company" AND n."nodeId"::text=a."kcid" WHERE '+where+' ORDER BY a."yysj" '+(history?'DESC':'ASC')+',a."id" DESC LIMIT $'+(args.length+1)+' OFFSET $'+(args.length+2),[...args,p.size,(p.index-1)*p.size]);
   const courses=coach?[]:await Psql.query('SELECT c."generalId",c."title",b.*,b."objectId" AS "bindingObjectId",n."nodeName" AS "courseTitle" FROM "CourseBinding" b JOIN "CommonModel" c ON c."company"=b."company" AND c."modelId"=58 AND c."itemId"=b."id" LEFT JOIN "Node" n ON n."company"=b."company" AND n."nodeId"::text=b."kcid" WHERE b."company"=$1 AND b."yhid"=$2 AND b."enrollmentStatus"=\'active\' AND c."status"=99 AND COALESCE(n."zstatus",99)=99 ORDER BY b."id" DESC',[DEFAULT_COMPANY_ID,String(uid)]);
-  const live=input.source==='database'?await databaseAppointmentVersions({studentId:coach?input.studentId:undefined},current):await appLiveVersionSnapshot(uid,['courses','appointments'],current);return {schedules:(coach&&history?await teacherSchedulePay(rows,uid):rows).map(appOverviewScheduleRow),courses:courses.map(appOverviewCourseRow),page:mobilePageMeta(count.total,p),dates,studentOptions,...mobileSource(),...live};
+  let earningsSummary;
+  if(coach&&history){
+    const allRows=await Psql.query('SELECT c."generalId",c."status" AS "commonStatus",a.*'+join+' WHERE '+where,args);
+    const paidRows=await teacherSchedulePay(allRows,uid);
+    earningsSummary={amount:paidRows.reduce((sum,row)=>sum+(Number(row.commonStatus)!==-2&&row.payStatus==='earned'?Math.round(Number(row.teacherPay)*100):0),0)/100,pendingCount:paidRows.filter(row=>Number(row.commonStatus)!==-2&&row.payStatus!=='earned').length};
+  }
+  const live=input.source==='database'?await databaseAppointmentVersions({studentId:coach?input.studentId:undefined},current):await appLiveVersionSnapshot(uid,['courses','appointments'],current);return {earningsSummary,schedules:(coach&&history?await teacherSchedulePay(rows,uid):rows).map(appOverviewScheduleRow),courses:courses.map(appOverviewCourseRow),page:mobilePageMeta(count.total,p),dates,studentOptions,...mobileSource(),...live};
 }
 async function appAccountOverview(input,current) {
   const uid=await authorizeRequestedUser(current,input.uid),groupId=number(current.get('legacyGroupId')||legacyData(current).GroupID),profile={...legacyUser(current),...legacyUserAddon(current)},today=appOverviewToday(),todayCompact=today.replace(/-/g,'');

+ 5 - 1
scripts/tests/review-student-names.test.mjs

@@ -28,9 +28,13 @@ test('老师复习列表待复习、已复习及翻页均返回姓名,保持
   const query=async(s,a)=>(await db.query(s,a)).rows;
   const ctx=vm.createContext({Date,Promise,DEFAULT_COMPANY_ID:'co',number:(v,f=0)=>Number(v)||f,authorizeRequestedUser:async()=>30,appOverviewToday:()=> '2026-10-14',legacyAliases:r=>r,appLiveVersionSnapshot:async()=>({}),reviewIncomeTotal:async()=>0.5,Psql:{query,one:async(s,a)=>(await query(s,a))[0]}});vm.runInContext(source,ctx);
   for(const [status,page] of [['pending',1],['pending',2],['completed',1]]){
-   const result=await ctx.appReviewPage({status,page,pageSize:10},{get:()=>3});assert.ok(result.items.length);assert.ok(result.items.every(r=>r.honeyName==='小陈'&&Number(r.plid)===30));assert.equal(result.summary.income,0.5);
+   const result=await ctx.appReviewPage({status,page,pageSize:10},{get:()=>3});assert.ok(result.items.length);assert.ok(result.items.every(r=>r.honeyName==='小陈'&&Number(r.plid)===30));assert.equal(result.summary.income,status==='completed'?0.5:0);
    if(status==='completed'){assert.equal(result.items[0].money,0.5);assert.equal(result.items[0].rewardStatus,'earned');}
   }
+  const monthly=await ctx.appReviewPage({status:'completed',month:'2026-10',pageSize:1},{get:()=>3});
+  assert.equal(monthly.summary.income,0.5);
+  const emptyMonth=await ctx.appReviewPage({status:'completed',month:'2026-09'},{get:()=>3});
+  assert.equal(emptyMonth.summary.income,0);assert.equal(emptyMonth.page.itemCount,0);
   await db.exec('DELETE FROM "_User"');
   const historical=await ctx.appReviewPage({status:'completed'},{get:()=>3});assert.equal(historical.items[0].honeyName,'历史学员');
  }finally{await db.close();}

+ 10 - 0
scripts/tests/teacher-earnings.test.mjs

@@ -31,3 +31,13 @@ test('今日查询在分页前筛选,超过20条未来任务仍不遮挡今日
   const overdue=await ctx.appReviewPage({scope:'overdue',includeMetadata:false,includeIncome:false},current);assert.equal(overdue.items.length,1);assert.equal(overdue.items[0].kywrq,'2026-10-13');
  }finally{await db.close();}
 });
+test('陪练费用合计覆盖全部分页并排除取消、区分零元和待核对',async()=>{
+ const fn=source.slice(source.indexOf('async function appCompanionOverview('),source.indexOf('async function appAccountOverview('));
+ const all=Array.from({length:25},(_,i)=>({generalId:i+1,dszt:30,commonStatus:i===24?-2:99,teacherPay:i===0?0:45,payStatus:i===23?'pending':'earned'}));
+ const queries=[];
+ const ctx=vm.createContext({DEFAULT_COMPANY_ID:'co',number:Number,authorizeRequestedUser:async()=>30,assertLessonTenant:()=>{},mobilePage:()=>({index:1,size:20}),mobilePageMeta:(total)=>({itemCount:total}),companionStudentNameSql:()=>"'学员'",databaseAppointmentVersions:async()=>({}),mobileSource:()=>({}),appOverviewScheduleRow:r=>r,appOverviewCourseRow:r=>r,teacherSchedulePay:async rows=>rows,Psql:{one:async()=>({total:25}),query:async(sql,args)=>{queries.push({sql,args});return sql.startsWith('SELECT c."generalId"')?(sql.includes(' LIMIT ')?all.slice(0,20):all):[];}}});
+ vm.runInContext(fn,ctx);
+ const result=await ctx.appCompanionOverview({history:true,source:'database',month:'2026-09'},{get:()=>3});
+ assert.equal(result.schedules.length,20);assert.equal(result.earningsSummary.amount,990);assert.equal(result.earningsSummary.pendingCount,1);
+ const aggregate=queries.find(q=>q.sql.startsWith('SELECT c."generalId"')&&!q.sql.includes(' LIMIT '));assert.ok(aggregate.args.includes('2026-09'));
+});

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