Procházet zdrojové kódy

优化老师费用页面并兼容旧接口按月汇总完整明细

彭峰 před 4 dny
rodič
revize
06aad88f37

+ 25 - 0
projects/xiaoshu-mobile/src/app/core/fee-summary.spec.ts

@@ -0,0 +1,25 @@
+import { firstValueFrom, of, throwError } from 'rxjs';
+import { allFeeRows, lessonFeeSummary, reviewFeeTotal } from './fee-summary';
+
+describe('费用汇总兼容旧网关', () => {
+  it('读取筛选范围全部分页,翻页时不重复计算当前页', async () => {
+    const calls: number[] = [];
+    const data = Array.from({length:45}, (_, i) => ({GeneralID:i+1, money:0.5}));
+    const page = (index:number) => ({items:data.slice((index-1)*20,index*20), total:45, pages:3, index});
+    const rows = await firstValueFrom(allFeeRows(page(2), index => {calls.push(index); return of(page(index));}));
+    expect(calls).toEqual([1,3]); expect(rows.length).toBe(45); expect(reviewFeeTotal(rows)).toBe(22.5);
+  });
+  it('零元费用有效,取消费用不计入,待核对单列', () => {
+    expect(lessonFeeSummary([{teacherPay:30,payStatus:'earned'}, {teacherPay:0,payStatus:'earned'}, {teacherPay:90,payStatus:'earned',cancelled:true}, {teacherPay:null,payStatus:'pending'}])).toEqual({amount:30,pendingCount:1});
+    expect(reviewFeeTotal([{money:0},{money:0.1},{money:0.2}])).toBe(0.3);
+  });
+  it('空月份显示真实零元', async () => {
+    const rows = await firstValueFrom(allFeeRows({items:[],total:0,pages:1,index:1}, () => {throw Error('不应发起下一页');}));
+    expect(reviewFeeTotal(rows)).toBe(0);
+  });
+  it('缺页、跨页重复或明细缺金额时不显示不完整总额', async () => {
+    await expectAsync(firstValueFrom(allFeeRows({items:[{GeneralID:1}],total:2,pages:2,index:1}, () => of({items:[{GeneralID:1}],total:2,pages:2,index:2})))).toBeRejected();
+    await expectAsync(firstValueFrom(allFeeRows({items:[{GeneralID:1}],total:2,pages:2,index:1}, () => throwError(() => Error('读取失败'))))).toBeRejected();
+    expect(() => reviewFeeTotal([{}])).toThrow();
+  });
+});

+ 35 - 0
projects/xiaoshu-mobile/src/app/core/fee-summary.ts

@@ -0,0 +1,35 @@
+import { from, map, mergeMap, Observable, of, toArray } from 'rxjs';
+
+type Item = Record<string, unknown>;
+interface FeePage { items: Item[]; total: number; pages: number; index: number; }
+
+// Older gateways omit a scoped total. Read every matching page before showing a sum.
+export function allFeeRows(first: FeePage, fetchPage: (page: number) => Observable<FeePage>): Observable<Item[]> {
+  const indexes = Array.from({length: first.pages}, (_, i) => i + 1).filter(i => i !== first.index);
+  const pages = indexes.length ? from(indexes).pipe(mergeMap(fetchPage, 3), toArray()) : of([] as FeePage[]);
+  return pages.pipe(map(rest => {
+    const rows = [first, ...rest].flatMap(page => page.items);
+    if (rows.length !== first.total || rest.some(page => page.total !== first.total)) throw new Error('记录已更新,请刷新后核对合计');
+    const ids = rows.map(row => row['GeneralID'] ?? row['objectId'] ?? row['id']);
+    if (ids.every(id => id != null) && new Set(ids.map(String)).size !== rows.length) throw new Error('记录已更新,请刷新后核对合计');
+    return rows;
+  }));
+}
+
+export function lessonFeeSummary(rows: Item[]): {amount: number; pendingCount: number} {
+  let cents = 0, pendingCount = 0;
+  for (const row of rows) {
+    if (row['cancelled']) continue;
+    if (row['payStatus'] === 'earned' && row['teacherPay'] != null && Number.isFinite(Number(row['teacherPay']))) cents += Math.round(Number(row['teacherPay']) * 100);
+    else pendingCount++;
+  }
+  return {amount: cents / 100, pendingCount};
+}
+
+export function reviewFeeTotal(rows: Item[]): number {
+  return rows.reduce((cents, row) => {
+    const amount = row['money'];
+    if (amount == null || !Number.isFinite(Number(amount))) throw new Error('部分记录缺少费用,请刷新后核对合计');
+    return cents + Math.round(Number(amount) * 100);
+  }, 0) / 100;
+}

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

@@ -13,6 +13,7 @@ import { HomePageComponent } from '../features/home/home-page.component';
 import { routes } from '../app.routes';
 import { ApiService } from './api.service';
 import { ApiEnvelope, AppUser } from './models';
+import { LiveDataRefreshService } from './live-data-refresh.service';
 import { PlatformService } from './platform.service';
 import { SessionService } from './session.service';
 
@@ -327,6 +328,43 @@ describe('home learning modules', () => {
     });
   });
 
+  it('reconciles monthly review fees from every matching page when an old gateway always returns 29', async () => {
+    user.set({userId:300, groupId:3, honeyName:'钱老师'}); session.groupId.set(3);
+    const api = TestBed.inject(ApiService), original = api.post.bind(api), requests: Record<string,unknown>[] = [];
+    spyOn(api, 'post').and.callFake(((action:string, params:Record<string,unknown> = {}) => {
+      if (action !== 'app_review_page') return original(action,params);
+      requests.push(params);
+      const rows = Array.from({length:params['month']==='2026-09'?6:28}, (_,i) => ({GeneralID:i+1,money:params['month']==='2026-09'?1:i<21?1:0,fxzt:1,kywrq:params['month']+'-15'}));
+      const size=Number(params['pageSize']), index=Number(params['page']);
+      return of({result:{items:rows.slice((index-1)*size,index*size),page:{itemCount:rows.length,pageCount:Math.ceil(rows.length/size)},summary:{income:29},dates:[{date:'2026-09-15',total:6,completed:6},{date:'2026-10-15',total:28,completed:28}]}});
+    }) as typeof api.post);
+    const harness=await RouterTestingHarness.create('/pages/member/ke_fuxi');
+    const component=harness.fixture.debugElement.query(By.directive(StudyRecordsPageComponent)).componentInstance as StudyRecordsPageComponent;
+    component.setIncomeMonth('2026-10'); await harness.fixture.whenStable(); harness.fixture.detectChanges();
+    expect(component.totalIncome()).toBe(21); expect(component.items().length).toBe(20);
+    expect(requests.some(p=>p['month']==='2026-10'&&p['pageSize']===100&&p['includeMetadata']===false)).toBeTrue();
+    expect(harness.routeNativeElement!.querySelector('.fee-summary-amount')!.textContent).toContain('21.00');
+    component.setIncomeMonth('2026-09'); await harness.fixture.whenStable(); harness.fixture.detectChanges();
+    expect(component.totalIncome()).toBe(6); expect(component.serverTotal()).toBe(6);
+    expect(harness.routeNativeElement!.querySelector('.fee-summary-top')!.textContent).toContain('2026年09月');
+  });
+
+  it('shows lesson fee total with older gateways that omit earningsSummary', async () => {
+    spyOn(TestBed.inject(LiveDataRefreshService), 'watch').and.returnValue(() => {});
+    user.set({userId:300, groupId:3, honeyName:'钱老师'}); session.groupId.set(3);
+    const api=TestBed.inject(ApiService), original=api.post.bind(api);
+    spyOn(api,'post').and.callFake(((action:string,params:Record<string,unknown>={}) => {
+      if(action!=='app_companion_overview')return original(action,params);
+      return of({result:{courses:[],schedules:Array.from({length:6},(_,i)=>({GeneralID:i+1,bxrq:'2026-09-15',dszt:30,teacherPay:30,payStatus:'earned'})),page:{itemCount:6,pageCount:1},dates:[{date:'2026-09-15',finished:6,planned:0}],studentOptions:[],refreshedAt:'2026-10-28'}});
+    }) as typeof api.post);
+    const harness=await RouterTestingHarness.create();
+    await harness.navigateByUrl('/pages/member/ke_xiao');
+    const component=harness.fixture.debugElement.query(By.directive(CourseCenterPageComponent)).componentInstance as CourseCenterPageComponent;
+    component.setHistoryMonth('2026-09'); await harness.fixture.whenStable(); harness.fixture.detectChanges();
+    expect(component.earningsSummary()?.amount).toBe(180);
+    expect(harness.routeNativeElement!.querySelector('.fee-summary-amount')!.textContent).toContain('180.00');
+  });
+
   it('renders scheduled duration for new lessons without duplicating legacy duration labels', async () => {
     user.set({ userId: 300, userName: 'coach', honeyName: '钱老师', groupId: 3 });
     session.groupId.set(3);
@@ -1497,8 +1535,8 @@ describe('home learning modules', () => {
     harness.fixture.detectChanges();
     expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(1);
     expect(harness.routeNativeElement?.querySelector('.schedule-list')?.textContent).toContain('花花');
-    expect(harness.routeNativeElement?.querySelector('.schedule-band > header')?.textContent).toContain('1 / 5');
-    (harness.routeNativeElement?.querySelector('.history-filter-panel footer button') as HTMLButtonElement).click();
+    expect(harness.routeNativeElement?.querySelector('.schedule-band > header')?.textContent).toContain('5 节');
+    (harness.routeNativeElement?.querySelector('.history-filter-panel footer button:last-child') as HTMLButtonElement).click();
     harness.fixture.detectChanges();
     expect(harness.routeNativeElement?.querySelectorAll('.schedule-list article').length).toBe(5);
 

+ 1 - 1
projects/xiaoshu-mobile/src/app/core/review-page.ts

@@ -1,5 +1,5 @@
 import { buildAntiForgetCalendar, buildAntiForgetView } from './anti-forget';
-export interface ReviewPage { serverToday?:string;items:Record<string,unknown>[];page:{itemCount:number;pageCount:number};summary:{total:number;completed:number;due:number;today:number;income:number;todayTotal?:number;todayCompleted?:number;todayPending?:number;overduePending?:number}; dates:Array<{date:string;total:number;completed:number}>;students?:Array<{id:string;name:string;taskCount:number}> }
+export interface ReviewPage { serverToday?:string;items:Record<string,unknown>[];page:{itemCount:number;pageCount:number};summary:{total:number;completed:number;due:number;today:number;income:number;incomeScope?:string;todayTotal?:number;todayCompleted?:number;todayPending?:number;overduePending?:number}; dates:Array<{date:string;total:number;completed:number}>;students?:Array<{id:string;name:string;taskCount:number}> }
 export function reviewCalendar(dates:ReviewPage['dates'],month:Date,selected:string){
   const view=buildAntiForgetCalendar([],month,selected);const today=new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai'}).format(new Date());
   view.days=view.days.map(day=>{const row=dates.find(d=>d.date===day.date);const total=Number(row?.total)||0,completed=Number(row?.completed)||0,pending=total-completed;return{...day,total,completed,pending,state:!total?'none' as const:!pending?'completed' as const:day.date<=today?'due' as const:'scheduled' as const};});

+ 15 - 5
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.html

@@ -15,10 +15,10 @@
     }
 
     <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>
-      <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>
+      <header><lucide-icon [img]="isCoach ? icons.GraduationCap : icons.CalendarDays" [size]="21" /><h2>{{ isHistory ? '陪练记录' : isCoach ? '陪练排课' : '伴学列表' }}</h2><span>{{ (loading()||error())&&!schedules().length ? '—' : isHistory ? listTotal() : hasScheduleCalendar ? visibleSchedules().length + ' / ' + schedules().length : schedules().length }} 节</span></header>
+      @if (!isHistory) { <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) {
-        <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>
@@ -27,7 +27,7 @@
                 <lucide-icon [img]="icons.CalendarDays" [size]="18" />
                 <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> }
+                  @for (month of historyMonthOptions(); track month.value) { <option [value]="month.value">{{ month.label }}</option> }
                 </select>
               </div>
             </label>
@@ -40,10 +40,20 @@
             </label>
           </div>
           <footer>
-            <span>当前显示 <strong>{{ visibleSchedules().length }}</strong> 节,共 {{ listTotal() }} 节</span>
+            <span>共 <strong>{{ loading() ? '—' : listTotal() }}</strong> 条记录 · 当前 {{ visibleSchedules().length }} 条</span>
+            <button type="button" (click)="load(true)" [disabled]="loading()">刷新记录</button>
             @if (historyFilterActive()) { <button type="button" (click)="clearHistoryFilters()"><lucide-icon [img]="icons.X" [size]="15" />清除筛选</button> }
           </footer>
         </section>
+        <section class="fee-summary-wrap" aria-label="陪练费用汇总" aria-live="polite" [attr.aria-busy]="loading()">
+          <div class="fee-summary-card">
+            <div class="fee-summary-top"><span>{{historyMonth()==='all'?'全部月份':historyMonth().replace('-', '年')+'月'}}</span><span class="fee-summary-tag">{{historyStudentQuery().trim()?'已筛选学员':'全部学员'}}</span></div>
+            <span class="fee-summary-label">陪练费用合计</span>
+            <strong class="fee-summary-amount">{{loading() || error() || !earningsSummary() ? '—' : '¥' + (earningsSummary()!.amount | number:'1.2-2')}}</strong>
+            <div class="fee-summary-bottom"><span>{{loading()?'正在核对费用…':'共 '+listTotal()+' 条陪练记录'}}</span>@if(!loading() && !error() && earningsSummary()?.pendingCount){<span>{{earningsSummary()!.pendingCount}} 条待核对</span>}</div>
+          </div>
+          <p class="fee-summary-note" [class.fee-summary-error]="feeSummaryError()">{{feeSummaryError() || '合计包含所选范围的全部分页;取消记录和待核对费用不计入。'}}</p>
+        </section>
       }
       @if (hasScheduleCalendar) {
         <div class="schedule-tools">

+ 8 - 0
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.scss

@@ -1,3 +1,4 @@
+@use '../../shared/fee-summary';
 :host { display: block; min-height: 100%; background: var(--surface-muted); }
 .center-page { min-height: 100%; padding-bottom: 18px; }
 .course-band, .schedule-band { margin: 12px 14px 0; overflow: hidden; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
@@ -119,3 +120,10 @@ header span { color: var(--ink-soft); font-size: 12px; }
 .lesson-pay small { color:var(--ink-soft); font-size:12px; }
 
 .lesson-pay small.feedback-pending { color:#b45309; font-weight:600; }
+
+.history-filter-grid label > span { font-size: 12px; line-height: 1.5; }
+.history-filter-panel footer { align-items: center; flex-wrap: wrap; }
+.history-filter-panel footer button { min-height: 44px; font-size: 12px; }
+.history-filter-control select, .history-filter-control input { font-size: 16px; }
+
+@media (max-width: 480px) { .history-filter-grid { grid-template-columns: minmax(0, 1fr); } }

+ 16 - 3
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.ts

@@ -5,7 +5,8 @@ import { ChangeDetectionStrategy, Component, computed, inject, NgZone, OnDestroy
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { AlertCircle, BarChart3, BookOpen, CalendarDays, ChevronDown, ChevronRight, GraduationCap, LoaderCircle, LucideAngularModule, Search, X } from 'lucide-angular';
-import { finalize, map, Subscription } from 'rxjs';
+import { catchError, finalize, map, of, Subscription, switchMap } from 'rxjs';
+import { allFeeRows, lessonFeeSummary } from '../../core/fee-summary';
 import { HomeDailyCacheService } from '../../core/home-daily-cache.service';
 import { ApiError, ApiService } from '../../core/api.service';
 import { canStartTeacherLedLearning } from '../../core/learning-access';
@@ -80,6 +81,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 feeSummaryError = signal('');
   readonly earningsSummary = signal<{amount:number;pendingCount:number}|null>(null);
   readonly historyMonth = signal('all');
   readonly historyStudentQuery = signal('');
@@ -166,6 +168,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
     if (!background) {
       this.loading.set(true);
       this.error.set('');
+      this.feeSummaryError.set('');
     }
     const today = this.today();
     const range = this.isHistory || this.scheduleRange() === 'all' ? null : this.calendarWindow();
@@ -180,13 +183,23 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
       rangeEnd: range?.end,
       source: 'database',
     };
-    const fetch = () => this.api.post<CompanionOverview>('app_companion_overview', { ...params, refresh }).pipe(map(({ result }) => result));
+    const fetchPage = (page: number, pageSize = 20) => this.api.post<CompanionOverview>('app_companion_overview', { ...params, page, pageSize, refresh }).pipe(map(({ result }) => result));
+    const fetch = () => fetchPage(this.listPage()).pipe(switchMap(result => {
+      if (!this.isHistory || result.earningsSummary) return of(result);
+      const toPage = (data: CompanionOverview, index: number) => ({items: data.schedules, total: data.page?.itemCount ?? data.schedules.length, pages: data.page?.pageCount || 1, index});
+      const total = result.page?.itemCount ?? result.schedules.length;
+      const first = total > result.schedules.length ? {items: [], total, pages: Math.ceil(total / 100), index: 0} : toPage(result, params.page);
+      return allFeeRows(first, page => fetchPage(page, 100).pipe(map(data => toPage(data, page)))).pipe(
+        map(rows => ({...result, earningsSummary: lessonFeeSummary(rows)})),
+        catchError(() => { this.feeSummaryError.set('费用合计暂不可用,请刷新重试'); return of(result); }),
+      );
+    }));
     const request = this.isHistory ? fetch() : this.dailyCache.load(`companion:${JSON.stringify(params)}`, fetch, refresh).pipe(map(({ value }) => value));
     this.loadRequest = request.pipe(finalize(() => {
       if (!background && requestId === this.loadId) this.loading.set(false);
     })).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||[]);
+        if(requestId!==this.loadId)return;this.listPages.set(result.page?.pageCount||1);this.listTotal.set(result.page?.itemCount ?? result.schedules?.length ?? 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 : []);

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

@@ -37,7 +37,22 @@
     }
   }
   @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"><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/ke_fuxi') {
+    <section class="fee-toolbar" aria-label="抗遗忘费用筛选">
+      <label for="income-month">查看月份</label>
+      <div class="fee-toolbar-controls"><div class="fee-month-select"><lucide-icon [img]="icons.CalendarDays" [size]="18" /><select id="income-month" aria-label="按月份筛选抗遗忘费用" [ngModel]="incomeMonth" (ngModelChange)="setIncomeMonth($event)"><option value="">全部月份</option>@for(month of incomeMonthOptions();track month){<option [value]="month">{{month.replace('-', '年')}}月</option>}</select><lucide-icon [img]="icons.ChevronRight" [size]="16" class="fee-select-chevron" /></div><button type="button" (click)="load()" [disabled]="loading()">刷新记录</button></div>
+    </section>
+    <section class="fee-summary-wrap" aria-label="抗遗忘费用汇总" aria-live="polite" [attr.aria-busy]="loading()">
+      <div class="fee-summary-card">
+        <div class="fee-summary-top"><span>{{incomeMonthLabel()}}</span><span class="fee-summary-tag">按计划复习月份</span></div>
+        <span class="fee-summary-label">抗遗忘费用合计</span>
+        <strong class="fee-summary-amount">{{loading() || error() || totalIncome() == null ? '—' : '¥' + (totalIncome() | number:'1.2-2')}}</strong>
+        <div class="fee-summary-bottom"><span>{{loading()?'正在核对费用…':'共 '+serverTotal()+' 条已复习记录'}}</span><span>含全部分页</span></div>
+      </div>
+      <p class="fee-summary-note" [class.fee-summary-error]="incomeSummaryError()">{{incomeSummaryError() || '仅计划当天首次完成计费;自习、逾期补做不计费。'}}</p>
+    </section>
+    <header class="fee-detail-heading"><h2>费用明细</h2><span>{{incomeMonthLabel()}}</span></header>
+  }
   @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) {
@@ -91,7 +106,7 @@
         } @else if (meta.path === 'pages/member/myke') {
           <article class="progress-item" (click)="open(item)"><lucide-icon [img]="icons.RotateCcw" [size]="21" /><div><strong>{{ title(item) }}</strong><p>点击查看分册最新学习进度</p></div><span>复习</span></article>
         } @else if (meta.path === 'pages/member/ke_fuxi') {
-          <article class="income-item"><span [class.done]="item['fxzt']">{{ item['fxzt'] ? '已复习' : '未复习' }}</span><div><strong>{{ title(item) }}</strong><p>{{ incomeName(item) }} 计划 {{ item['kywrq'] || item['kywsj'] }} · 完成 {{ item['completedAt'] ? ($any(item['completedAt']) | date:'yyyy-MM-dd HH:mm':'+0800') : '待核对' }}</p>@if(item['correctionReason']){<small>原金额 ¥{{item['originalAmount']}} · 调整 ¥{{item['correctionAmount']}} · {{item['correctionReason']}}</small>}@else if(item['rewardStatus']==='ineligible'){<small>{{ item['ineligibleReason']==='self_study' ? '学生自己复习不记薪' : item['ineligibleReason']==='duplicate_review' ? '重复复习,不重复计薪' : item['ineligibleReason']==='overdue' ? '逾期补做,不计薪' : '本次复习不计薪' }}</small>}@else if(item['rewardStatus']==='historical'){<small>历史待核对,未自动计薪</small>}@else if(item['rewardStatus']==='exception'){<small>计薪异常待核对</small>}</div><b>¥{{ item['money'] || 0 }}</b></article>
+          <article class="income-item"><span [class.done]="item['fxzt']">{{ item['fxzt'] ? '已复习' : '未复习' }}</span><div><strong>{{ title(item) }}</strong><p>{{ incomeName(item) }} 计划 {{ item['kywrq'] || item['kywsj'] }} · 完成 {{ item['completedAt'] ? ($any(item['completedAt']) | date:'yyyy-MM-dd HH:mm':'+0800') : '待核对' }}</p>@if(item['correctionReason']){<small>费用已核对@if(item['originalAmount'] != null){ · 原金额 ¥{{ $any(item['originalAmount']) | number:'1.2-2' }}}@if(item['correctionAmount'] != null){ · 调整 ¥{{ $any(item['correctionAmount']) | number:'1.2-2' }}}</small><details class="fee-correction"><summary>查看核对说明</summary><p>{{item['correctionReason']}}</p></details>}@else if(item['rewardStatus']==='ineligible'){<small>{{ item['ineligibleReason']==='self_study' ? '学生自主复习,不计费' : item['ineligibleReason']==='duplicate_review' ? '重复复习,不重复计费' : item['ineligibleReason']==='overdue' ? '逾期补做,不计费' : '本次复习不计费' }}</small>}@else if(item['rewardStatus']==='historical'){<small>历史待核对,未自动计费</small>}@else if(item['rewardStatus']==='exception'){<small>费用异常待核对</small>}</div><b [class.zero-fee]="!item['money']">¥{{ ($any(item['money']) || 0) | number:'1.2-2' }}</b></article>
         } @else {
           <article class="learning-item" [class.readonly]="!canStartPointReading" (click)="open(item)"><lucide-icon [img]="item['xxqs'] ? icons.BookOpen : icons.CalendarDays" [size]="21" /><div><time>{{ time(item) }}</time><strong>{{ title(item) }}</strong></div>@if (item['xxqs'] || completedReadingId(item)) { <button type="button" class="reading-button" [class.completed]="completedReadingId(item)" [disabled]="readingLoadingId() === recordId(item)" [attr.aria-label]="(completedReadingId(item) ? '查看已完成的历史阅读理解' : '生成阅读理解') + (readingArticleCount(item) ? ',已生成' + readingArticleCount(item) + '篇' : '')" (click)="openReading(item, $event)">{{ readingLoadingId() === recordId(item) ? '加载中' : completedReadingId(item) ? '已完成阅读' : '阅读' }}@if (readingArticleCount(item)) { <span class="reading-count">{{ readingArticleCount(item) }}</span> }</button> }<lucide-icon class="chevron" [img]="icons.ChevronRight" [size]="18" /></article>
         }

+ 11 - 0
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.scss

@@ -1,3 +1,4 @@
+@use '../../shared/fee-summary';
 :host { display: block; min-height: 100%; background: var(--surface-muted); }
 .records-page { min-height: 100%; }
 .filter-row { display: grid; position: relative; z-index: 50; grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 12px 16px; background: white; border-bottom: 1px solid var(--line); gap: 10px; }
@@ -124,3 +125,13 @@
 @media (max-width: 420px) { .date-picker-popover { top: 154px; } .date-picker-day { height: 42px; } }
 @media (max-width: 360px) { .learning-record-card { margin-inline: 10px; } .date-picker-weekdays, .date-picker-grid { gap: 5px; } .date-picker-day { height: 42px; font-size: 15px; } }
 @media (min-width: 760px) { .records-page { max-width: 760px; margin: 0 auto; border-inline: 1px solid var(--line); } }
+
+.income-item b { color: var(--brand-strong); font-size: 15px; font-variant-numeric: tabular-nums; }
+.income-item b.zero-fee { color: var(--ink-soft); }
+.income-item small { color: var(--ink-soft); font-size: 12px; line-height: 1.6; }
+
+.fee-correction { color: var(--ink-soft); font-size: 12px; }
+.fee-correction summary { display: flex; align-items: center; min-height: 44px; color: var(--brand-strong); cursor: pointer; }
+.fee-correction summary::after { content: '+'; margin-left: 6px; }
+.fee-correction[open] summary::after { content: '−'; }
+.fee-correction p { white-space: normal; line-height: 1.7; overflow-wrap: anywhere; }

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

@@ -1,11 +1,12 @@
 import { DropdownDirective } from '../../shared/dropdown.directive';
+import { allFeeRows, reviewFeeTotal } from '../../core/fee-summary';
 import { ReviewPage } from '../../core/review-page';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, computed, inject, OnInit, OnDestroy, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, Router, RouterLink } from '@angular/router';
 import { AlertCircle, ArrowRight, BookOpen, CalendarDays, CheckCircle2, ChevronLeft, ChevronRight, Clock3, LoaderCircle, LucideAngularModule, RotateCcw, Sparkles } from 'lucide-angular';
-import { catchError, finalize, forkJoin, of, Subscription } from 'rxjs';
+import { catchError, finalize, forkJoin, map, of, Subscription, switchMap } from 'rxjs';
 import { ApiError, ApiService } from '../../core/api.service';
 import { extractReadingWords } from '../../core/external-reading';
 import { canStartTeacherLedLearning } from '../../core/learning-access';
@@ -64,6 +65,15 @@ export class StudyRecordsPageComponent implements OnInit, OnDestroy {
   readonly liveSyncStatus = signal<LiveSyncStatus>({ state: 'current' });
   readonly error = signal('');
   incomeMonth = '';
+  readonly incomeDates = signal<ReviewPage['dates']>([]);
+  readonly incomeSummaryError = signal('');
+  readonly incomeMonthOptions = computed(() => {
+    const months = new Set(this.incomeDates().map(row => row.date.slice(0, 7)).filter(month => /^\d{4}-\d{2}$/.test(month)));
+    months.add(new Intl.DateTimeFormat('sv-SE', {timeZone:'Asia/Shanghai'}).format(new Date()).slice(0,7));
+    return [...months].sort().reverse();
+  });
+  setIncomeMonth(value: string): void { this.incomeMonth = value; this.recordPage.set(1); this.load(); }
+  incomeMonthLabel(): string { return this.incomeMonth ? this.incomeMonth.replace('-', '年') + '月' : '全部月份'; }
   readonly totalIncome = signal<number|null>(null);
   readonly activeTab = signal(0);
   readonly datePickerOpen = signal(false);
@@ -616,8 +626,30 @@ 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,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)});
+    this.historyRequest?.unsubscribe();
+    const requestId = ++this.historyRequestId;
+    const params = {uid, month: this.incomeMonth || undefined, page: this.recordPage(), pageSize: 20, status: this.meta.path === 'pages/member/ke_fuxi' ? 'completed' : undefined};
+    const isIncome = this.meta.path === 'pages/member/ke_fuxi';
+    this.incomeSummaryError.set('');
+    this.totalIncome.set(null);
+    const fetchPage = (page: number, pageSize = 20, includeMetadata = true) => this.api.post<ReviewPage>('app_review_page', {...params, page, pageSize, includeMetadata, includeIncome: includeMetadata}).pipe(map(({result}) => result));
+    this.historyRequest = fetchPage(params.page).pipe(switchMap(result => {
+      if (!isIncome || result.summary.incomeScope === (params.month || 'all')) return of({result, income: result.summary.income});
+      const toPage = (data: ReviewPage, index: number) => ({items: data.items, total: data.page.itemCount, pages: data.page.pageCount || 1, index});
+      const total = result.page.itemCount;
+      const first = total > result.items.length ? {items: [], total, pages: Math.ceil(total / 100), index: 0} : toPage(result, params.page);
+      return allFeeRows(first, page => fetchPage(page, 100, false).pipe(map(data => toPage(data, page)))).pipe(
+        map(rows => ({result, income: reviewFeeTotal(rows)})),
+        catchError(() => { this.incomeSummaryError.set('费用合计暂不可用,请刷新重试'); return of({result, income: null}); }),
+      );
+    }), finalize(() => { if (requestId === this.historyRequestId) this.loading.set(false); })).subscribe({
+      next: ({result, income}) => {
+        if (requestId !== this.historyRequestId) return;
+        this.items.set(result.items); this.serverTotal.set(result.page.itemCount); this.totalIncome.set(income);
+        if (result.dates?.length) this.incomeDates.set(result.dates);
+      },
+      error: (e: ApiError) => this.error.set(e.message),
+    });
   }
 
   private asList(value: unknown): Item[] {

+ 24 - 0
projects/xiaoshu-mobile/src/app/shared/fee-summary.scss

@@ -0,0 +1,24 @@
+.fee-summary-wrap { padding: 16px; background: var(--surface-muted); }
+.fee-summary-card { display: flex; min-width: 0; flex-direction: column; padding: 20px; color: white; background: var(--brand-strong); border-radius: 16px; }
+.fee-summary-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; font-size: 13px; line-height: 1.5; }
+.fee-summary-tag { padding: 3px 8px; border: 1px solid rgb(255 255 255 / 34%); border-radius: 6px; font-size: 12px; }
+.fee-summary-label { margin-top: 20px; font-size: 14px; }
+.fee-summary-amount { display: block; margin-top: 4px; font-size: 36px; font-weight: 750; line-height: 1.3; letter-spacing: -.5px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
+.fee-summary-bottom { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-top: 18px; padding-top: 12px; border-top: 1px solid rgb(255 255 255 / 26%); font-size: 12px; line-height: 1.6; }
+.fee-summary-note { margin: 10px 2px 0; color: var(--ink-soft); font-size: 12px; line-height: 1.7; }
+.fee-summary-error { color: var(--danger); }
+.fee-toolbar { padding: 16px; background: white; border-bottom: 1px solid var(--line); }
+.fee-toolbar > label { display: block; margin-bottom: 8px; color: var(--ink-soft); font-size: 12px; }
+.fee-toolbar-controls { display: flex; gap: 12px; align-items: center; }
+.fee-month-select { position: relative; flex: 1; min-width: 0; }
+.fee-month-select > lucide-icon { position: absolute; top: 50%; left: 12px; color: var(--brand-strong); transform: translateY(-50%); pointer-events: none; }
+.fee-month-select > .fee-select-chevron { left: auto; right: 12px; transform: translateY(-50%) rotate(90deg); }
+.fee-month-select select { width: 100%; min-height: 48px; padding: 10px 36px 10px 40px; color: var(--ink); background: white; border: 1px solid var(--line); border-radius: 10px; appearance: none; font: inherit; font-size: 16px; }
+.fee-toolbar-controls > button { min-height: 48px; padding: 8px 14px; color: var(--brand-strong); background: var(--brand-pale); border: 1px solid var(--line); border-radius: 10px; font: inherit; font-size: 13px; cursor: pointer; }
+.fee-toolbar-controls > button:disabled { opacity: .6; cursor: wait; }
+.fee-toolbar :is(select, button):focus-visible { outline: 2px solid var(--brand); outline-offset: 3px; }
+.fee-toolbar-controls > button:active { background: var(--line); }
+.fee-detail-heading { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; padding: 16px; background: white; border-bottom: 1px solid var(--line); }
+.fee-detail-heading h2 { margin: 0; font-size: 15px; }
+.fee-detail-heading > span { color: var(--ink-soft); font-size: 12px; }
+@media(max-width: 360px) { .fee-summary-wrap, .fee-toolbar { padding: 12px; } .fee-summary-card { padding: 16px; } }

+ 10 - 0
projects/xiaoshu-mobile/src/styles.scss

@@ -904,3 +904,13 @@ app-learning-page .word-column-empty { display: flex; min-height: 150px; padding
 }
 
 .mobile-pagination{display:flex;align-items:center;justify-content:center;gap:16px;padding:20px;flex-wrap:wrap}.mobile-pagination button{border:1px solid #dce6e2;border-radius:10px;background:#fff;color:#18745d;padding:10px 16px;font:inherit;cursor:pointer}.mobile-pagination button:disabled{opacity:.45;cursor:default}
+
+/* Keep the generated dropdown trigger aligned with the fee filter's calendar icon. */
+body app-study-records-page .fee-month-select > button.xs-dropdown-trigger {
+  min-height: 48px;
+  height: auto;
+  padding: 10px 16px 10px 40px;
+  font-size: 16px;
+  border-radius: 10px;
+}
+body app-study-records-page .fee-month-select > .fee-select-chevron { display: none; }

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

@@ -47,7 +47,7 @@ async function appReviewPage(input,current) {
   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?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};
+  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,incomeScope:input.month||'all'},dates,students,legacyReviewSync,serverToday:today,...mobileSource(),...versions};
 }
 
 async function appReviewComplete(input,current) {