Explorar o código

perf(admin): defer export libraries until needed

彭峰 hai 1 semana
pai
achega
2d30cb825d

+ 8 - 0
perf/BRIEF.md

@@ -0,0 +1,8 @@
+# 管理后台提速简报
+
+- 范围:小树陪练 Angular 管理后台的常用运营页面,先按构建依赖与可重复加载实验选择优先级。
+- 选择:会员管理、学习报表的首次打开;工资结算和词库管理沿用相同的按需加载修复。
+- 真正可用:模拟接口返回后,会员空列表或学习报表空列表已经渲染,页面可继续筛选与操作。仅显示页面标题不计入。
+- 目标:默认争取把上述操作的 p75 耗时减半;达不到时如实报告已验证的收益。
+- 约束:不改云函数、数据库、登录协议、可见布局、导入导出内容或第三方统计;现有迁移资料不参与本次提交。
+- 发布:按仓库 `AGENTS.md` 的既有要求,完成检查后只提交并推送本次文件;线上部署和真实账号验收单独记录。

+ 167 - 0
perf/bench/admin-frontend.py

@@ -0,0 +1,167 @@
+#!/usr/bin/env python3
+"""Compare cold admin member-list loads with a fixed, local API response.
+
+Requires Python Playwright and its Chromium browser. No real account or backend
+is contacted. Run with --baseline DIST/browser [--candidate DIST/browser].
+"""
+
+import argparse
+import asyncio
+import json
+import statistics
+import sys
+import threading
+from functools import partial
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from urllib.parse import urlsplit
+
+from playwright.async_api import async_playwright
+
+
+MEMBERS = {
+    "page": 1,
+    "pageSize": 20,
+    "total": 0,
+    "items": [],
+    "agents": [],
+    "summary": {"total": 0, "todayNew": 0, "sevenDayNew": 0, "studying": 0, "followUp": 0},
+    "refreshedAt": "2026-09-26T00:00:00.000Z",
+}
+REPORTS = {
+    "page": 1,
+    "pageSize": 20,
+    "total": 0,
+    "items": [],
+    "summary": {"reportCount": 0, "studentCount": 0, "learnedCount": 0, "masteredCount": 0, "reviewDueCount": 0, "masteryRate": 0},
+    "sourceStatus": {"state": "synced", "label": "已同步", "message": "本地模拟数据", "lastSuccessAt": "", "lagSeconds": 0},
+}
+SESSION = {
+    "objectId": "perf-test",
+    "username": "perf-test",
+    "sessionToken": "local-perf-test",
+    "displayName": "测速账号",
+    "roles": [],
+    "operationsRole": "ops-manager",
+    "isSuperAdmin": False,
+}
+
+
+class AppHandler(SimpleHTTPRequestHandler):
+    def do_GET(self):
+        path = Path(self.directory) / urlsplit(self.path).path.lstrip("/")
+        if not path.is_file():
+            self.path = "/index.html"
+        return super().do_GET()
+
+    def log_message(self, *_args):
+        pass
+
+
+def serve(directory):
+    server = ThreadingHTTPServer(("127.0.0.1", 0), partial(AppHandler, directory=str(directory)))
+    threading.Thread(target=server.serve_forever, daemon=True).start()
+    return server
+
+
+def percentile(values, percentage):
+    return round(statistics.quantiles(sorted(values), n=100, method="inclusive")[percentage - 1], 1)
+
+
+async def measure(browser, base_url, page_name):
+    context = await browser.new_context(viewport={"width": 1440, "height": 900})
+    await context.add_init_script(
+        "sessionStorage.setItem('xiaoshu_admin_session_v1', " + json.dumps(json.dumps(SESSION)) + ");"
+    )
+    page = await context.new_page()
+    cdp = await context.new_cdp_session(page)
+    await cdp.send("Network.enable")
+    await cdp.send("Network.setCacheDisabled", {"cacheDisabled": True})
+    await cdp.send("Network.emulateNetworkConditions", {
+        "offline": False, "latency": 20,
+        "downloadThroughput": 4 * 1024 * 1024 / 8,
+        "uploadThroughput": 3 * 1024 * 1024 / 8,
+    })
+
+    async def mock_gateway(route):
+        await asyncio.sleep(0.15)
+        payload = REPORTS if page_name == "learning-reports" else MEMBERS
+        await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": True, "data": payload}))
+
+    await page.route("**/cloud-functions/**", mock_gateway)
+    try:
+        await page.goto(base_url + "/admin/" + page_name, wait_until="commit")
+        ready_text = "当前筛选范围暂无学习报表" if page_name == "learning-reports" else "没有符合条件的会员"
+        await page.get_by_text(ready_text, exact=True).first.wait_for(timeout=30000)
+        result = await page.evaluate("""() => {
+          const js = performance.getEntriesByType('resource').filter(item => item.name.endsWith('.js'));
+          return {
+            readyMs: performance.now(),
+            jsRequests: js.length,
+            jsTransferBytes: js.reduce((total, item) => total + item.transferSize, 0),
+            jsNames: js.map(item => new URL(item.name).pathname.split('/').pop())
+          };
+        }""")
+        return result
+    finally:
+        await context.close()
+
+
+async def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--baseline", type=Path, required=True)
+    parser.add_argument("--candidate", type=Path)
+    parser.add_argument("--page", choices=["members", "learning-reports"], default="members")
+    parser.add_argument("--runs", type=int, default=10)
+    parser.add_argument("--out", type=Path)
+    parser.add_argument("--max-js-bytes", type=int, help="Fail if the candidate loads more JavaScript bytes")
+    parser.add_argument("--ceilings", type=Path, help="JSON map from page name to JavaScript byte ceiling")
+    args = parser.parse_args()
+    if args.runs < 2:
+        parser.error("--runs must be at least 2")
+    roots = {"A": args.baseline}
+    if args.candidate:
+        roots["B"] = args.candidate
+    servers = {name: serve(root) for name, root in roots.items()}
+    urls = {name: f"http://127.0.0.1:{server.server_port}" for name, server in servers.items()}
+    runs = {name: [] for name in roots}
+    try:
+        async with async_playwright() as playwright:
+            browser = await playwright.chromium.launch()
+            try:
+                for index in range(args.runs):
+                    for name in (list(roots) if index % 2 == 0 else list(reversed(roots))):
+                        runs[name].append(await measure(browser, urls[name], args.page))
+            finally:
+                await browser.close()
+    finally:
+        for server in servers.values():
+            server.shutdown()
+    summary = {
+        name: {
+            "n": len(items),
+            "readyP50Ms": percentile([item["readyMs"] for item in items], 50),
+            "readyP75Ms": percentile([item["readyMs"] for item in items], 75),
+            "jsRequests": max(item["jsRequests"] for item in items),
+            "jsTransferBytes": max(item["jsTransferBytes"] for item in items),
+            "jsNames": items[-1]["jsNames"],
+            "runs": items,
+        }
+        for name, items in runs.items()
+    }
+    print(json.dumps({name: {key: value for key, value in item.items() if key not in ("runs", "jsNames")} for name, item in summary.items()}, ensure_ascii=False, indent=2))
+    if args.out:
+        args.out.parent.mkdir(parents=True, exist_ok=True)
+        args.out.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
+    ceiling = args.max_js_bytes
+    if args.ceilings:
+        ceiling = json.loads(args.ceilings.read_text(encoding="utf-8"))[args.page]
+    if ceiling is not None:
+        measured = summary["B" if args.candidate else "A"]["jsTransferBytes"]
+        if measured > ceiling:
+            print(f"JavaScript transfer ceiling exceeded: {measured} > {ceiling}", file=sys.stderr)
+            raise SystemExit(1)
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 4 - 0
perf/bench/ceilings.json

@@ -0,0 +1,4 @@
+{
+  "members": 500000,
+  "learning-reports": 500000
+}

+ 14 - 0
perf/基线.md

@@ -0,0 +1,14 @@
+# 管理后台前端基线
+
+基线源代码:`e411817315943daeaaf91fa9f44f17ae97abdae8`。生产构建命令:`npm run build:admin -- --stats-json`。
+
+本地实验在同一台电脑上用生产构建、冷缓存、1440×900、Fast 4G 模拟、固定 150 ms 模拟接口完成。旧版与新版交替各测 10 次,报告 p75。静态服务器不压缩资源,所以 JS 字节数是本地原始传输量,不代表线上 CDN 的压缩传输量。使用模拟会话和模拟接口;没有访问真实账户或真实生产接口。
+
+| 页面 | 旧版真正可用 p75 | 旧版首次 JS 请求 | 旧版首次 JS 字节 |
+| --- | ---: | ---: | ---: |
+| 会员管理 | 2386.3 ms | 19 | 908831 |
+| 学习报表 | 2886.8 ms | 18 | 1169844 |
+
+构建 `stats.json` 指出会员页面会引入 `xlsx/xlsx.mjs` 约 429 KB;学习报表页面在列表阶段引入 PDF 生成相关代码。它们都不参与首次显示列表,是此次选中的稳定代理指标。后台初始包约 350.65 KB,两个页面按路由懒加载,但路由内部仍含上述导出依赖。
+
+测量脚本:`perf/bench/admin-frontend.py`。它在本机启动旧、新两套静态服务,固定接口响应,并记录列表真正出现的时间。实验数据暂存 `/tmp/xiaoshu-admin-perf-final-members.json` 和 `/tmp/xiaoshu-admin-perf-pair-reports.json`,不作为仓库源文件提交。

+ 16 - 0
perf/实验日志.md

@@ -0,0 +1,16 @@
+# 实验日志
+
+## 1. 会员管理 Excel 按需加载
+
+- 假设:列表首次显示不需要 `xlsx`;把库延后至下载模板或读取导入文件,首次 JS 至少减少约 429 KB,真实列表 p75 随之下降。
+- 改动:`operations-users.component.ts` 的两处 Excel 操作改用动态导入,并保留错误反馈。
+- 配对结果:首次 JS 908831 → 479783 字节;真正可用 p75 2386.3 → 1885.5 ms,下降 21.0%。保留。
+
+## 2. 其他导出库按需加载
+
+- 假设:学习报表列表不需要 PDF 引擎;工资结算、词库和抗遗忘工资列表不需要 Excel 引擎。
+- 改动:只在相应下载、导入或导出操作中加载 `html2pdf.js` 或 `xlsx`。
+- 学习报表配对结果:首次 JS 1169844 → 477352 字节;真正可用 p75 2886.8 → 1883.2 ms,下降 34.8%。保留。
+- 工资结算和词库的前端导入链已改变,但没有单独取得真实可用耗时,不给它们填写耗时降幅。
+
+两次实验都符合“代理指标下降、列表真正可用耗时也下降”。耗时受浏览器与测量机负载影响,JS 字节阈值用作回退检查,p75 仅用于本次配对结论。

+ 19 - 0
perf/报告.md

@@ -0,0 +1,19 @@
+# 管理后台提速报告
+
+| 页面 | 旧版 p75 | 新版 p75 | 提升 | 首次 JS 原始传输量 |
+| --- | ---: | ---: | ---: | ---: |
+| 会员管理 | 2386.3 ms | 1885.5 ms | 21.0% | 908831 → 479783 字节 |
+| 学习报表 | 2886.8 ms | 1883.2 ms | 34.8% | 1169844 → 477352 字节 |
+
+两个页面均未达到简报里默认的减半目标。当前主要收益来自首次打开时不再加载 Excel/PDF 引擎;后续要继续提速,应测真实账号下的接口耗时、请求瀑布和列表渲染,再决定是否处理后台数据查询或其他前端瓶颈。
+
+验证:生产构建成功;后台 221 个单元测试通过;模拟接口下 Excel 模板和 PDF 实际下载成功;会员管理和学习报表在 1440 与 390 像素宽度下,新旧版截图完全一致。固定本地接口的配对测速各 10 轮。`perf/bench/ceilings.json` 将两页首次 JS 原始传输量上限定为 500000 字节,可用 `admin-frontend.py --ceilings` 检查回退。
+
+复测命令(需 Python Playwright 与 Chromium;`BASELINE_BROWSER` 指向基线提交的 `dist/xiaoshu-admin/browser`):
+
+```bash
+python3 perf/bench/admin-frontend.py --baseline "$BASELINE_BROWSER" --candidate dist/xiaoshu-admin/browser --page members --runs 10 --ceilings perf/bench/ceilings.json
+python3 perf/bench/admin-frontend.py --baseline "$BASELINE_BROWSER" --candidate dist/xiaoshu-admin/browser --page learning-reports --runs 10 --ceilings perf/bench/ceilings.json
+```
+
+本报告的时间和字节来自本地模拟条件。线上 CDN 压缩、真实接口响应、真实管理员数据量和实际设备体验尚未验收;推送代码也不等于确认线上部署。若需回滚,回退本次动态导入改动即可恢复原加载行为。

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

@@ -3,7 +3,6 @@ import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, RouterLink } from '@angular/router';
-import html2pdf from 'html2pdf.js';
 import { CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Download, Eye, FileChartColumn, LoaderCircle, LucideAngularModule, RefreshCw, RotateCcw, Search, X } from 'lucide-angular';
 import { LearningReportDetail, LearningReportListItem, LearningReportPage, LearningReportWord } from '../admin.models';
 import { OperationsService } from '../operations.service';
@@ -246,6 +245,7 @@ export class OperationsLearningReportsComponent implements OnInit, OnDestroy {
       await document.fonts?.ready;
       const element = this.pdfContent?.nativeElement;
       if (!element) throw new Error('报表打印区域尚未准备完成');
+      const { default: html2pdf } = await import('html2pdf.js');
       const safeName = `${report.studentName}-${report.studyDate}-${report.generalId}`.replace(/[^a-zA-Z0-9一-龥_-]/g, '_');
       let hasBlankTrailingPage = false;
       const worker = html2pdf().set({

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

@@ -6,7 +6,6 @@ import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, HostListener, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { Banknote, Brain, CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Download, Eye, FileCheck2, LucideAngularModule, Plus, RefreshCw, Search, Settings2, TrendingUp, X } from 'lucide-angular';
-import { utils, writeFile } from 'xlsx';
 import { CourseTeachingMapping, CreditReservationReconciliation, OperationsPage, PayRate, PayrollCoachSummary, PayrollDetail, PayrollLine, PayrollOverview, PayrollSummary, TeachingCourseCategory, TeachingCreditAccount, TeachingRule, TeachingStageKey, TeachingStandardsData } from '../admin.models';
 import { AdminSessionService } from '../admin-session.service';
 import { OperationsDateFilterComponent } from '../components/operations-date-filter.component';
@@ -95,6 +94,6 @@ export class OperationsPayrollComponent implements OnInit,OnDestroy{
  async excludeLine(objectId:string):Promise<void>{const batch=this.detail(),reason=this.adjustmentReason().trim();if(!batch||batch.status!=='draft')return;if(!reason){this.error.set('请先在调整原因中填写排除该异常记录的原因');return;}this.saving.set(true);this.error.set('');try{await this.payrollCommand('ops/payroll/exclude-line',objectId,reason,{},'');await this.open(batch);this.message.set('异常记录已排除并保留审计原因');}catch(error){this.error.set(error instanceof Error?error.message:'异常记录排除失败');}finally{this.saving.set(false);}}
  async review():Promise<void>{if(this.saving())return;const batch=this.detail();if(!batch)return;this.saving.set(true);this.error.set('');try{await this.payrollCommand('ops/payroll/review',batch.objectId,'实时刷新完成,复核工资明细并永久锁定',{},batch.updatedAt);await this.load();await this.open(batch);this.message.set('工资单已审核锁定');}catch(error){this.error.set(error instanceof Error?error.message:'工资审核失败');}finally{this.saving.set(false);}}
  async markPaid():Promise<void>{if(this.saving())return;const batch=this.detail();if(!batch||!this.paymentMethod().trim()||!this.paymentReference().trim()){this.error.set('请填写发放方式和凭证编号');return;}this.saving.set(true);this.error.set('');try{await this.payrollCommand('ops/payroll/mark-paid',batch.objectId,'确认工资已线下发放',{paymentMethod:this.paymentMethod(),paymentReference:this.paymentReference()},batch.updatedAt);await this.load();await this.open(batch);this.message.set('工资发放信息已登记');}catch(error){this.error.set(error instanceof Error?error.message:'发放登记失败');}finally{this.saving.set(false);}}
- async exportExcel():Promise<void>{this.saving.set(true);this.error.set('');try{const batch=this.detail(),data=await this.operations.payrollExport(this.month(),batch?.objectId||'',this.storeId(),this.search(),this.selectedCoachId());const summary=data.coachItems.map((row)=>({'统计口径':batch?'工资批次结算':'当月收入(不含批次调整)','陪练老师':this.coachLabel(row.coachName,row.coachId),'门店':row.storeName,'30分钟课':row.type30Count,'60分钟课':row.type60Count,'体验课':row.trialCount,'课型4':row.type4Count,'总课次':row.lessonCount,'工时':row.totalHours,'基础工资':row.baseAmount,'有效复习次数':row.reviewCount||0,'抗遗忘工资':row.reviewAmount||0,'调整':row.adjustmentAmount,'应发金额':row.payableAmount}));const details=data.lines.map((line)=>this.exportLine(line)),exceptions=data.exceptions.map((line)=>({...this.exportLine(line),'异常原因':line.exception}));const book=utils.book_new();utils.book_append_sheet(book,utils.json_to_sheet(summary),'老师汇总');utils.book_append_sheet(book,utils.json_to_sheet(details),'计薪明细');utils.book_append_sheet(book,utils.json_to_sheet(exceptions),'异常记录');utils.book_append_sheet(book,utils.json_to_sheet((data.excluded||[]).map(line=>({...this.exportLine(line),'排除原因':line['exclusionReason']}))),'已排除记录');utils.book_append_sheet(book,utils.json_to_sheet([{'课酬':data.summary.baseAmount,'抗遗忘工资':data.summary.reviewAmount||0,'人工调整':data.summary.adjustmentAmount,'应发合计':data.summary.payableAmount}]),'金额核对');writeFile(book,data.fileName);}catch(error){this.error.set(error instanceof Error?error.message:'工资 Excel 导出失败');}finally{this.saving.set(false);}}
+ async exportExcel():Promise<void>{this.saving.set(true);this.error.set('');try{const batch=this.detail(),data=await this.operations.payrollExport(this.month(),batch?.objectId||'',this.storeId(),this.search(),this.selectedCoachId());const {utils,writeFile}=await import('xlsx');const summary=data.coachItems.map((row)=>({'统计口径':batch?'工资批次结算':'当月收入(不含批次调整)','陪练老师':this.coachLabel(row.coachName,row.coachId),'门店':row.storeName,'30分钟课':row.type30Count,'60分钟课':row.type60Count,'体验课':row.trialCount,'课型4':row.type4Count,'总课次':row.lessonCount,'工时':row.totalHours,'基础工资':row.baseAmount,'有效复习次数':row.reviewCount||0,'抗遗忘工资':row.reviewAmount||0,'调整':row.adjustmentAmount,'应发金额':row.payableAmount}));const details=data.lines.map((line)=>this.exportLine(line)),exceptions=data.exceptions.map((line)=>({...this.exportLine(line),'异常原因':line.exception}));const book=utils.book_new();utils.book_append_sheet(book,utils.json_to_sheet(summary),'老师汇总');utils.book_append_sheet(book,utils.json_to_sheet(details),'计薪明细');utils.book_append_sheet(book,utils.json_to_sheet(exceptions),'异常记录');utils.book_append_sheet(book,utils.json_to_sheet((data.excluded||[]).map(line=>({...this.exportLine(line),'排除原因':line['exclusionReason']}))),'已排除记录');utils.book_append_sheet(book,utils.json_to_sheet([{'课酬':data.summary.baseAmount,'抗遗忘工资':data.summary.reviewAmount||0,'人工调整':data.summary.adjustmentAmount,'应发合计':data.summary.payableAmount}]),'金额核对');writeFile(book,data.fileName);}catch(error){this.error.set(error instanceof Error?error.message:'工资 Excel 导出失败');}finally{this.saving.set(false);}}
  private exportLine(line:PayrollLine):Record<string,unknown>{return{'明细类型':line.kind==='review_reward'?'抗遗忘工资':line.kind==='adjustment'?'人工调整':'课酬','计薪凭据':line.reviewRewardId||'','复习任务':line.reviewTaskId||'','计划复习日期':line.plannedDate||'','来源记录ID':line.legacyGeneralId,'上课日期':line.lessonAt?new Date(line.lessonAt).toLocaleString('sv-SE',{timeZone:'Asia/Shanghai'}):'','结算月份':line.settlementMonth,'原上课月份':line.originalMonth,'陪练老师':this.coachLabel(line.coachName,line.coachId),'门店':line.storeName,'学员':line.studentName,'课程':line.courseName,'课程分类':line.kind==='review_reward'?'抗遗忘工资':line.courseCategoryName||line.classTypeLabel,'授课方式':line.deliveryLabel||'未记录','时长':line.kind==='review_reward'?'—':line.durationMinutes?`${line.durationMinutes}分钟`:line.classTypeLabel,'工时':line.durationHours,'单价':line.rateAmount,'金额':line.amount,'说明':line.description};}
 }

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

@@ -4,7 +4,6 @@ import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, computed, inject
 import { FormsModule } from '@angular/forms';
 import { Router, RouterLink } from '@angular/router';
 import { ChevronLeft, ChevronRight, Download, Eye, FileSpreadsheet, LoaderCircle, LucideAngularModule, Plus, RefreshCw, Search, Upload, UserPlus, Users, X } from 'lucide-angular';
-import { read, utils, writeFile } from 'xlsx';
 import { MemberAgentOption, MemberImportPreview, MemberListSort, MemberPage } from '../admin.models';
 import { AdminSessionService } from '../admin-session.service';
 import { OperationsDateFilterComponent } from '../components/operations-date-filter.component';
@@ -70,13 +69,18 @@ export class OperationsUsersComponent implements OnInit, OnDestroy {
     this.importOpen.set(false); this.importFileName.set(''); this.importRows.set([]); this.importPreview.set(null); this.importIdempotencyKey.set('');
   }
 
-  downloadImportTemplate(): void {
-    const sheet = utils.aoa_to_sheet([['姓名', '昵称', '手机号', '性别', '年级', '邮箱', '门店ID', '所属门店', '临时密码']]);
-    sheet['!cols'] = [{ wch: 14 }, { wch: 14 }, { wch: 20 }, { wch: 10 }, { wch: 14 }, { wch: 22 }, { wch: 20 }, { wch: 22 }, { wch: 18 }];
-    const instructions = utils.aoa_to_sheet([
-      ['字段', '填写要求'], ['姓名', '必填,最长 100 字'], ['昵称', '可选,不填时使用姓名'], ['手机号', '必填,11 位;同时作为登录账号'], ['性别', '可选:男、女或其他'], ['年级', '可选,最长 50 字'], ['邮箱', '可选,需为有效邮箱'], ['门店ID / 所属门店', '二选一;名称重复时必须填写门店 ID'], ['临时密码', '必填,8 至 72 位;预检批次与日志均不保存明文'], ['单次上限', '200 人;请勿改动“会员导入”工作表的表头'],
-    ]); instructions['!cols'] = [{ wch: 24 }, { wch: 58 }];
-    const workbook = utils.book_new(); utils.book_append_sheet(workbook, sheet, '会员导入'); utils.book_append_sheet(workbook, instructions, '填写说明'); writeFile(workbook, '小树陪练会员导入模板.xlsx');
+  async downloadImportTemplate(): Promise<void> {
+    try {
+      const { utils, writeFile } = await import('xlsx');
+      const sheet = utils.aoa_to_sheet([['姓名', '昵称', '手机号', '性别', '年级', '邮箱', '门店ID', '所属门店', '临时密码']]);
+      sheet['!cols'] = [{ wch: 14 }, { wch: 14 }, { wch: 20 }, { wch: 10 }, { wch: 14 }, { wch: 22 }, { wch: 20 }, { wch: 22 }, { wch: 18 }];
+      const instructions = utils.aoa_to_sheet([
+        ['字段', '填写要求'], ['姓名', '必填,最长 100 字'], ['昵称', '可选,不填时使用姓名'], ['手机号', '必填,11 位;同时作为登录账号'], ['性别', '可选:男、女或其他'], ['年级', '可选,最长 50 字'], ['邮箱', '可选,需为有效邮箱'], ['门店ID / 所属门店', '二选一;名称重复时必须填写门店 ID'], ['临时密码', '必填,8 至 72 位;预检批次与日志均不保存明文'], ['单次上限', '200 人;请勿改动“会员导入”工作表的表头'],
+      ]); instructions['!cols'] = [{ wch: 24 }, { wch: 58 }];
+      const workbook = utils.book_new(); utils.book_append_sheet(workbook, sheet, '会员导入'); utils.book_append_sheet(workbook, instructions, '填写说明'); writeFile(workbook, '小树陪练会员导入模板.xlsx');
+    } catch (error) {
+      this.error.set(error instanceof Error ? error.message : '会员导入模板下载失败');
+    }
   }
 
   async selectImportFile(event: Event): Promise<void> {
@@ -86,6 +90,7 @@ export class OperationsUsersComponent implements OnInit, OnDestroy {
     if (!/\.xlsx$/i.test(file.name)) { this.error.set('请选择 .xlsx 格式的会员导入文件'); return; }
     if (file.size > 5 * 1024 * 1024) { this.error.set('会员导入文件不能超过 5 MB'); return; }
     try {
+      const { read, utils } = await import('xlsx');
       const workbook = read(await file.arrayBuffer(), { type: 'array', cellDates: false }); const firstSheet = workbook.SheetNames[0];
       if (!firstSheet) throw new Error('Excel 中没有可读取的工作表');
       const rows = utils.sheet_to_json<Record<string, unknown>>(workbook.Sheets[firstSheet], { defval: '', raw: false });

+ 3 - 4
projects/xiaoshu-admin/src/app/pages/operations-vocabulary.component.ts

@@ -3,7 +3,6 @@ import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { ChevronLeft, ChevronRight, Download, FileSpreadsheet, FolderPlus, Headphones, Languages, LucideAngularModule, Pencil, Plus, RefreshCw, RotateCcw, Search, Trash2, Upload, X } from 'lucide-angular';
-import { read, utils, writeFile } from 'xlsx';
 import { LEGACY_ASSET_BASE_URL } from '../admin.constants';
 import { OperationsPage, VocabularyImportPreview, VocabularyNode, VocabularyWord } from '../admin.models';
 import { OperationsService } from '../operations.service';
@@ -127,13 +126,13 @@ export class OperationsVocabularyComponent implements OnInit {
   async saveNode():Promise<void>{const draft=this.nodeDraft();if(!draft.name.trim()||!this.reason().trim()){this.error.set('分类名称和操作原因必填');return;}this.saving.set(true);try{await this.operations.mutate('ops/vocabulary/node-save',draft.targetId,this.reason(),draft,draft.updatedAt);this.nodeOpen.set(false);await this.initialLoad();this.message.set('词库分类已保存');}catch(error){this.error.set(this.errorText(error,'分类保存失败'));}finally{this.saving.set(false);}}
   async chooseWorkbook(event: Event): Promise<void> {
     const file=(event.target as HTMLInputElement).files?.[0]; if(!file)return;
-    try { const workbook=read(await file.arrayBuffer(),{type:'array',cellDates:true}); const sheet=workbook.Sheets[workbook.SheetNames[0]]; const rows=utils.sheet_to_json<Record<string,unknown>>(sheet,{defval:''}); if(!rows.length)throw new Error('Excel 没有可导入的数据'); this.importFileName.set(file.name);this.importRows.set(rows);this.preview.set(null);this.importOpen.set(true);this.reason.set('导入词库'); }
+    try { const {read,utils}=await import('xlsx');const workbook=read(await file.arrayBuffer(),{type:'array',cellDates:true}); const sheet=workbook.Sheets[workbook.SheetNames[0]]; const rows=utils.sheet_to_json<Record<string,unknown>>(sheet,{defval:''}); if(!rows.length)throw new Error('Excel 没有可导入的数据'); this.importFileName.set(file.name);this.importRows.set(rows);this.preview.set(null);this.importOpen.set(true);this.reason.set('导入词库'); }
     catch(error){this.error.set(this.errorText(error,'Excel 读取失败'));} finally{(event.target as HTMLInputElement).value='';}
   }
   async previewImport(): Promise<void> { if(!this.reason().trim()){this.error.set('请填写导入原因');return;}this.saving.set(true);try{this.preview.set(await this.operations.previewVocabularyImport(this.importFileName(),this.importRows(),this.reason()));}catch(error){this.error.set(this.errorText(error,'导入预检失败'));}finally{this.saving.set(false);} }
   async commitImport(): Promise<void> {const preview=this.preview();if(!preview||preview.summary.error){this.error.set('请先解决全部阻断错误');return;}this.saving.set(true);try{await this.operations.commitVocabularyImport(preview.batchId,this.reason());this.importOpen.set(false);this.message.set(`导入完成:新增 ${preview.summary.create},更新 ${preview.summary.update}`);await this.initialLoad();}catch(error){this.error.set(this.errorText(error,'导入发布失败'));}finally{this.saving.set(false);}}
-  downloadTemplate():void{const sheet=utils.aoa_to_sheet([['GeneralID(可选)','学段','教材','单元','单词','释义','音标','例句','音频文件名或URL'],['','小学','示例教材','第一单元','example','例子','/ɪɡˈzɑːmpəl/','This is an example.','']]);const book=utils.book_new();utils.book_append_sheet(book,sheet,'词库导入');writeFile(book,'小树陪练词库导入模板.xlsx');}
-  exportCurrent():void{const rows=(this.page()?.items||[]).map((row)=>({'GeneralID':row.generalId,'学段':row.stageName,'教材':row.bookName,'单元':row.unitName,'单词':row.word,'释义':row.meaning,'音标':row.phonetic,'例句':row.example,'音频URL':row.audioUrl,'状态':row.statusLabel}));const book=utils.book_new();utils.book_append_sheet(book,utils.json_to_sheet(rows),'词库');writeFile(book,`词库_${this.selectedNode()?.name||'筛选结果'}.xlsx`);}
+  async downloadTemplate():Promise<void>{try{const {utils,writeFile}=await import('xlsx');const sheet=utils.aoa_to_sheet([['GeneralID(可选)','学段','教材','单元','单词','释义','音标','例句','音频文件名或URL'],['','小学','示例教材','第一单元','example','例子','/ɪɡˈzɑːmpəl/','This is an example.','']]);const book=utils.book_new();utils.book_append_sheet(book,sheet,'词库导入');writeFile(book,'小树陪练词库导入模板.xlsx');}catch(error){this.error.set(this.errorText(error,'模板下载失败'));}}
+  async exportCurrent():Promise<void>{try{const {utils,writeFile}=await import('xlsx');const rows=(this.page()?.items||[]).map((row)=>({'GeneralID':row.generalId,'学段':row.stageName,'教材':row.bookName,'单元':row.unitName,'单词':row.word,'释义':row.meaning,'音标':row.phonetic,'例句':row.example,'音频URL':row.audioUrl,'状态':row.statusLabel}));const book=utils.book_new();utils.book_append_sheet(book,utils.json_to_sheet(rows),'词库');writeFile(book,`词库_${this.selectedNode()?.name||'筛选结果'}.xlsx`);}catch(error){this.error.set(this.errorText(error,'词库导出失败'));}}
   depthStyle(node:VocabularyNode):Record<string,string>{return{'--depth':String(Math.max(0,Number(node['displayDepth']||0)))};}
   importErrors(item:Record<string,unknown>):string{return Array.isArray(item['errors'])?item['errors'].map(String).join('、'):'';}
   private emptyDraft():WordDraft{return{targetId:'',word:'',meaning:'',phonetic:'',example:'',audioUrl:'',nodeId:0,orderId:0,status:0,updatedAt:''};}

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

@@ -4,7 +4,6 @@ import { ChangeDetectionStrategy, Component, Input, OnChanges, inject, signal }
 import { FormsModule } from '@angular/forms';
 import { ReviewRewardFilters, ReviewRewardPage } from '../admin.models';
 import { OperationsService } from '../operations.service';
-import { utils, writeFile } from 'xlsx';
 
 @Component({selector:'app-review-rewards',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule],templateUrl:'./review-rewards.component.html',styleUrls:['./operations-professional.scss','./review-rewards.component.scss'],changeDetection:ChangeDetectionStrategy.OnPush})
 export class ReviewRewardsComponent implements OnChanges {
@@ -29,7 +28,7 @@ export class ReviewRewardsComponent implements OnChanges {
         rows.push(...data.items.map(row=>({'计薪凭据':row.objectId,'复习任务':row.taskId,'老师':row.coachName,'门店':row.storeName,'学生':row.studentName,'课程':row.courseName,'计划日期':row.plannedDate,'首次完成时间':row.completedAt?new Date(row.completedAt).toLocaleString('sv-SE',{timeZone:'Asia/Shanghai'}):'','收入月份':row.incomeMonth,'工资金额':row.amount,'结算状态':row.statusLabel,'结算月份':row.settlementMonth||'','异常原因':row.exception})));
         filters.afterId=data.nextCursor||'';if(!filters.afterId)break;page++;
       }while(filters.afterId);if(rows.length!==total)throw new Error('导出记录不完整,请重新导出');
-      const book=utils.book_new();utils.book_append_sheet(book,utils.json_to_sheet(rows),'抗遗忘工资');writeFile(book,`抗遗忘工资_${filters.month}.xlsx`);
+      const {utils,writeFile}=await import('xlsx');const book=utils.book_new();utils.book_append_sheet(book,utils.json_to_sheet(rows),'抗遗忘工资');writeFile(book,`抗遗忘工资_${filters.month}.xlsx`);
     }catch(error){this.error.set(error instanceof Error?error.message:'导出失败');}finally{this.exporting.set(false);}
   }
 }