Просмотр исходного кода

fix: preserve learning report history across month and date filters

彭峰 20 часов назад
Родитель
Сommit
5ebad5d853

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

@@ -7,7 +7,7 @@
 
 <section class="toolbar report-toolbar" aria-label="学习报表筛选">
   <div class="filter-field date-picker-field"><span>统计月份</span><button class="picker-trigger" type="button" aria-label="统计月份" aria-haspopup="dialog" [attr.aria-expanded]="activePicker()==='month'" [class.active]="activePicker()==='month'" (click)="toggleMonthPicker($event)"><lucide-icon [img]="icons.CalendarDays" [size]="17" /><strong>{{ monthLabel() }}</strong><lucide-icon class="picker-chevron" [img]="icons.ChevronDown" [size]="16" /></button>
-    @if(activePicker()==='month'){<div class="calendar-popover month-popover" role="dialog" aria-label="选择统计月份" (click)="$event.stopPropagation()"><header><button type="button" aria-label="上一年" (click)="shiftMonthPickerYear(-1,$event)"><lucide-icon [img]="icons.ChevronLeft" [size]="17" /></button><strong>{{ monthPickerYear() }}年</strong><button type="button" aria-label="下一年" (click)="shiftMonthPickerYear(1,$event)"><lucide-icon [img]="icons.ChevronRight" [size]="17" /></button></header><div class="month-grid">@for(name of monthNames;track $index){<button type="button" [class.selected]="month()===monthOptionValue($index)" (click)="selectMonth($index)">{{ name }}</button>}</div></div>}
+    @if(activePicker()==='month'){<div class="calendar-popover month-popover" role="dialog" aria-label="选择统计月份" (click)="$event.stopPropagation()"><header><button type="button" aria-label="上一年" (click)="shiftMonthPickerYear(-1,$event)"><lucide-icon [img]="icons.ChevronLeft" [size]="17" /></button><strong>{{ monthPickerYear() }}年</strong><button type="button" aria-label="下一年" (click)="shiftMonthPickerYear(1,$event)"><lucide-icon [img]="icons.ChevronRight" [size]="17" /></button></header><div class="month-grid">@for(name of monthNames;track $index){<button type="button" [class.selected]="month()===monthOptionValue($index)" (click)="selectMonth($index)">{{ name }}</button>}</div><button class="ui-button secondary" type="button" (click)="selectAllMonths()">全部月份</button></div>}
   </div>
   <div class="filter-field date-picker-field"><span>开始日期</span><button class="picker-trigger" type="button" aria-label="开始日期" aria-haspopup="dialog" [attr.aria-expanded]="activePicker()==='from'" [class.active]="activePicker()==='from'" (click)="toggleDatePicker('from',$event)"><lucide-icon [img]="icons.CalendarDays" [size]="17" /><strong>{{ dateLabel(dateFrom()) }}</strong><lucide-icon class="picker-chevron" [img]="icons.ChevronDown" [size]="16" /></button>
     @if(activePicker()==='from'){<ng-container [ngTemplateOutlet]="dateCalendar" />}
@@ -42,7 +42,7 @@
     <div class="table-scroll"><table class="data-table report-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 pageData.items; track trackReport($index, row)) {
         <tr><td><strong>{{ row.studyDate || '日期缺失' }}</strong><small>@if(row.appointmentAt){ {{ row.appointmentAt | date:'HH:mm' }} } @else {记录 {{ row.generalId }}}</small></td><td><strong>{{ row.studentName }}</strong><small>ID {{ row.studentId }} · {{ row.studentMobileMasked || '无手机号' }}</small></td><td>{{ row.coachName }}<small>@if(row.coachId){ ID {{ row.coachId }} }</small></td><td>{{ row.courseName }}<small>{{ row.classTypeLabel }}</small></td><td><strong>{{ row.learnedCount }} 个</strong><small>掌握 {{ row.masteredCount }} · 复习 {{ row.reviewDueCount }} · 未标记 {{ row.unmarkedCount }}</small></td><td><div class="rate"><span [style.width.%]="row.masteryRate"></span></div><strong>{{ row.masteryRate | number:'1.0-1' }}%</strong></td><td><span class="report-status" [attr.data-status]="row.status">{{ row.statusLabel }}</span></td><td><div class="row-actions"><button type="button" title="查看报表" aria-label="查看报表" (click)="open(row.targetId)"><lucide-icon [img]="icons.Eye" [size]="16" /></button></div></td></tr>
-      } @empty { <tr><td colspan="8"><div class="empty">当前筛选范围暂无学习报表</div></td></tr> }
+      } @empty { <tr><td colspan="8"><div class="empty">{{month()?monthLabel()+'暂无单词学习报表,可选择“全部月份”查看历史记录':'当前筛选范围暂无单词学习报表'}}</div></td></tr> }
     </tbody></table></div>
     <footer class="pagination"><span>第 {{ pageData.page }} / {{ totalPages() }} 页</span><div><button type="button" aria-label="上一页" [disabled]="pageData.page<=1" (click)="changePage(pageData.page-1)"><lucide-icon [img]="icons.ChevronLeft" [size]="17" /></button><button type="button" aria-label="下一页" [disabled]="pageData.page>=totalPages()" (click)="changePage(pageData.page+1)"><lucide-icon [img]="icons.ChevronRight" [size]="17" /></button></div></footer>
   </section>

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

@@ -31,6 +31,20 @@ describe('OperationsLearningReportsComponent', () => {
     fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges();
   });
 
+  it('starts with all months instead of hiding previous months at a month boundary', () => {
+    expect(fixture.componentInstance.monthLabel()).toBe('全部月份');
+    expect(operations.learningReports).toHaveBeenCalledWith(1,20,'',jasmine.objectContaining({month:''}),false);
+  });
+
+  it('keeps month and custom date filters mutually exclusive', () => {
+    const c=fixture.componentInstance;
+    c.month.set('2026-10');c.activePicker.set('from');c.selectDate('2026-09-01');
+    expect(c.month()).toBe('');expect(c.dateFrom()).toBe('2026-09-01');
+    c.monthPickerYear.set(2026);c.selectMonth(9);
+    expect(c.month()).toBe('2026-10');expect(c.dateFrom()).toBe('');expect(c.dateTo()).toBe('');
+    c.selectAllMonths();expect(c.monthLabel()).toBe('全部月份');
+  });
+
   it('scopes a member entry to the selected student and renders report metrics', () => {
     expect(operations.learningReports).toHaveBeenCalledWith(1, 20, '', jasmine.objectContaining({ studentObjectId: 'student-1' }), false);
     const element: HTMLElement = fixture.nativeElement;
@@ -100,7 +114,7 @@ describe('OperationsLearningReportsComponent', () => {
     }, false);
   });
 
-  it('resets every editable filter and immediately reloads the default month', async () => {
+  it('resets filters and loads all months so historical reports stay visible', async () => {
     const component = fixture.componentInstance;
     operations.learningReports.calls.reset();
     component.month.set('2025-02');
@@ -113,9 +127,7 @@ describe('OperationsLearningReportsComponent', () => {
 
     await component.resetFilters();
 
-    const now = new Date();
-    const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
-    expect(component.month()).toBe(currentMonth);
+    expect(component.month()).toBe('');
     expect(component.dateFrom()).toBe('');
     expect(component.dateTo()).toBe('');
     expect(component.search()).toBe('');
@@ -123,7 +135,7 @@ describe('OperationsLearningReportsComponent', () => {
     expect(component.courseSearch()).toBe('');
     expect(component.status()).toBe('');
     expect(operations.learningReports).toHaveBeenCalledWith(1, 20, '', {
-      month: currentMonth,
+      month: '',
       dateFrom: '',
       dateTo: '',
       status: '',

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

@@ -76,7 +76,7 @@ export class OperationsLearningReportsComponent implements OnInit, OnDestroy {
   readonly message = signal('');
   readonly page = signal(1);
   readonly pageSize = 20;
-  readonly month = signal(localMonth());
+  readonly month = signal('');
   readonly dateFrom = signal('');
   readonly dateTo = signal('');
   readonly search = signal('');
@@ -140,7 +140,7 @@ export class OperationsLearningReportsComponent implements OnInit, OnDestroy {
 
   async resetFilters(): Promise<void> {
     if (this.searching()) return;
-    this.month.set(localMonth()); this.dateFrom.set(''); this.dateTo.set(''); this.search.set('');
+    this.month.set(''); this.dateFrom.set(''); this.dateTo.set(''); this.search.set('');
     this.coachSearch.set(''); this.courseSearch.set(''); this.status.set(''); this.activePicker.set(null);
     await this.applyFilters();
   }
@@ -167,9 +167,11 @@ export class OperationsLearningReportsComponent implements OnInit, OnDestroy {
   }
 
   selectMonth(monthIndex: number): void {
-    this.month.set(`${this.monthPickerYear()}-${String(monthIndex + 1).padStart(2, '0')}`); this.activePicker.set(null);
+    this.month.set(`${this.monthPickerYear()}-${String(monthIndex + 1).padStart(2, '0')}`); this.dateFrom.set(''); this.dateTo.set(''); this.activePicker.set(null);
   }
 
+  selectAllMonths(): void { this.month.set(''); this.dateFrom.set(''); this.dateTo.set(''); this.activePicker.set(null); }
+
   shiftMonthPickerYear(delta: number, event: Event): void { event.stopPropagation(); this.monthPickerYear.update((year) => year + delta); }
 
   shiftCalendar(delta: number, event: Event): void {
@@ -179,6 +181,7 @@ export class OperationsLearningReportsComponent implements OnInit, OnDestroy {
   }
 
   selectDate(date: string): void {
+    this.month.set('');
     if (this.activePicker() === 'from') {
       this.dateFrom.set(date); if (this.dateTo() && this.dateTo() < date) this.dateTo.set('');
     } else if (this.activePicker() === 'to') {
@@ -197,7 +200,7 @@ export class OperationsLearningReportsComponent implements OnInit, OnDestroy {
     })];
   }
 
-  monthLabel(): string { const [year, month] = this.month().split('-'); return year && month ? `${year}年${Number(month)}月` : '选择月份'; }
+  monthLabel(): string { const [year, month] = this.month().split('-'); return year && month ? `${year}年${Number(month)}月` : '全部月份'; }
   monthOptionValue(monthIndex: number): string { return `${this.monthPickerYear()}-${String(monthIndex + 1).padStart(2, '0')}`; }
   dateLabel(value: string): string { return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value.replace(/^(\d{4})-(\d{2})-(\d{2})$/, '$1年$2月$3日') : '选择日期'; }
   calendarTitle(): string { return `${this.calendarYear()}年 ${this.calendarMonth()}月`; }

Разница между файлами не показана из-за своего большого размера
+ 6 - 5
scripts/deploy-admin-functions.mjs


+ 48 - 0
scripts/tests/learning-report-query.test.mjs

@@ -0,0 +1,48 @@
+import { after, before, test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import vm from 'node:vm';
+import { PGlite } from '@electric-sql/pglite';
+
+const db = new PGlite();
+const source = readFileSync(new URL('../deploy-admin-functions.mjs', import.meta.url), 'utf8');
+const gateway = {};
+vm.createContext(gateway);
+vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1], gateway);
+gateway.companyIdOf = () => 'co';
+before(async () => {
+  await db.exec(`
+    CREATE TABLE "DailyStudyRecord"("objectId" text,"company" text,"id" int,"userId" int,"pl" text,"dsid" text,"dqrq" text,"xxqs" text,"createdAt" timestamptz);
+    CREATE TABLE "CommonModel"("objectId" text,"company" text,"modelId" int,"itemId" int,"generalId" int,"createTime" timestamptz,"createdAt" timestamptz);
+    CREATE TABLE "_User"("objectId" text,"company" text,"legacyUserId" int,"legacyUserData" jsonb,"realName" text,"nickname" text,"username" text,"mobile" text);
+    CREATE TABLE "CourseAppointment"("objectId" text,"company" text,"id" int,"kcid" text,"dszt" text);
+    CREATE TABLE "Node"("company" text,"nodeId" int,"nodeName" text);
+    INSERT INTO "DailyStudyRecord" VALUES
+      ('compact','co',1,20,'30','','20261004','[]','2026-10-04T00:00:00Z'),
+      ('dashed','co',2,20,'30','','2026-10-04','[]','2026-10-04T00:00:00Z'),
+      ('slash','co',3,20,'30','','2026/10/03','[]','2026-10-03T00:00:00Z'),
+      ('fallback','co',4,20,'30','',NULL,'[]','2026-09-30T16:01:00Z'),
+      ('history','co',5,20,'30','','20260930','[]','2026-09-30T00:00:00Z'),
+      ('foreign','other',6,20,'30','','20261004','[]','2026-10-04T00:00:00Z');
+  `);
+});
+after(() => db.close());
+async function rows(filters) {
+  const query = gateway.learningReportQueryParts({}, { page: 1, pageSize: 20, filters });
+  return (await db.query('SELECT d."objectId"' + query.from + query.where + ' ORDER BY ' + query.studyDateSql + ' DESC,d."objectId"', query.values)).rows.map(row => row.objectId);
+}
+test('月份筛选兼容三种日期格式,并按北京时间补齐缺失学习日期', async () => {
+  assert.deepEqual(await rows({ month: '2026-10' }), ['compact','dashed','slash','fallback']);
+});
+test('全部月份包含历史记录,保持帐套隔离', async () => {
+  assert.deepEqual(await rows({ month: '' }), ['compact','dashed','slash','fallback','history']);
+});
+test('日期范围包含首尾当天,兼容不同保存格式', async () => {
+  assert.deepEqual(await rows({ dateFrom: '2026-10-03', dateTo: '2026-10-04' }), ['compact','dashed','slash']);
+  assert.deepEqual(await rows({ month: '2026-09' }), ['history']);
+});
+
+test('报表展示与筛选使用同一个学习日期,斜线日期与 UTC 时间不会少一天', () => {
+  for(const raw of ['20261004','2026-10-04','2026/10/04'])assert.equal(gateway.learningReportProjection({dqrq:raw}).studyDate,'2026-10-04');
+  assert.equal(gateway.learningReportProjection({recordCreatedAt:'2026-09-30T16:01:00Z'}).studyDate,'2026-10-01');
+});

Некоторые файлы не были показаны из-за большого количества измененных файлов