Browse Source

feat: add operations learning reports

彭峰 1 month ago
parent
commit
d046aea050

+ 1 - 0
projects/xiaoshu-admin/src/app/admin.constants.ts

@@ -21,6 +21,7 @@ export const OPERATIONS_NAVIGATION: readonly AdminNavigationGroup[] = [
     { label: '会员管理', icon: 'users', route: '/admin/members' },
     { label: '门店管理', icon: 'store', route: '/admin/stores' },
     { label: '排课中心', icon: 'calendar-days', route: '/admin/schedule' },
+    { label: '学习报表', icon: 'file-chart-column', route: '/admin/learning-reports' },
     { label: '陪练老师', icon: 'graduation-cap', route: '/admin/coaches' },
     { label: '工资结算', icon: 'wallet-cards', route: '/admin/payroll' },
   ] },

+ 73 - 0
projects/xiaoshu-admin/src/app/admin.models.ts

@@ -341,6 +341,79 @@ export interface OperationsUserDetail extends OperationsUser {
   agents: MemberAgentOption[];
 }
 
+export type LearningReportStatus = 'available' | 'incomplete' | 'missing' | 'historical';
+
+export interface LearningReportSummary extends Record<string, unknown> {
+  reportCount: number;
+  studentCount: number;
+  learnedCount: number;
+  masteredCount: number;
+  reviewDueCount: number;
+  masteryRate: number;
+}
+
+export interface LearningReportSourceStatus extends Record<string, unknown> {
+  state: 'synced' | 'delayed' | 'not_configured';
+  label: string;
+  message: string;
+  lastSuccessAt: string;
+  lagSeconds: number | null;
+}
+
+export interface LearningReportListItem extends Record<string, unknown> {
+  targetId: string;
+  generalId: number;
+  studentObjectId: string;
+  studentId: number;
+  studentName: string;
+  studentMobileMasked: string;
+  coachId: number;
+  coachName: string;
+  courseId: number;
+  courseName: string;
+  appointmentId: number;
+  appointmentAt: string;
+  studyAt: string;
+  studyDate: string;
+  classTypeLabel: string;
+  learnedCount: number;
+  masteredCount: number;
+  reviewDueCount: number;
+  unmarkedCount: number;
+  masteryRate: number;
+  status: LearningReportStatus;
+  statusLabel: string;
+  canExport: boolean;
+  updatedAt: string;
+}
+
+export interface LearningReportWord extends Record<string, unknown> {
+  index: number;
+  generalId: number;
+  word: string;
+  phonetic: string;
+  meaning: string;
+  example: string;
+  result: 'mastered' | 'review' | 'unmarked';
+  resultLabel: string;
+}
+
+export interface LearningReportDetail extends LearningReportListItem {
+  words: LearningReportWord[];
+  reviewSchedule: Array<{ day: number; date: string }>;
+  score: number;
+  studentComment: string;
+  coachComment: string;
+  generatedAt: string;
+  sourceStatus: LearningReportSourceStatus;
+  countMismatch: boolean;
+}
+
+export interface LearningReportPage extends OperationsPage<LearningReportListItem> {
+  summary: LearningReportSummary;
+  sourceStatus: LearningReportSourceStatus;
+}
+
 export interface CoachSummary extends Record<string, unknown> {
   objectId: string;
   userId: number;

+ 1 - 0
projects/xiaoshu-admin/src/app/admin.routes.ts

@@ -11,6 +11,7 @@ const operationsChildren: Routes = [
   { path: 'stores', loadComponent: () => import('./pages/operations-stores.component').then((module) => module.OperationsStoresComponent) },
   { path: 'stores/:objectId', loadComponent: () => import('./pages/operations-store-detail.component').then((module) => module.OperationsStoreDetailComponent) },
   { path: 'schedule', loadComponent: () => import('./pages/operations-schedule.component').then((module) => module.OperationsScheduleComponent) },
+  { path: 'learning-reports', loadComponent: () => import('./pages/operations-learning-reports.component').then((module) => module.OperationsLearningReportsComponent) },
   { path: 'appointments', pathMatch: 'full', redirectTo: 'schedule' },
   { path: 'coaches', loadComponent: () => import('./pages/operations-coaches.component').then((module) => module.OperationsCoachesComponent) },
   { path: 'coaches/:objectId', loadComponent: () => import('./pages/operations-coach-detail.component').then((module) => module.OperationsCoachDetailComponent) },

+ 10 - 0
projects/xiaoshu-admin/src/app/operations.service.spec.ts

@@ -75,6 +75,16 @@ describe('OperationsService', () => {
     expect(functions.operations.calls.argsFor(1)).toEqual(['ops/sync/run', { dataset: 'appointments', full: false, pageSize: 500, maxPages: 5 }]);
   });
 
+  it('loads, previews and exports learning reports through dedicated cloud operations', async () => {
+    functions.operations.and.resolveTo({ page: 1, pageSize: 20, total: 0, items: [], summary: {}, sourceStatus: {} });
+    await service.learningReports(1, 20, '万宇昊', { month: '2026-09', status: 'available' }, true);
+    await service.learningReportDetail('study-common-1');
+    await service.learningReportExport('study-common-1');
+    expect(functions.operations.calls.argsFor(0)).toEqual(['ops/learning-reports/list', { page: 1, pageSize: 20, search: '万宇昊', filters: { month: '2026-09', status: 'available' }, sort: { field: 'studyDate', direction: 'desc' }, refresh: true }]);
+    expect(functions.operations.calls.argsFor(1)).toEqual(['ops/learning-reports/detail', { targetId: 'study-common-1' }]);
+    expect(functions.operations.calls.argsFor(2)).toEqual(['ops/learning-reports/export', { targetId: 'study-common-1' }]);
+  });
+
   it('sends Excel rows to preview before committing an import batch', async () => {
     spyOn(crypto, 'randomUUID').and.returnValue('00000000-0000-4000-8000-000000000001');
     functions.operations.and.resolveTo({ batchId: 'batch-1', status: 'preview', summary: { total: 1, create: 1, update: 0, unchanged: 0, duplicate: 0, error: 0, missingMedia: 1 }, items: [], createdAt: '' });

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

@@ -1,5 +1,5 @@
 import { inject, Injectable } from '@angular/core';
-import { CoachAvailability, CoachDetailData, CoachPage, LegacySyncRunResult, LegacySyncStatus, MediaUploadTicket, MemberImportPreview, MemberImportResult, MemberListSort, MemberPage, OperationsDashboardData, OperationsPage, OperationsUserDetail, PayRate, PayrollDetail, PayrollExportData, PayrollOverview, PayrollSummary, ScheduleCalendarData, ScheduleTrendData, ScheduleTrendPeriod, SpecialTrainingPage, StoreDetailData, StorePage, VocabularyImportPreview, VocabularyTree, VocabularyWord } from './admin.models';
+import { CoachAvailability, CoachDetailData, CoachPage, LearningReportDetail, LearningReportPage, LegacySyncRunResult, LegacySyncStatus, MediaUploadTicket, MemberImportPreview, MemberImportResult, MemberListSort, MemberPage, OperationsDashboardData, OperationsPage, OperationsUserDetail, PayRate, PayrollDetail, PayrollExportData, PayrollOverview, PayrollSummary, ScheduleCalendarData, ScheduleTrendData, ScheduleTrendPeriod, SpecialTrainingPage, StoreDetailData, StorePage, VocabularyImportPreview, VocabularyTree, VocabularyWord } from './admin.models';
 import { CloudFunctionsService } from './cloud-functions.service';
 
 export type OperationsListKind = 'relations' | 'course-bindings' | 'appointments' | 'lessons' | 'learning-records' | 'finance' | 'money-logs';
@@ -72,6 +72,18 @@ export class OperationsService {
     return this.functions.operations<CoachDetailData>('ops/coaches/detail', { objectId, month, refresh });
   }
 
+  learningReports(page: number, pageSize: number, search = '', filters: Record<string, unknown> = {}, refresh = false): Promise<LearningReportPage> {
+    return this.functions.operations<LearningReportPage>('ops/learning-reports/list', { page, pageSize, search, filters, sort: { field: 'studyDate', direction: 'desc' }, refresh });
+  }
+
+  learningReportDetail(targetId: string): Promise<LearningReportDetail> {
+    return this.functions.operations<LearningReportDetail>('ops/learning-reports/detail', { targetId });
+  }
+
+  learningReportExport(targetId: string): Promise<LearningReportDetail> {
+    return this.functions.operations<LearningReportDetail>('ops/learning-reports/export', { targetId });
+  }
+
   schedule(dateFrom: string, dateTo: string, coachId = 0): Promise<ScheduleCalendarData> {
     return this.functions.operations<ScheduleCalendarData>('ops/schedule/calendar', { dateFrom, dateTo, filters: { coachId } });
   }

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

@@ -0,0 +1,55 @@
+<header class="page-header report-header"><div><p>LEARNING REPORTS</p><h1>单词学习报表</h1><span>集中查看学生每次学习结果,生成可下载的专业 PDF 报表</span></div><button class="ui-button refresh-action" type="button" (click)="load(true)" [disabled]="refreshing()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="refreshing()" />{{ refreshing() ? '正在同步' : '刷新最新数据' }}</button></header>
+
+@if (message()) { <p class="notice" role="status">{{ message() }}</p> }
+@if (error()) { <p class="notice error" role="alert">{{ error() }}</p> }
+
+@if (studentObjectId()) { <div class="member-scope"><span>当前仅显示该会员的学习报表</span><a routerLink="/admin/learning-reports">查看全部会员</a></div> }
+
+<section class="toolbar report-toolbar" aria-label="学习报表筛选">
+  <label><span>统计月份</span><input type="month" [ngModel]="month()" (ngModelChange)="month.set($event)" /></label>
+  <label><span>开始日期</span><input type="date" [ngModel]="dateFrom()" (ngModelChange)="dateFrom.set($event)" /></label>
+  <label><span>结束日期</span><input type="date" [ngModel]="dateTo()" (ngModelChange)="dateTo.set($event)" /></label>
+  <label class="search-field"><span>学员</span><input [ngModel]="search()" (ngModelChange)="search.set($event)" placeholder="姓名、手机号或ID" (keyup.enter)="applyFilters()" /></label>
+  <label><span>陪练</span><input [ngModel]="coachSearch()" (ngModelChange)="coachSearch.set($event)" placeholder="姓名或ID" /></label>
+  <label><span>课程</span><input [ngModel]="courseSearch()" (ngModelChange)="courseSearch.set($event)" placeholder="课程名称" /></label>
+  <label><span>报表状态</span><select [ngModel]="status()" (ngModelChange)="status.set($event)"><option value="">全部状态</option><option value="available">可生成</option><option value="historical">历史记录</option><option value="incomplete">学习未完成</option><option value="missing">明细缺失</option></select></label>
+  <button class="ui-button primary query-action" type="button" (click)="applyFilters()"><lucide-icon [img]="icons.Search" [size]="17" />查询</button>
+</section>
+
+@if (data(); as pageData) {
+  <div class="source-line" [class.delayed]="pageData.sourceStatus.state !== 'synced'" role="status"><i></i><strong>{{ pageData.sourceStatus.label }}</strong><span>{{ pageData.sourceStatus.message }}</span>@if (pageData.sourceStatus.lastSuccessAt) { <time>{{ pageData.sourceStatus.lastSuccessAt | date:'MM-dd HH:mm:ss' }}</time> }</div>
+  <section class="report-metrics" aria-label="学习报表摘要">
+    <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.learnedCount }}</strong><small>全部学习词次</small></article>
+    <article><span>已掌握</span><strong>{{ pageData.summary.masteredCount }}</strong><small>学习结果为已掌握</small></article>
+    <article><span>待复习</span><strong>{{ pageData.summary.reviewDueCount }}</strong><small>进入复习计划</small></article>
+    <article><span>平均掌握率</span><strong>{{ pageData.summary.masteryRate | number:'1.0-1' }}%</strong><small>已掌握 / 学习单词</small></article>
+  </section>
+
+  <section class="content-card report-list-card">
+    <header><div><strong>学习报表明细</strong><span>共 {{ pageData.total }} 份,点击查看完整单词与复习计划</span></div></header>
+    <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><button type="button" title="下载PDF" aria-label="下载PDF" [disabled]="!row.canExport || exportingId()===row.targetId" (click)="download(row)"><lucide-icon [img]="exportingId()===row.targetId ? icons.LoaderCircle : icons.Download" [size]="16" [class.spin]="exportingId()===row.targetId" /></button></div></td></tr>
+      } @empty { <tr><td colspan="8"><div class="empty">当前筛选范围暂无学习报表</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>
+} @else if (loading()) { <div class="state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="28" />正在加载学习报表…</div> }
+
+@if (detailLoading() && !detail()) { <div class="detail-loading"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="28" />正在整理学习报表…</div> }
+@if (detail(); as report) {
+  <div class="backdrop" (click)="close()"></div><aside class="drawer report-drawer" role="dialog" aria-modal="true" aria-label="单词学习报表预览"><header><div><p>REPORT PREVIEW</p><h2>{{ report.studentName }} · {{ report.studyDate }}</h2><span>学习记录 {{ report.generalId }}</span></div><button class="icon-action" type="button" aria-label="关闭" (click)="close()"><lucide-icon [img]="icons.X" [size]="20" /></button></header>
+  @if (report.countMismatch) { <p class="notice error">历史汇总数量与单词明细不一致,报表已按实际单词明细重新计算。</p> }
+  <div class="preview-scroll"><article #pdfContent class="pdf-sheet">
+    <section class="pdf-title"><div class="brand-mark"><lucide-icon [img]="icons.FileChartColumn" [size]="28" /></div><div><p>小树陪练</p><h1>单词学习报表</h1></div><span>NO. {{ report.generalId }}</span></section>
+    <dl class="report-meta"><div><dt>学员</dt><dd>{{ report.studentName }}</dd></div><div><dt>陪练老师</dt><dd>{{ report.coachName }}</dd></div><div><dt>课程</dt><dd>{{ report.courseName }}</dd></div><div><dt>学习日期</dt><dd>{{ report.studyDate }} @if(report.appointmentAt){ {{ report.appointmentAt | date:'HH:mm' }} }</dd></div><div><dt>课型</dt><dd>{{ report.classTypeLabel }}</dd></div><div><dt>预约编号</dt><dd>{{ report.appointmentId || '历史记录' }}</dd></div></dl>
+    <section class="report-summary"><article><strong>{{ report.learnedCount }}</strong><span>学习单词</span></article><article><strong>{{ report.masteredCount }}</strong><span>已掌握</span></article><article><strong>{{ report.reviewDueCount }}</strong><span>待复习</span></article><article><strong>{{ report.unmarkedCount }}</strong><span>未标记</span></article><article><strong>{{ report.masteryRate | number:'1.0-1' }}%</strong><span>掌握率</span></article></section>
+    <section class="pdf-section"><h2>单词学习明细</h2><table class="word-table"><thead><tr><th>#</th><th>单词 / 音标</th><th>中文释义</th><th>学习结果</th></tr></thead><tbody>@for (word of report.words; track word.index) { <tr class="word-row"><td>{{ word.index }}</td><td><strong>{{ word.word }}</strong><small>{{ word.phonetic || '—' }}</small>@if(word.example){<em>{{ word.example }}</em>}</td><td>{{ word.meaning || '暂无释义' }}</td><td><span [attr.data-result]="word.result">{{ word.resultLabel }}</span></td></tr> } @empty { <tr><td colspan="4">没有可展示的单词明细</td></tr> }</tbody></table></section>
+    @if (report.reviewSchedule.length) { <section class="pdf-section review-section"><h2>21天复习计划</h2><div class="review-grid">@for (item of report.reviewSchedule; track item.day) { <div class="review-item"><strong>第 {{ item.day }} 天</strong><span>{{ item.date }}</span></div> }</div></section> }
+    @if (report.score || report.studentComment || report.coachComment) { <section class="pdf-section"><h2>课程评价</h2><div class="comments"><article class="comment-card"><strong>学员评分</strong><p>{{ report.score ? report.score + ' 分' : '未评分' }}</p></article><article class="comment-card"><strong>学员评价</strong><p>{{ report.studentComment || '暂无评价' }}</p></article><article class="comment-card"><strong>陪练点评</strong><p>{{ report.coachComment || '暂无点评' }}</p></article></div></section> }
+    <footer class="pdf-footer"><span>数据来源:学习记录 {{ report.generalId }}</span><span>生成时间:{{ report.generatedAt | date:'yyyy-MM-dd HH:mm:ss' }}</span></footer>
+  </article></div>
+  <footer class="drawer-actions"><span class="report-status" [attr.data-status]="report.status">{{ report.statusLabel }}</span><button class="ui-button primary" type="button" [disabled]="!report.canExport || !!exportingId()" (click)="download(report)"><lucide-icon [img]="exportingId() ? icons.LoaderCircle : icons.Download" [size]="17" [class.spin]="!!exportingId()" />{{ exportingId() ? '正在生成' : '下载 PDF' }}</button></footer></aside>
+}

File diff suppressed because it is too large
+ 0 - 0
projects/xiaoshu-admin/src/app/pages/operations-learning-reports.component.scss


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

@@ -0,0 +1,49 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
+import { LearningReportDetail, LearningReportPage } from '../admin.models';
+import { OperationsService } from '../operations.service';
+import { OperationsLearningReportsComponent } from './operations-learning-reports.component';
+
+const sourceStatus = { state: 'synced' as const, label: '学习记录已同步', message: '', lastSuccessAt: '2026-09-04T12:00:00.000Z', lagSeconds: 0 };
+const report = {
+  targetId: 'study-common-1', generalId: 855751, studentObjectId: 'student-1', studentId: 1825, studentName: '测试学员', studentMobileMasked: '135****1234',
+  coachId: 1665, coachName: '测试陪练', courseId: 291, courseName: '人教版', appointmentId: 854951, appointmentAt: '2026-09-04T12:00:00.000Z',
+  studyAt: '2026-09-04T12:00:00.000Z', studyDate: '2026-09-04', classTypeLabel: '30 分钟', learnedCount: 3, masteredCount: 2,
+  reviewDueCount: 1, unmarkedCount: 0, masteryRate: 66.7, status: 'available' as const, statusLabel: '可生成', canExport: true, updatedAt: '2026-09-04T12:00:00.000Z',
+};
+
+describe('OperationsLearningReportsComponent', () => {
+  let fixture: ComponentFixture<OperationsLearningReportsComponent>;
+  let operations: jasmine.SpyObj<OperationsService>;
+
+  beforeEach(async () => {
+    const page: LearningReportPage = { page: 1, pageSize: 20, total: 1, items: [report], summary: { reportCount: 1, studentCount: 1, learnedCount: 3, masteredCount: 2, reviewDueCount: 1, masteryRate: 66.7 }, sourceStatus, refreshedAt: '2026-09-04T12:00:00.000Z' };
+    const detail: LearningReportDetail = { ...report, words: [{ index: 1, generalId: 1, word: 'apple', phonetic: '', meaning: '苹果', example: '', result: 'mastered', resultLabel: '已掌握' }], reviewSchedule: [{ day: 1, date: '2026-09-05' }], score: 5, studentComment: '很好', coachComment: '继续复习', generatedAt: '2026-09-04T12:00:00.000Z', sourceStatus, countMismatch: false };
+    operations = jasmine.createSpyObj<OperationsService>('OperationsService', ['learningReports', 'learningReportDetail', 'learningReportExport']);
+    operations.learningReports.and.resolveTo(page);
+    operations.learningReportDetail.and.resolveTo(detail);
+    operations.learningReportExport.and.resolveTo(detail);
+    await TestBed.configureTestingModule({
+      imports: [OperationsLearningReportsComponent],
+      providers: [provideRouter([]), { provide: OperationsService, useValue: operations }, { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: convertToParamMap({ studentObjectId: 'student-1' }) } } }],
+    }).compileComponents();
+    fixture = TestBed.createComponent(OperationsLearningReportsComponent);
+    fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges();
+  });
+
+  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;
+    expect(element.textContent).toContain('单词学习报表');
+    expect(element.textContent).toContain('测试学员');
+    expect(element.textContent).toContain('66.7%');
+  });
+
+  it('opens the complete word report through the detail cloud operation', async () => {
+    await fixture.componentInstance.open('study-common-1');
+    fixture.detectChanges();
+    expect(operations.learningReportDetail).toHaveBeenCalledWith('study-common-1');
+    expect((fixture.nativeElement as HTMLElement).textContent).toContain('apple');
+    expect((fixture.nativeElement as HTMLElement).textContent).toContain('21天复习计划');
+  });
+});

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

@@ -0,0 +1,127 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, ElementRef, OnInit, ViewChild, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute, RouterLink } from '@angular/router';
+import html2pdf from 'html2pdf.js';
+import { ChevronLeft, ChevronRight, Download, Eye, FileChartColumn, LoaderCircle, LucideAngularModule, RefreshCw, Search, X } from 'lucide-angular';
+import { LearningReportDetail, LearningReportListItem, LearningReportPage } from '../admin.models';
+import { OperationsService } from '../operations.service';
+
+const localMonth = (): string => {
+  const now = new Date();
+  return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
+};
+
+@Component({
+  selector: 'app-operations-learning-reports',
+  standalone: true,
+  imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule],
+  templateUrl: './operations-learning-reports.component.html',
+  styleUrls: ['./operations-professional.scss', './operations-learning-reports.component.scss'],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class OperationsLearningReportsComponent implements OnInit {
+  @ViewChild('pdfContent') pdfContent?: ElementRef<HTMLElement>;
+
+  readonly data = signal<LearningReportPage | null>(null);
+  readonly detail = signal<LearningReportDetail | null>(null);
+  readonly loading = signal(true);
+  readonly refreshing = signal(false);
+  readonly detailLoading = signal(false);
+  readonly exportingId = signal('');
+  readonly error = signal('');
+  readonly message = signal('');
+  readonly page = signal(1);
+  readonly pageSize = 20;
+  readonly month = signal(localMonth());
+  readonly dateFrom = signal('');
+  readonly dateTo = signal('');
+  readonly search = signal('');
+  readonly coachSearch = signal('');
+  readonly courseSearch = signal('');
+  readonly status = signal('');
+  readonly studentObjectId = signal('');
+  readonly icons = { ChevronLeft, ChevronRight, Download, Eye, FileChartColumn, LoaderCircle, RefreshCw, Search, X };
+
+  private readonly operations = inject(OperationsService);
+  private readonly route = inject(ActivatedRoute);
+
+  ngOnInit(): void {
+    this.studentObjectId.set(this.route.snapshot.queryParamMap.get('studentObjectId') || '');
+    const requestedReport = this.route.snapshot.queryParamMap.get('report') || '';
+    void this.load(false).then(() => requestedReport ? this.open(requestedReport) : undefined);
+  }
+
+  private filters(): Record<string, unknown> {
+    return {
+      month: this.month(), dateFrom: this.dateFrom(), dateTo: this.dateTo(), status: this.status(),
+      coachSearch: this.coachSearch(), courseSearch: this.courseSearch(), studentObjectId: this.studentObjectId(),
+    };
+  }
+
+  async load(refresh = false): Promise<void> {
+    if (refresh) this.refreshing.set(true); else this.loading.set(true);
+    this.error.set(''); this.message.set('');
+    try {
+      const page = await this.operations.learningReports(this.page(), this.pageSize, this.search(), this.filters(), refresh);
+      this.data.set(page);
+      if (refresh) this.message.set(page.sourceStatus.state === 'synced' ? '学习记录已同步到最新水位' : page.sourceStatus.message);
+    } catch (error) {
+      this.error.set(error instanceof Error ? error.message : '学习报表加载失败');
+    } finally {
+      this.loading.set(false); this.refreshing.set(false);
+    }
+  }
+
+  async applyFilters(): Promise<void> { this.page.set(1); await this.load(false); }
+  async changePage(next: number): Promise<void> {
+    const totalPages = Math.max(1, Math.ceil((this.data()?.total || 0) / this.pageSize));
+    if (next < 1 || next > totalPages || next === this.page()) return;
+    this.page.set(next); await this.load(false);
+  }
+
+  async open(targetId: string): Promise<void> {
+    this.detailLoading.set(true); this.error.set('');
+    try { this.detail.set(await this.operations.learningReportDetail(targetId)); }
+    catch (error) { this.error.set(error instanceof Error ? error.message : '报表详情加载失败'); }
+    finally { this.detailLoading.set(false); }
+  }
+
+  close(): void { this.detail.set(null); }
+
+  async download(row: LearningReportListItem | LearningReportDetail): Promise<void> {
+    if (!row.canExport || this.exportingId()) return;
+    this.exportingId.set(row.targetId); this.error.set('');
+    try {
+      const report = await this.operations.learningReportExport(row.targetId);
+      this.detail.set(report);
+      await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
+      await document.fonts?.ready;
+      const element = this.pdfContent?.nativeElement;
+      if (!element) throw new Error('报表打印区域尚未准备完成');
+      const safeName = `${report.studentName}-${report.studyDate}-${report.generalId}`.replace(/[^a-zA-Z0-9一-龥_-]/g, '_');
+      let hasBlankTrailingPage = false;
+      const worker = html2pdf().set({
+        margin: [9, 9, 9, 9], filename: `小树陪练-单词学习报表-${safeName}.pdf`,
+        image: { type: 'jpeg', quality: 1 },
+        html2canvas: { scale: 1.5, logging: false, useCORS: true, backgroundColor: '#ffffff' },
+        jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
+        pagebreak: { mode: ['css', 'legacy'], avoid: ['.report-summary article', '.word-row', '.review-item', '.comment-card'] },
+      }).from(element).toCanvas().get('canvas', (canvas) => {
+        const pageHeight = Math.floor(canvas.width * (279 / 192));
+        const trailingSlice = canvas.height % pageHeight;
+        hasBlankTrailingPage = trailingSlice > 0 && trailingSlice < 32;
+      }).toPdf();
+      await worker.get('pdf', (pdf) => {
+        const pageCount = pdf.internal.getNumberOfPages();
+        if (hasBlankTrailingPage && pageCount > 1) pdf.deletePage(pageCount);
+      }).save();
+      this.message.set('学习报表 PDF 已生成');
+    } catch (error) {
+      this.error.set(error instanceof Error ? error.message : 'PDF 生成失败');
+    } finally { this.exportingId.set(''); }
+  }
+
+  totalPages(): number { return Math.max(1, Math.ceil((this.data()?.total || 0) / this.pageSize)); }
+  trackReport(_index: number, row: LearningReportListItem): string { return row.targetId; }
+}

File diff suppressed because it is too large
+ 2 - 0
projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.html


+ 5 - 4
projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.ts

@@ -2,11 +2,11 @@ import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, RouterLink } from '@angular/router';
-import { ArrowLeft, BookOpen, CalendarDays, ClipboardList, Clock3, Eye, FileClock, GraduationCap, LoaderCircle, LucideAngularModule, MessageSquarePlus, RefreshCw, Save, ShieldAlert, UserRound, WalletCards } from 'lucide-angular';
+import { ArrowLeft, BookOpen, CalendarDays, ClipboardList, Clock3, Eye, FileChartColumn, FileClock, GraduationCap, LoaderCircle, LucideAngularModule, MessageSquarePlus, RefreshCw, Save, ShieldAlert, UserRound, WalletCards } from 'lucide-angular';
 import { MemberAgentOption, OperationsUserDetail, ScheduleCalendarData } from '../admin.models';
 import { OperationsListKind, OperationsService } from '../operations.service';
 
-type MemberTab = 'overview' | 'courses' | 'appointments' | 'lessons' | 'balances' | 'audit';
+type MemberTab = 'overview' | 'courses' | 'appointments' | 'lessons' | 'reports' | 'balances' | 'audit';
 
 @Component({
   selector: 'app-operations-user-detail', standalone: true,
@@ -15,12 +15,12 @@ type MemberTab = 'overview' | 'courses' | 'appointments' | 'lessons' | 'balances
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class OperationsUserDetailComponent implements OnInit {
-  readonly user = signal<OperationsUserDetail | null>(null); readonly tab = signal<MemberTab>('overview'); readonly related = signal<Record<MemberTab, Record<string, unknown>[]>>({ overview: [], courses: [], appointments: [], lessons: [], balances: [], audit: [] });
+  readonly user = signal<OperationsUserDetail | null>(null); readonly tab = signal<MemberTab>('overview'); readonly related = signal<Record<MemberTab, Record<string, unknown>[]>>({ overview: [], courses: [], appointments: [], lessons: [], reports: [], balances: [], audit: [] });
   readonly notes = signal<Record<string, unknown>[]>([]); readonly loading = signal(true); readonly sectionLoading = signal(false); readonly saving = signal(false); readonly error = signal(''); readonly message = signal(''); readonly reason = signal('');
   readonly editName = signal(''); readonly editNickname = signal(''); readonly editMobile = signal(''); readonly editEmail = signal(''); readonly editGender = signal(''); readonly editGrade = signal(''); readonly parentUserId = signal(0); readonly agentSearch = signal('');
   readonly balanceType = signal(1); readonly balanceDirection = signal<'add' | 'deduct'>('add'); readonly balanceAmount = signal(0); readonly payMethod = signal(''); readonly resetPassword = signal(''); readonly noteContent = signal(''); readonly revealMobile = signal(false);
   readonly courseId = signal(0); readonly courseOpenedCount = signal(1); readonly courseSearch = signal(''); readonly courseOptions = signal<ScheduleCalendarData['courses']>([]);
-  readonly icons = { ArrowLeft, BookOpen, CalendarDays, ClipboardList, Clock3, Eye, FileClock, GraduationCap, LoaderCircle, MessageSquarePlus, RefreshCw, Save, ShieldAlert, UserRound, WalletCards };
+  readonly icons = { ArrowLeft, BookOpen, CalendarDays, ClipboardList, Clock3, Eye, FileChartColumn, FileClock, GraduationCap, LoaderCircle, MessageSquarePlus, RefreshCw, Save, ShieldAlert, UserRound, WalletCards };
   private readonly route = inject(ActivatedRoute); private readonly operations = inject(OperationsService); private readonly objectId = this.route.snapshot.paramMap.get('objectId') || '';
   readonly filteredAgents = computed(() => { const keyword = this.agentSearch().trim().toLowerCase(); return (this.user()?.agents || []).filter(agent => !keyword || `${agent.displayName} ${agent.userId}`.toLowerCase().includes(keyword)).slice(0, 30); });
   readonly filteredCourses = computed(() => { const keyword = this.courseSearch().trim().toLowerCase(); return this.courseOptions().filter(course => !keyword || `${course.courseName} ${course.courseId}`.toLowerCase().includes(keyword)).slice(0, 30); });
@@ -38,6 +38,7 @@ export class OperationsUserDetailComponent implements OnInit {
     const user = this.user(); if (!user || tab === 'overview') return; this.sectionLoading.set(true); this.error.set('');
     try {
       if (tab === 'audit') { const [notes, audit] = await Promise.all([this.operations.memberRows('ops/users/notes', user.objectId), this.operations.memberRows('ops/users/audit', user.objectId)]); this.notes.set(notes.items); this.related.update(value => ({ ...value, audit: audit.items })); }
+      else if (tab === 'reports') { const page = await this.operations.learningReports(1, 100, '', { studentObjectId: user.objectId }, false); this.related.update(value => ({ ...value, reports: page.items })); }
       else { const kind = this.kindFor(tab); if (kind) { const page = await this.operations.list(kind, 1, 100, String(user.userId)); this.related.update(value => ({ ...value, [tab]: page.items })); } }
       if (tab === 'courses' && user.permissions.canEdit && !this.courseOptions().length) { const today = new Date().toISOString().slice(0, 10); const schedule = await this.operations.schedule(today, today); this.courseOptions.set(schedule.courses); }
     } catch (error) { this.error.set(error instanceof Error ? error.message : '业务记录加载失败'); }

+ 15 - 0
projects/xiaoshu-admin/src/types/html2pdf.d.ts

@@ -0,0 +1,15 @@
+declare module 'html2pdf.js' {
+  interface Html2PdfWorker {
+    set(options: Record<string, unknown>): Html2PdfWorker;
+    from(element: HTMLElement): Html2PdfWorker;
+    toCanvas(): Html2PdfWorker;
+    toPdf(): Html2PdfWorker;
+    get(key: 'canvas', callback: (canvas: HTMLCanvasElement) => void): Html2PdfWorker;
+    get(key: 'pdf', callback: (pdf: {
+      internal: { getNumberOfPages(): number };
+      deletePage(pageNumber: number): void;
+    }) => void): Html2PdfWorker;
+    save(): Promise<void>;
+  }
+  export default function html2pdf(): Html2PdfWorker;
+}

File diff suppressed because it is too large
+ 23 - 0
scripts/deploy-admin-functions.mjs


+ 4 - 5
scripts/ensure-app-performance-indexes.mjs

@@ -33,6 +33,9 @@ const statements = [
   'CREATE INDEX IF NOT EXISTS "idx_cm_company_model_node" ON "CommonModel" ("company","modelId","nodeId")',
   'CREATE INDEX IF NOT EXISTS "idx_cm_company_model_node_order" ON "CommonModel" ("company","modelId","nodeId","orderId","generalId")',
   'CREATE INDEX IF NOT EXISTS "idx_daily_company_user_date" ON "DailyStudyRecord" ("company","userId","dqrq")',
+  'CREATE INDEX IF NOT EXISTS "idx_daily_company_date_user" ON "DailyStudyRecord" ("company","dqrq","userId")',
+  'CREATE INDEX IF NOT EXISTS "idx_daily_company_coach_date" ON "DailyStudyRecord" ("company","pl","dqrq")',
+  'CREATE INDEX IF NOT EXISTS "idx_daily_company_appointment" ON "DailyStudyRecord" ("company","dsid")',
   'CREATE INDEX IF NOT EXISTS "idx_daily_company_id" ON "DailyStudyRecord" ("company","id")',
   'CREATE INDEX IF NOT EXISTS "idx_appt_company_student_time" ON "CourseAppointment" ("company","szyh","yysj")',
   'CREATE INDEX IF NOT EXISTS "idx_appt_company_coach_time" ON "CourseAppointment" ("company","pl","yysj")',
@@ -62,11 +65,7 @@ async function handler(request, response) {
     const applied = [], skipped = [];
     for (const statement of statements) {
       try {
-        await Psql.transaction(async (tx) => {
-          await tx.none("SET LOCAL lock_timeout = '3s'");
-          await tx.none("SET LOCAL statement_timeout = '60s'");
-          await tx.none(statement);
-        });
+        await Psql.none(statement);
         applied.push(statement.match(/"([^"]+)"/)?.[1] || statement);
       } catch (error) {
         skipped.push({ index: statement.match(/"([^"]+)"/)?.[1] || statement, reason: String(error.message || error) });

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

@@ -161,6 +161,19 @@ try {
     if (!Array.isArray(page.items) || typeof page.total !== 'number') throw new Error(`${operation} 未返回统一分页结构`);
   }
 
+  const learningReports = (await ops({ operation: 'ops/learning-reports/list', page: 1, pageSize: 20, search: '', filters: { month: '2026-09' }, refresh: true })).data;
+  if (!Array.isArray(learningReports.items) || typeof learningReports.total !== 'number' || !learningReports.summary || !learningReports.sourceStatus) throw new Error('学习报表列表、摘要或同步状态结构无效');
+  if (learningReports.sourceStatus.state === 'not_configured') throw new Error('学习记录持续同步桥尚未配置,无法保证报表包含最新数据');
+  if (!learningReports.items.some((item) => Number(item.generalId) === 855751) || !learningReports.items.some((item) => String(item.studyDate || '') >= '2026-09-04')) throw new Error(`学习报表没有同步旧系统 2026-09-04 样本:${JSON.stringify({ total: learningReports.total, sourceStatus: learningReports.sourceStatus, ids: learningReports.items.map((item) => item.generalId) })}`);
+  const exportableReport = learningReports.items.find((item) => item.canExport);
+  if (exportableReport) {
+    const reportDetail = (await ops({ operation: 'ops/learning-reports/detail', targetId: exportableReport.targetId })).data;
+    if (!Array.isArray(reportDetail.words) || reportDetail.words.length !== reportDetail.learnedCount || !Array.isArray(reportDetail.reviewSchedule)) throw new Error('学习报表详情单词或复习计划结构无效');
+    if (reportDetail.words.some((word) => !word.word || !word.resultLabel)) throw new Error('学习报表单词明细缺少单词或学习结果');
+    const exportSnapshot = (await ops({ operation: 'ops/learning-reports/export', targetId: exportableReport.targetId })).data;
+    if (exportSnapshot.generalId !== reportDetail.generalId || exportSnapshot.words?.length !== reportDetail.words.length || !exportSnapshot.generatedAt) throw new Error('学习报表 PDF 导出快照与详情不一致');
+  }
+
   const denied = await call('xiaoshu/admin/gateway', token, { operation: 'catalog' }, 403);
   if (denied.success !== false) throw new Error('普通运营管理员仍可访问技术数据目录');
   console.log(`Operations smoke passed: users=${users.total}, newestMember=${users.items[0]?.displayName || 'none'}@${users.items[0]?.registeredAt || 'none'}, coaches=${coaches.total}, activeCoaches=${activeCoaches.total}, monthActiveCoaches=${coaches.summary.monthActive}, dashboardCoaches=${dashboardCoachMetric.value}, liveCoachAppointments=${coaches.liveSourceCount}, appointments=${schedule.totalAppointments}, latest=${latestSchedule.appointments.length}, vocabulary=${vocabulary.total}, specialTraining=${specialTraining.total}, identityConflicts=${identityConflicts.total}, conflictNames=${identityConflicts.items.map((item) => item.displayName).join('|') || 'none'}, metrics=${dashboard.metrics.length}, learning=${JSON.stringify(learningCounts)}.`);

Some files were not shown because too many files changed in this diff