Procházet zdrojové kódy

Align admin overview metrics with scheduling cards

彭峰 před 4 dny
rodič
revize
f678e295bb

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

@@ -30,7 +30,7 @@
       <div class="trend-state error" role="alert">{{trendError()}}<button type="button" (click)="selectTrendPeriod(trendPeriod())">重新加载</button></div>
       <div class="trend-state error" role="alert">{{trendError()}}<button type="button" (click)="selectTrendPeriod(trendPeriod())">重新加载</button></div>
     }@else{
     }@else{
     @if(trend();as chart){
     @if(trend();as chart){
-      <div class="trend-summary">
+      <div class="trend-summary overview-metrics">
         @if(selectedTrendPoint();as selected){
         @if(selectedTrendPoint();as selected){
           <div class="selected-date"><span>{{selected.isToday?'今日运营':'当日运营'}}</span><strong>{{selected.label}}</strong><small>已完课 {{selected.completedCount}} 节</small></div>
           <div class="selected-date"><span>{{selected.isToday?'今日运营':'当日运营'}}</span><strong>{{selected.label}}</strong><small>已完课 {{selected.completedCount}} 节</small></div>
           <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>

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

@@ -9,7 +9,7 @@
 </section>
 </section>
 @if(message()){<p class="notice" role="status">{{message()}}</p>}@if(error()&&!drawerOpen()){<p class="notice error" role="alert">{{error()}}</p>}
 @if(message()){<p class="notice" role="status">{{message()}}</p>}@if(error()&&!drawerOpen()){<p class="notice error" role="alert">{{error()}}</p>}
 @if(page()&&!page()?.liveSourceAvailable&&page()?.liveSourceMessage){<p class="notice warning" role="status">{{page()?.liveSourceMessage}}</p>}
 @if(page()&&!page()?.liveSourceAvailable&&page()?.liveSourceMessage){<p class="notice warning" role="status">{{page()?.liveSourceMessage}}</p>}
-@if(page()?.summary){<section class="coach-summary" aria-label="陪练老师统计">
+@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?.total||0}}</strong><small>按历史排课与授课证据识别</small></article>
   <article><span>正常账号</span><strong>{{page()?.summary?.active||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?.monthActive||0}}</strong><small>本月已产生上课记录</small></article>

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

@@ -28,7 +28,7 @@
 <ng-template #dateCalendar><div class="calendar-popover day-popover" role="dialog" aria-label="选择日期" (click)="$event.stopPropagation()"><header><button type="button" aria-label="上一月" (click)="shiftCalendar(-1,$event)"><lucide-icon [img]="icons.ChevronLeft" [size]="17" /></button><strong>{{ calendarTitle() }}</strong><button type="button" aria-label="下一月" (click)="shiftCalendar(1,$event)"><lucide-icon [img]="icons.ChevronRight" [size]="17" /></button></header><div class="week-grid" aria-hidden="true">@for(day of weekDays;track day){<span>{{ day }}</span>}</div><div class="day-grid">@for(day of calendarDays();track $index){@if(day){<button type="button" [class.today]="day.date===(today | date:'yyyy-MM-dd')" [class.selected]="day.date===selectedDate()" [attr.aria-label]="day.date" [attr.aria-current]="day.date===selectedDate()?'date':null" (click)="selectDate(day.date)">{{ day.day }}</button>}@else{<span></span>}}</div></div></ng-template>
 <ng-template #dateCalendar><div class="calendar-popover day-popover" role="dialog" aria-label="选择日期" (click)="$event.stopPropagation()"><header><button type="button" aria-label="上一月" (click)="shiftCalendar(-1,$event)"><lucide-icon [img]="icons.ChevronLeft" [size]="17" /></button><strong>{{ calendarTitle() }}</strong><button type="button" aria-label="下一月" (click)="shiftCalendar(1,$event)"><lucide-icon [img]="icons.ChevronRight" [size]="17" /></button></header><div class="week-grid" aria-hidden="true">@for(day of weekDays;track day){<span>{{ day }}</span>}</div><div class="day-grid">@for(day of calendarDays();track $index){@if(day){<button type="button" [class.today]="day.date===(today | date:'yyyy-MM-dd')" [class.selected]="day.date===selectedDate()" [attr.aria-label]="day.date" [attr.aria-current]="day.date===selectedDate()?'date':null" (click)="selectDate(day.date)">{{ day.day }}</button>}@else{<span></span>}}</div></div></ng-template>
 
 
 @if (data(); as pageData) {
 @if (data(); as pageData) {
-  <section class="report-metrics" aria-label="学习报表摘要">
+  <section class="report-metrics overview-metrics" aria-label="学习报表摘要">
     <article><span>学习报表</span><strong>{{ pageData.summary.reportCount }}</strong><small>当前筛选范围</small></article>
     <article><span>学习报表</span><strong>{{ pageData.summary.reportCount }}</strong><small>当前筛选范围</small></article>
     <article><span>学习学员</span><strong>{{ pageData.summary.studentCount }}</strong><small>去重学员人数</small></article>
     <article><span>学习学员</span><strong>{{ pageData.summary.studentCount }}</strong><small>去重学员人数</small></article>
     <article><span>学习单词</span><strong>{{ pageData.summary.learnedCount }}</strong><small>全部学习词次</small></article>
     <article><span>学习单词</span><strong>{{ pageData.summary.learnedCount }}</strong><small>全部学习词次</small></article>

+ 186 - 0
projects/xiaoshu-admin/src/app/pages/operations-overview-metrics.scss

@@ -0,0 +1,186 @@
+/* Shared dashboard-card composition, scoped to the seven overview summaries. */
+.overview-metrics:not(.trend-summary) > article {
+  display: grid !important;
+  grid-template-columns: minmax(0, 1fr) auto;
+  grid-template-rows: auto 1fr;
+  align-items: center;
+  column-gap: 12px;
+  row-gap: 0;
+  min-width: 0;
+  min-height: 124px;
+}
+
+.overview-metrics:not(.trend-summary) > article > span {
+  grid-column: 1;
+  grid-row: 1;
+  align-self: start;
+  min-width: 0;
+  line-height: 1.35;
+}
+
+.overview-metrics:not(.trend-summary) > article > small {
+  grid-column: 1;
+  grid-row: 2;
+  align-self: end;
+  min-width: 0;
+  margin: 0;
+  line-height: 1.4;
+  white-space: normal;
+  overflow-wrap: anywhere;
+}
+
+.overview-metrics:not(.trend-summary) > article > strong {
+  grid-column: 2;
+  grid-row: 1 / span 2;
+  justify-self: end;
+  align-self: center;
+  min-width: 0;
+  max-width: 100%;
+  margin: 0;
+  color: #21493c;
+  font-size: clamp(38px, 3vw, 54px) !important;
+  line-height: 1;
+  font-variant-numeric: tabular-nums;
+  text-align: right;
+  white-space: nowrap;
+}
+
+/* Amounts need more horizontal space than counts, especially with a currency sign. */
+app-operations-payroll .payroll-metrics.overview-metrics {
+  grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
+}
+
+app-operations-payroll .payroll-metrics.overview-metrics > article > strong {
+  font-size: clamp(30px, 2.35vw, 43px) !important;
+  white-space: normal;
+  overflow-wrap: anywhere;
+}
+
+/* The selected day is context; the four live figures are the metrics. */
+app-admin-dashboard .trend-summary.overview-metrics {
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+app-admin-dashboard .trend-summary.overview-metrics .selected-date {
+  grid-column: 1 / -1;
+  min-height: 58px;
+  border-right: 0;
+  border-bottom: 1px solid #e3eae7;
+}
+
+app-admin-dashboard .trend-summary.overview-metrics .trend-stat {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto;
+  grid-template-rows: auto 1fr;
+  align-items: center;
+  min-height: 120px;
+  column-gap: 10px;
+}
+
+app-admin-dashboard .trend-summary.overview-metrics .trend-stat > span {
+  grid-column: 1;
+  grid-row: 1;
+  align-self: start;
+}
+
+app-admin-dashboard .trend-summary.overview-metrics .trend-stat > small {
+  grid-column: 1;
+  grid-row: 2;
+  align-self: end;
+  min-width: 0;
+  line-height: 1.3;
+}
+
+app-admin-dashboard .trend-summary.overview-metrics .trend-stat > strong {
+  grid-column: 2;
+  grid-row: 1 / span 2;
+  justify-self: end;
+  align-self: center;
+  min-width: 0;
+  margin: 0;
+  font-size: clamp(36px, 2.6vw, 50px) !important;
+  font-variant-numeric: tabular-nums;
+  text-align: right;
+  white-space: nowrap;
+}
+
+app-admin-dashboard .trend-summary.overview-metrics .trend-stat.pay > strong {
+  font-size: clamp(29px, 2.1vw, 42px) !important;
+}
+
+app-admin-dashboard .trend-summary.overview-metrics .trend-stat:is(.coaches, .pay) {
+  padding-right: 24px;
+}
+
+app-admin-dashboard .trend-summary.overview-metrics .trend-stat > lucide-icon {
+  top: 17px !important;
+  right: 8px !important;
+  transform: none !important;
+}
+
+app-admin-dashboard .welcome h2 {
+  overflow-wrap: anywhere;
+}
+
+@media (max-width: 1600px) {
+  app-operations-learning-reports .report-metrics.overview-metrics {
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+  }
+}
+
+@media (max-width: 1250px) {
+  app-admin-dashboard .trend-summary.overview-metrics {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  app-admin-dashboard .trend-summary.overview-metrics .trend-stat:nth-child(odd) {
+    border-right: 0;
+  }
+
+  app-admin-dashboard .trend-summary.overview-metrics .trend-stat:nth-child(-n+3) {
+    border-bottom: 1px solid #e3eae7;
+  }
+}
+
+@media (max-width: 760px) {
+  app-operations-learning-reports .report-metrics.overview-metrics {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .overview-metrics:not(.trend-summary) > article {
+    min-height: 108px;
+  }
+
+  .overview-metrics:not(.trend-summary) > article > strong {
+    font-size: clamp(32px, 7vw, 44px) !important;
+  }
+
+  app-admin-dashboard .trend-summary.overview-metrics .trend-stat > strong {
+    font-size: clamp(30px, 6vw, 42px) !important;
+  }
+}
+
+@media (max-width: 620px) {
+  app-operations-learning-reports .report-metrics.overview-metrics {
+    grid-template-columns: minmax(0, 1fr);
+  }
+
+  app-admin-dashboard .trend-summary.overview-metrics {
+    grid-template-columns: minmax(0, 1fr);
+  }
+
+  app-admin-dashboard .trend-summary.overview-metrics .trend-stat {
+    min-height: 100px;
+    border-right: 0;
+    border-bottom: 1px solid #e3eae7;
+  }
+
+  app-admin-dashboard .trend-summary.overview-metrics .trend-stat:last-child {
+    border-bottom: 0;
+  }
+
+  app-admin-dashboard .trend-summary.overview-metrics .trend-stat > strong,
+  app-admin-dashboard .trend-summary.overview-metrics .trend-stat.pay > strong {
+    font-size: clamp(38px, 10vw, 48px) !important;
+  }
+}

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

@@ -6,7 +6,7 @@
 @if(tab()==='overview'){
 @if(tab()==='overview'){
  @if(overview();as data){
  @if(overview();as data){
   @if(data.sourceStatus.sync;as sync){<div class="sync-status" [class.delayed]="!sync.settlementReady" role="status"><strong>{{sync.settlementReady?'工资截止来源已确认':sync.state==='batch_verified'?'旧接口已同步 · 结算待核对':'结算暂不可用'}}</strong><span>来源同步:{{sync.lastSuccessAt?(sync.lastSuccessAt|date:'yyyy-MM-dd HH:mm:ss':'+0800'):'尚无成功记录'}} · 延迟 {{sync.lagSeconds==null?'未知':sync.lagSeconds+' 秒'}}</span><small>{{sync.message}}</small></div>}@else{<p class="notice error">无法核验旧系统同步状态,当前统计仅供查看,不能结算。</p>}
   @if(data.sourceStatus.sync;as sync){<div class="sync-status" [class.delayed]="!sync.settlementReady" role="status"><strong>{{sync.settlementReady?'工资截止来源已确认':sync.state==='batch_verified'?'旧接口已同步 · 结算待核对':'结算暂不可用'}}</strong><span>来源同步:{{sync.lastSuccessAt?(sync.lastSuccessAt|date:'yyyy-MM-dd HH:mm:ss':'+0800'):'尚无成功记录'}} · 延迟 {{sync.lagSeconds==null?'未知':sync.lagSeconds+' 秒'}}</span><small>{{sync.message}}</small></div>}@else{<p class="notice error">无法核验旧系统同步状态,当前统计仅供查看,不能结算。</p>}
-  <section class="payroll-metrics" aria-label="工资收益摘要"><article><span>参与陪练</span><strong>{{data.coachCount}}</strong><small>本月有课酬或抗遗忘工资</small></article><article><span>有效课次</span><strong>{{data.lessonCount}}</strong><small>含课型4 {{data.type4Count||0}} 节</small></article><article><span>总工时</span><strong>{{data.totalHours|number:'1.1-1'}}h</strong><small>课型4不计工时</small></article><article><span>基础工资</span><strong>¥{{data.baseAmount|number:'1.2-2'}}</strong><small>优先使用已保存的工资快照</small></article><article><span>抗遗忘工资</span><strong>¥{{data.reviewAmount||0|number:'1.2-2'}}</strong><small>有效复习 {{data.reviewCount||0}} 次</small></article><article><span>当月收入</span><strong>¥{{data.payableAmount|number:'1.2-2'}}</strong><small>按收入月份汇总,结算调整另列</small></article><article><span>批次结算应发</span><strong>{{data.settlementAmount==null?'未生成':'¥'+(data.settlementAmount|number:'1.2-2')}}</strong><small>调整 ¥{{data.settlementAdjustmentAmount||0|number:'1.2-2'}} · 补结 ¥{{data.settlementCarryAmount||0|number:'1.2-2'}}</small></article><article class="exception-card"><span>异常记录</span><strong>{{data.exceptionCount}}</strong><small>审核工资前必须处理</small></article></section>
+  <section class="payroll-metrics overview-metrics" aria-label="工资收益摘要"><article><span>参与陪练</span><strong>{{data.coachCount}}</strong><small>本月有课酬或抗遗忘工资</small></article><article><span>有效课次</span><strong>{{data.lessonCount}}</strong><small>含课型4 {{data.type4Count||0}} 节</small></article><article><span>总工时</span><strong>{{data.totalHours|number:'1.1-1'}}h</strong><small>课型4不计工时</small></article><article><span>基础工资</span><strong>¥{{data.baseAmount|number:'1.2-2'}}</strong><small>优先使用已保存的工资快照</small></article><article><span>抗遗忘工资</span><strong>¥{{data.reviewAmount||0|number:'1.2-2'}}</strong><small>有效复习 {{data.reviewCount||0}} 次</small></article><article><span>当月收入</span><strong>¥{{data.payableAmount|number:'1.2-2'}}</strong><small>按收入月份汇总,结算调整另列</small></article><article><span>批次结算应发</span><strong>{{data.settlementAmount==null?'未生成':'¥'+(data.settlementAmount|number:'1.2-2')}}</strong><small>调整 ¥{{data.settlementAdjustmentAmount||0|number:'1.2-2'}} · 补结 ¥{{data.settlementCarryAmount||0|number:'1.2-2'}}</small></article><article class="exception-card"><span>异常记录</span><strong>{{data.exceptionCount}}</strong><small>审核工资前必须处理</small></article></section>
   <section class="content-card payroll-table-card"><header><div><strong>{{data.month}} 老师收益汇总</strong><span>点击老师查看课酬与抗遗忘工资明细</span></div>@if(isManager()){<button class="ui-button primary create-payroll-action" type="button" (click)="create()" [disabled]="saving()||!data.sourceStatus.sync?.settlementReady"><lucide-icon [img]="icons.Plus" [size]="17" />生成全公司工资草稿</button>}</header><div class="table-scroll"><table class="data-table payroll-summary-table"><thead><tr><th>陪练老师</th><th>门店</th><th>30分钟</th><th>60分钟</th><th>体验课</th><th>课型4</th><th>总课次</th><th>工时</th><th>基础工资</th><th>复习次数</th><th>抗遗忘工资</th><th>当月收入</th><th>明细</th></tr></thead><tbody>@for(row of data.coachItems;track row.coachId){<tr><td><strong>{{coachLabel(row.coachName,row.coachId)}}</strong><small>ID {{row.coachId}}</small></td><td>{{row.storeName}}</td><td>{{row.type30Count}}</td><td>{{row.type60Count}}</td><td>{{row.trialCount}}</td><td>{{row.type4Count}}</td><td>{{row.lessonCount}}</td><td>{{row.totalHours|number:'1.1-1'}}h</td><td>¥{{row.baseAmount|number:'1.2-2'}}</td><td>{{row.reviewCount||0}}</td><td>¥{{row.reviewAmount||0|number:'1.2-2'}}</td><td class="money">¥{{row.payableAmount|number:'1.2-2'}}</td><td><button class="icon-action view-action" type="button" (click)="openCoach(row)" aria-label="查看老师上课明细" title="查看明细"><lucide-icon [img]="icons.Eye" [size]="17" /></button></td></tr>}@empty{<tr><td colspan="13"><div class="empty">当前筛选范围没有有效计薪记录</div></td></tr>}</tbody></table></div>@if(data.type4Count){<p class="type4-note">课型4“陪练”按原系统口径只计入课次,不计算工资和工时。</p>}</section>
   <section class="content-card payroll-table-card"><header><div><strong>{{data.month}} 老师收益汇总</strong><span>点击老师查看课酬与抗遗忘工资明细</span></div>@if(isManager()){<button class="ui-button primary create-payroll-action" type="button" (click)="create()" [disabled]="saving()||!data.sourceStatus.sync?.settlementReady"><lucide-icon [img]="icons.Plus" [size]="17" />生成全公司工资草稿</button>}</header><div class="table-scroll"><table class="data-table payroll-summary-table"><thead><tr><th>陪练老师</th><th>门店</th><th>30分钟</th><th>60分钟</th><th>体验课</th><th>课型4</th><th>总课次</th><th>工时</th><th>基础工资</th><th>复习次数</th><th>抗遗忘工资</th><th>当月收入</th><th>明细</th></tr></thead><tbody>@for(row of data.coachItems;track row.coachId){<tr><td><strong>{{coachLabel(row.coachName,row.coachId)}}</strong><small>ID {{row.coachId}}</small></td><td>{{row.storeName}}</td><td>{{row.type30Count}}</td><td>{{row.type60Count}}</td><td>{{row.trialCount}}</td><td>{{row.type4Count}}</td><td>{{row.lessonCount}}</td><td>{{row.totalHours|number:'1.1-1'}}h</td><td>¥{{row.baseAmount|number:'1.2-2'}}</td><td>{{row.reviewCount||0}}</td><td>¥{{row.reviewAmount||0|number:'1.2-2'}}</td><td class="money">¥{{row.payableAmount|number:'1.2-2'}}</td><td><button class="icon-action view-action" type="button" (click)="openCoach(row)" aria-label="查看老师上课明细" title="查看明细"><lucide-icon [img]="icons.Eye" [size]="17" /></button></td></tr>}@empty{<tr><td colspan="13"><div class="empty">当前筛选范围没有有效计薪记录</div></td></tr>}</tbody></table></div>@if(data.type4Count){<p class="type4-note">课型4“陪练”按原系统口径只计入课次,不计算工资和工时。</p>}</section>
  }
  }
 } @else if(tab()==='rewards'){
 } @else if(tab()==='rewards'){
@@ -17,7 +17,7 @@
  @if(page();as batches){<div class="filter-actions"><span>共 {{batches.total}} 个批次 · 第 {{batches.page}} 页</span><button class="ui-button secondary" (click)="loadBatchPage(batches.page-1)" [disabled]="batches.page<=1">上一页</button><button class="ui-button secondary" (click)="loadBatchPage(batches.page+1)" [disabled]="batches.page*batches.pageSize>=batches.total">下一页</button></div>}
  @if(page();as batches){<div class="filter-actions"><span>共 {{batches.total}} 个批次 · 第 {{batches.page}} 页</span><button class="ui-button secondary" (click)="loadBatchPage(batches.page-1)" [disabled]="batches.page<=1">上一页</button><button class="ui-button secondary" (click)="loadBatchPage(batches.page+1)" [disabled]="batches.page*batches.pageSize>=batches.total">下一页</button></div>}
 } @else {
 } @else {
  @if(standards();as config){
  @if(standards();as config){
-  <section class="standards-summary"><article><span>课程数</span><strong>{{config.summary.courseCount}}</strong></article><article><span>已确认映射</span><strong>{{config.summary.confirmedCount}}</strong></article><article [class.warn]="config.summary.unmappedCount"><span>待确认课程</span><strong>{{config.summary.unmappedCount}}</strong></article><article><span>生效规则</span><strong>{{config.summary.activeRuleCount}}</strong></article></section>
+  <section class="standards-summary overview-metrics"><article><span>课程数</span><strong>{{config.summary.courseCount}}</strong></article><article><span>已确认映射</span><strong>{{config.summary.confirmedCount}}</strong></article><article [class.warn]="config.summary.unmappedCount"><span>待确认课程</span><strong>{{config.summary.unmappedCount}}</strong></article><article><span>生效规则</span><strong>{{config.summary.activeRuleCount}}</strong></article></section>
   <nav class="standards-nav" aria-label="排课与工资标准分组"><button type="button" [class.active]="standardsSection()==='rules'" (click)="standardsSection.set('rules')">工资与课时规则</button><button type="button" [class.active]="standardsSection()==='mappings'" (click)="standardsSection.set('mappings')">课程映射 <span>{{config.summary.unmappedCount}}</span></button><button type="button" [class.active]="standardsSection()==='categories'" (click)="standardsSection.set('categories')">课程分类</button><button type="button" [class.active]="standardsSection()==='reservations'" (click)="standardsSection.set('reservations');previewReservations()">历史预约预留</button><button type="button" [class.active]="standardsSection()==='reconciliation'" (click)="standardsSection.set('reconciliation');previewReconciliation()">复习课课时核对</button><button type="button" [class.active]="standardsSection()==='students'" (click)="loadWritingMappings()">按学员核对语法写作</button></nav>
   <nav class="standards-nav" aria-label="排课与工资标准分组"><button type="button" [class.active]="standardsSection()==='rules'" (click)="standardsSection.set('rules')">工资与课时规则</button><button type="button" [class.active]="standardsSection()==='mappings'" (click)="standardsSection.set('mappings')">课程映射 <span>{{config.summary.unmappedCount}}</span></button><button type="button" [class.active]="standardsSection()==='categories'" (click)="standardsSection.set('categories')">课程分类</button><button type="button" [class.active]="standardsSection()==='reservations'" (click)="standardsSection.set('reservations');previewReservations()">历史预约预留</button><button type="button" [class.active]="standardsSection()==='reconciliation'" (click)="standardsSection.set('reconciliation');previewReconciliation()">复习课课时核对</button><button type="button" [class.active]="standardsSection()==='students'" (click)="loadWritingMappings()">按学员核对语法写作</button></nav>
  @if(standardsSection()==='students'){<section class="content-card"><header><div><strong>按学员与预约确认学段</strong><span>包含待开始预约及所选工资月份的已完课记录。按学员确认分类并填写依据后,工资统计会刷新。</span></div></header><div class="table-scroll"><table class="data-table"><thead><tr><th>学员 / 预约</th><th>课程 / 时间</th><th>档案年级</th><th>对应分类</th><th>核对依据 <span class="required-mark">*</span></th><th>操作</th></tr></thead><tbody>@for(row of writingRows();track row.appointmentId){<tr><td>{{row.studentName}}<small>{{row.appointmentGeneralId}}</small></td><td>{{row.courseName}}<small>{{row.startsAt}}</small></td><td>{{row.grade||'待确认'}}<small>{{row.assessmentReason||(row.suggestedCategoryKey?'档案可识别学段':'请补充核实依据')}}</small></td><td><select [(ngModel)]="row.categoryKey"><option value="">待确认</option><option value="primary_writing">小学语法写作课</option><option value="middle_writing">中学语法写作课</option><option value="high_writing">高中语法写作课</option></select></td><td><div class="evidence-field"><input [(ngModel)]="row.evidence" placeholder="如:学生档案为小学五年级" /><small>必填,至少 2 个字</small></div></td><td>@if(isManager()){<button class="ui-button secondary" type="button" (click)="saveWritingMapping(row)" [disabled]="saving()" [attr.title]="!row.categoryKey?'请先选择对应分类':row.evidence.trim().length<2?'请填写核对依据(至少 2 个字)':'确认此预约分类'">{{saving()?'确认中…':'确认'}}</button>}</td></tr>}@empty{<tr><td colspan="6">暂无待核对预约</td></tr>}</tbody></table></div></section>}
  @if(standardsSection()==='students'){<section class="content-card"><header><div><strong>按学员与预约确认学段</strong><span>包含待开始预约及所选工资月份的已完课记录。按学员确认分类并填写依据后,工资统计会刷新。</span></div></header><div class="table-scroll"><table class="data-table"><thead><tr><th>学员 / 预约</th><th>课程 / 时间</th><th>档案年级</th><th>对应分类</th><th>核对依据 <span class="required-mark">*</span></th><th>操作</th></tr></thead><tbody>@for(row of writingRows();track row.appointmentId){<tr><td>{{row.studentName}}<small>{{row.appointmentGeneralId}}</small></td><td>{{row.courseName}}<small>{{row.startsAt}}</small></td><td>{{row.grade||'待确认'}}<small>{{row.assessmentReason||(row.suggestedCategoryKey?'档案可识别学段':'请补充核实依据')}}</small></td><td><select [(ngModel)]="row.categoryKey"><option value="">待确认</option><option value="primary_writing">小学语法写作课</option><option value="middle_writing">中学语法写作课</option><option value="high_writing">高中语法写作课</option></select></td><td><div class="evidence-field"><input [(ngModel)]="row.evidence" placeholder="如:学生档案为小学五年级" /><small>必填,至少 2 个字</small></div></td><td>@if(isManager()){<button class="ui-button secondary" type="button" (click)="saveWritingMapping(row)" [disabled]="saving()" [attr.title]="!row.categoryKey?'请先选择对应分类':row.evidence.trim().length<2?'请填写核对依据(至少 2 个字)':'确认此预约分类'">{{saving()?'确认中…':'确认'}}</button>}</td></tr>}@empty{<tr><td colspan="6">暂无待核对预约</td></tr>}</tbody></table></div></section>}
  @if(standardsSection()==='rules'){
  @if(standardsSection()==='rules'){
@@ -31,11 +31,11 @@
    <section class="content-card"><header><div><strong>标准课程分类</strong><span>分类名称与价目表完全一致,不再显示或使用旧分类</span></div></header><div class="stage-overview" aria-label="数据库课程大类">@for(stage of stageOptions;track stage.key){<span>{{stage.name}}·数据库大类</span>}</div><div class="category-grid">@for(category of activeCategories();track category.key){<article><span>{{category.key}}</span><strong>{{category.name}}</strong><small>固定分类</small></article>}</div></section>
    <section class="content-card"><header><div><strong>标准课程分类</strong><span>分类名称与价目表完全一致,不再显示或使用旧分类</span></div></header><div class="stage-overview" aria-label="数据库课程大类">@for(stage of stageOptions;track stage.key){<span>{{stage.name}}·数据库大类</span>}</div><div class="category-grid">@for(category of activeCategories();track category.key){<article><span>{{category.key}}</span><strong>{{category.name}}</strong><small>固定分类</small></article>}</div></section>
   } @else if(standardsSection()==='reservations') {
   } @else if(standardsSection()==='reservations') {
    <section class="content-card reconciliation-card"><header><div><strong>历史未来预约课时预留</strong><span>按预约时间顺序核对现有余额;确认后仅补建预留,不扣减实际课时</span></div><div class="header-actions"><button class="ui-button secondary" type="button" (click)="previewReservations()" [disabled]="saving()"><lucide-icon [img]="icons.RefreshCw" [size]="16" />重新核对</button>@if(isManager()&&reservationReconciliation()?.summary?.total){<button class="ui-button primary" type="button" (click)="commitReservations()" [disabled]="saving()"><lucide-icon [img]="icons.FileCheck2" [size]="16" />确认补建预留</button>}</div></header>
    <section class="content-card reconciliation-card"><header><div><strong>历史未来预约课时预留</strong><span>按预约时间顺序核对现有余额;确认后仅补建预留,不扣减实际课时</span></div><div class="header-actions"><button class="ui-button secondary" type="button" (click)="previewReservations()" [disabled]="saving()"><lucide-icon [img]="icons.RefreshCw" [size]="16" />重新核对</button>@if(isManager()&&reservationReconciliation()?.summary?.total){<button class="ui-button primary" type="button" (click)="commitReservations()" [disabled]="saving()"><lucide-icon [img]="icons.FileCheck2" [size]="16" />确认补建预留</button>}</div></header>
-   @if(reservationReconciliation();as result){<div class="reconciliation-summary"><article><span>待核对预约</span><strong>{{result.summary.total}}</strong></article><article><span>可补建预留</span><strong>{{result.summary.ready}}</strong></article><article><span>待补课时</span><strong>{{result.summary.insufficient}}</strong></article><article><span>映射/数据异常</span><strong>{{result.summary.exceptions}}</strong></article></div><div class="table-scroll"><table class="data-table"><thead><tr><th>预约ID</th><th>学员</th><th>课程</th><th>预约时间</th><th>标准</th><th>课时预留</th><th>处理结果</th></tr></thead><tbody>@for(row of result.items;track row.appointmentId){<tr><td>{{row.appointmentGeneralId}}</td><td>{{row.studentName}}<small>ID {{row.studentId}}</small></td><td>{{row.courseName}}</td><td>{{row.startsAt|date:'yyyy-MM-dd HH:mm':'+0800'}}</td><td>{{row.categoryName||'未映射'}}<small>{{row.durationMinutes}}分钟 · {{row.deliveryLabel}}</small></td><td>@for(cost of row.creditCosts;track cost.account){<span>{{cost.label}} {{cost.amount}}</span>}</td><td><span class="badge" [class.warn]="row.status!=='ready'">{{row.statusLabel}}</span><small>{{row.reason}}</small></td></tr>}@empty{<tr><td colspan="7"><div class="empty">所有未来预约均已建立课时预留</div></td></tr>}</tbody></table></div>}@else{<div class="empty">正在核对历史未来预约……</div>}
+   @if(reservationReconciliation();as result){<div class="reconciliation-summary overview-metrics"><article><span>待核对预约</span><strong>{{result.summary.total}}</strong></article><article><span>可补建预留</span><strong>{{result.summary.ready}}</strong></article><article><span>待补课时</span><strong>{{result.summary.insufficient}}</strong></article><article><span>映射/数据异常</span><strong>{{result.summary.exceptions}}</strong></article></div><div class="table-scroll"><table class="data-table"><thead><tr><th>预约ID</th><th>学员</th><th>课程</th><th>预约时间</th><th>标准</th><th>课时预留</th><th>处理结果</th></tr></thead><tbody>@for(row of result.items;track row.appointmentId){<tr><td>{{row.appointmentGeneralId}}</td><td>{{row.studentName}}<small>ID {{row.studentId}}</small></td><td>{{row.courseName}}</td><td>{{row.startsAt|date:'yyyy-MM-dd HH:mm':'+0800'}}</td><td>{{row.categoryName||'未映射'}}<small>{{row.durationMinutes}}分钟 · {{row.deliveryLabel}}</small></td><td>@for(cost of row.creditCosts;track cost.account){<span>{{cost.label}} {{cost.amount}}</span>}</td><td><span class="badge" [class.warn]="row.status!=='ready'">{{row.statusLabel}}</span><small>{{row.reason}}</small></td></tr>}@empty{<tr><td colspan="7"><div class="empty">所有未来预约均已建立课时预留</div></td></tr>}</tbody></table></div>}@else{<div class="empty">正在核对历史未来预约……</div>}
    </section>
    </section>
   } @else {
   } @else {
    <section class="content-card reconciliation-card"><header><div><strong>2026 年 9 月起复习课课时差异</strong><span>只预览新规则生效后的已完课记录;确认前不会修改余额</span></div><div class="header-actions"><button class="ui-button secondary" type="button" (click)="previewReconciliation()" [disabled]="saving()"><lucide-icon [img]="icons.RefreshCw" [size]="16" />重新核对</button>@if(isManager()&&reconciliation()?.summary?.differenceCount){<button class="ui-button primary" type="button" (click)="commitReconciliation()" [disabled]="saving()"><lucide-icon [img]="icons.FileCheck2" [size]="16" />确认退回差额</button>}</div></header>
    <section class="content-card reconciliation-card"><header><div><strong>2026 年 9 月起复习课课时差异</strong><span>只预览新规则生效后的已完课记录;确认前不会修改余额</span></div><div class="header-actions"><button class="ui-button secondary" type="button" (click)="previewReconciliation()" [disabled]="saving()"><lucide-icon [img]="icons.RefreshCw" [size]="16" />重新核对</button>@if(isManager()&&reconciliation()?.summary?.differenceCount){<button class="ui-button primary" type="button" (click)="commitReconciliation()" [disabled]="saving()"><lucide-icon [img]="icons.FileCheck2" [size]="16" />确认退回差额</button>}</div></header>
-   @if(reconciliation();as result){<div class="reconciliation-summary three-column"><article><span>已完成复习课</span><strong>{{result.summary.completedReviewCount}}</strong></article><article><span>待处理差异</span><strong>{{result.summary.differenceCount}}</strong></article><article><span>应退陪练时长</span><strong>{{result.summary.refundTotal|number:'1.1-2'}}</strong></article></div><div class="table-scroll"><table class="data-table"><thead><tr><th>预约ID</th><th>学员ID</th><th>上课时间</th><th>时长</th><th>规则应扣</th><th>实际已扣</th><th>应退</th><th>状态</th></tr></thead><tbody>@for(row of result.items;track row.appointmentId){<tr><td>{{row.appointmentGeneralId}}</td><td>{{row.studentId}}</td><td>{{row.lessonAt}}</td><td>{{row.durationMinutes}}分钟</td><td>{{row.expected}}</td><td>{{row.charged}}</td><td [class.money]="row.adjustment">{{row.adjustment}}</td><td><span class="badge" [class.warn]="row.adjustment">{{row.status}}</span></td></tr>}@empty{<tr><td colspan="8"><div class="empty">暂无已完成的复习课记录</div></td></tr>}</tbody></table></div>}@else{<div class="empty">正在核对复习课课时……</div>}
+   @if(reconciliation();as result){<div class="reconciliation-summary three-column overview-metrics"><article><span>已完成复习课</span><strong>{{result.summary.completedReviewCount}}</strong></article><article><span>待处理差异</span><strong>{{result.summary.differenceCount}}</strong></article><article><span>应退陪练时长</span><strong>{{result.summary.refundTotal|number:'1.1-2'}}</strong></article></div><div class="table-scroll"><table class="data-table"><thead><tr><th>预约ID</th><th>学员ID</th><th>上课时间</th><th>时长</th><th>规则应扣</th><th>实际已扣</th><th>应退</th><th>状态</th></tr></thead><tbody>@for(row of result.items;track row.appointmentId){<tr><td>{{row.appointmentGeneralId}}</td><td>{{row.studentId}}</td><td>{{row.lessonAt}}</td><td>{{row.durationMinutes}}分钟</td><td>{{row.expected}}</td><td>{{row.charged}}</td><td [class.money]="row.adjustment">{{row.adjustment}}</td><td><span class="badge" [class.warn]="row.adjustment">{{row.status}}</span></td></tr>}@empty{<tr><td colspan="8"><div class="empty">暂无已完成的复习课记录</div></td></tr>}</tbody></table></div>}@else{<div class="empty">正在核对复习课课时……</div>}
    </section>
    </section>
   }
   }
  }
  }

+ 1 - 1
projects/xiaoshu-admin/src/app/pages/operations-special-training.component.html

@@ -1,5 +1,5 @@
 <header class="page-header"><div><p>CONTENT OPERATIONS</p><h1>专项训练</h1><span>维护自然拼读、阅读理解、完形填空和语法专项的图文与视频内容</span></div><div class="header-actions"><button class="ui-button secondary" type="button" (click)="refresh()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="loading()"/>刷新</button><button class="ui-button primary" type="button" (click)="openCreate()"><lucide-icon [img]="icons.Plus" [size]="17"/>新增专项</button></div></header>
 <header class="page-header"><div><p>CONTENT OPERATIONS</p><h1>专项训练</h1><span>维护自然拼读、阅读理解、完形填空和语法专项的图文与视频内容</span></div><div class="header-actions"><button class="ui-button secondary" type="button" (click)="refresh()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="loading()"/>刷新</button><button class="ui-button primary" type="button" (click)="openCreate()"><lucide-icon [img]="icons.Plus" [size]="17"/>新增专项</button></div></header>
-<section class="summary-grid"><article class="summary-card"><span>专项内容</span><strong>{{page()?.total||0}}</strong><small>栏目 3 · 图文/视频内容</small></article><article class="summary-card"><span>已发布</span><strong>{{publishedCount()}}</strong><small>C 端当前可见</small></article><article class="summary-card"><span>草稿</span><strong>{{draftCount()}}</strong><small>仅运营后台可见</small></article><article class="summary-card"><span>内容待补</span><strong>{{mediaMissingCount()}}</strong><small>正文和视频均为空</small></article></section>
+<section class="summary-grid overview-metrics"><article class="summary-card"><span>专项内容</span><strong>{{page()?.total||0}}</strong><small>栏目 3 · 图文/视频内容</small></article><article class="summary-card"><span>已发布</span><strong>{{publishedCount()}}</strong><small>C 端当前可见</small></article><article class="summary-card"><span>草稿</span><strong>{{draftCount()}}</strong><small>仅运营后台可见</small></article><article class="summary-card"><span>内容待补</span><strong>{{mediaMissingCount()}}</strong><small>正文和视频均为空</small></article></section>
 @if(page()?.sourceStatus;as source){<p class="sync-status" [class.error]="!source.available" role="status"><span></span>{{source.message}} · 旧后台 {{source.legacyTotal}} 条 · 新增 {{source.created}} 条 · 更新 {{source.updated}} 条</p>}
 @if(page()?.sourceStatus;as source){<p class="sync-status" [class.error]="!source.available" role="status"><span></span>{{source.message}} · 旧后台 {{source.legacyTotal}} 条 · 新增 {{source.created}} 条 · 更新 {{source.updated}} 条</p>}
 <section class="toolbar"><label class="grow"><span>搜索内容</span><input [ngModel]="query()" (ngModelChange)="query.set($event)" (keyup.enter)="load()" placeholder="标题或简介"/></label><label><span>发布状态</span><select [ngModel]="status()" (ngModelChange)="status.set($event)"><option value="">全部</option><option value="99">已发布</option><option value="0">草稿</option><option value="-2">回收站</option></select></label><button class="ui-button primary" type="button" (click)="load()"><lucide-icon [img]="icons.Search" [size]="17"/>查询</button><div class="segmented"><button aria-label="卡片视图" type="button" [class.active]="view()==='cards'" (click)="view.set('cards')" title="卡片视图"><lucide-icon [img]="icons.LayoutGrid" [size]="17"/></button><button aria-label="列表视图" type="button" [class.active]="view()==='list'" (click)="view.set('list')" title="列表视图"><lucide-icon [img]="icons.List" [size]="17"/></button></div></section>
 <section class="toolbar"><label class="grow"><span>搜索内容</span><input [ngModel]="query()" (ngModelChange)="query.set($event)" (keyup.enter)="load()" placeholder="标题或简介"/></label><label><span>发布状态</span><select [ngModel]="status()" (ngModelChange)="status.set($event)"><option value="">全部</option><option value="99">已发布</option><option value="0">草稿</option><option value="-2">回收站</option></select></label><button class="ui-button primary" type="button" (click)="load()"><lucide-icon [img]="icons.Search" [size]="17"/>查询</button><div class="segmented"><button aria-label="卡片视图" type="button" [class.active]="view()==='cards'" (click)="view.set('cards')" title="卡片视图"><lucide-icon [img]="icons.LayoutGrid" [size]="17"/></button><button aria-label="列表视图" type="button" [class.active]="view()==='list'" (click)="view.set('list')" title="列表视图"><lucide-icon [img]="icons.List" [size]="17"/></button></div></section>
 @if(message()){<p class="notice" role="status">{{message()}}</p>}@if(error()&&!editorOpen()){<p class="notice error" role="alert">{{error()}}</p>}
 @if(message()){<p class="notice" role="status">{{message()}}</p>}@if(error()&&!editorOpen()){<p class="notice error" role="alert">{{error()}}</p>}

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

@@ -6,7 +6,7 @@
   </div>
   </div>
 </header>
 </header>
 
 
-<section class="summary-grid" aria-label="门店运营摘要">
+<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?.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?.members||0}}</strong><small>按门店关系归集</small></article>
   <article><span>归属陪练</span><strong>{{page()?.summary?.coaches||0}}</strong><small>可用于排课与工资统计</small></article>
   <article><span>归属陪练</span><strong>{{page()?.summary?.coaches||0}}</strong><small>可用于排课与工资统计</small></article>

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

@@ -5,7 +5,7 @@
 
 
 @if (message()) { <p class="notice" role="status">{{ message() }}</p> } @if (error()) { <p class="notice error" role="alert">{{ error() }}</p> }
 @if (message()) { <p class="notice" role="status">{{ message() }}</p> } @if (error()) { <p class="notice error" role="alert">{{ error() }}</p> }
 
 
-<section class="summary-grid" aria-label="会员运营摘要">
+<section class="summary-grid overview-metrics" aria-label="会员运营摘要">
   <article><span>会员总数</span><strong>{{ page()?.summary?.total || 0 }}</strong><small>已排除陪练与管理账号</small></article>
   <article><span>会员总数</span><strong>{{ page()?.summary?.total || 0 }}</strong><small>已排除陪练与管理账号</small></article>
   <article><span>今日新增</span><strong>{{ page()?.summary?.todayNew || 0 }}</strong><small>按真实注册时间统计</small></article>
   <article><span>今日新增</span><strong>{{ page()?.summary?.todayNew || 0 }}</strong><small>按真实注册时间统计</small></article>
   <article><span>近 7 日新增</span><strong>{{ page()?.summary?.sevenDayNew || 0 }}</strong><small>新会员承接情况</small></article>
   <article><span>近 7 日新增</span><strong>{{ page()?.summary?.sevenDayNew || 0 }}</strong><small>新会员承接情况</small></article>

+ 1 - 0
projects/xiaoshu-admin/src/styles.scss

@@ -1,5 +1,6 @@
 @use '../../shared/ui/dropdown';
 @use '../../shared/ui/dropdown';
 @use './app/pages/operations-schedule-layout';
 @use './app/pages/operations-schedule-layout';
+@use './app/pages/operations-overview-metrics';
 :root {
 :root {
   --brand: #2f9b82;
   --brand: #2f9b82;
   --brand-strong: #1f7b68;
   --brand-strong: #1f7b68;