Эх сурвалжийг харах

perf(admin): warm operations routes and parallelize vocabulary load

彭峰 1 өдөр өмнө
parent
commit
cd264e0b12

+ 7 - 0
perf/BRIEF.md

@@ -6,3 +6,10 @@
 - 目标:默认争取把上述操作的 p75 耗时减半;达不到时如实报告已验证的收益。
 - 目标:默认争取把上述操作的 p75 耗时减半;达不到时如实报告已验证的收益。
 - 约束:不改云函数、数据库、登录协议、可见布局、导入导出内容或第三方统计;现有迁移资料不参与本次提交。
 - 约束:不改云函数、数据库、登录协议、可见布局、导入导出内容或第三方统计;现有迁移资料不参与本次提交。
 - 发布:按仓库 `AGENTS.md` 的既有要求,完成检查后只提交并推送本次文件;线上部署和真实账号验收单独记录。
 - 发布:按仓库 `AGENTS.md` 的既有要求,完成检查后只提交并推送本次文件;线上部署和真实账号验收单独记录。
+
+## 2026-09-26 扩展范围
+
+- 菜单:截图中的工作台、门店管理、排课中心、陪练老师、工资结算、词库管理、专项训练;会员管理和学习报表继续作为回退对照。
+- 当前切入任务:登录后从工作台首次点击一个运营菜单,目标页面标题已渲染。先测路由代码等待;它不代表业务数据已到齐。
+- 词库真正可用:目录总数和词条第一页总数均已显示,可查询和选择目录。
+- 约束:仅预加载运营菜单页面代码,等待当前页 3 秒;省流量和 2G 网络不预热;业务接口和 Excel/PDF 引擎继续按需加载。

+ 15 - 5
perf/bench/admin-frontend.py

@@ -1,5 +1,5 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
-"""Compare cold admin member-list loads with a fixed, local API response.
+"""Compare cold admin page loads with a fixed, local API response.
 
 
 Requires Python Playwright and its Chromium browser. No real account or backend
 Requires Python Playwright and its Chromium browser. No real account or backend
 is contacted. Run with --baseline DIST/browser [--candidate DIST/browser].
 is contacted. Run with --baseline DIST/browser [--candidate DIST/browser].
@@ -36,6 +36,8 @@ REPORTS = {
     "summary": {"reportCount": 0, "studentCount": 0, "learnedCount": 0, "masteredCount": 0, "reviewDueCount": 0, "masteryRate": 0},
     "summary": {"reportCount": 0, "studentCount": 0, "learnedCount": 0, "masteredCount": 0, "reviewDueCount": 0, "masteryRate": 0},
     "sourceStatus": {"state": "synced", "label": "已同步", "message": "本地模拟数据", "lastSuccessAt": "", "lagSeconds": 0},
     "sourceStatus": {"state": "synced", "label": "已同步", "message": "本地模拟数据", "lastSuccessAt": "", "lagSeconds": 0},
 }
 }
+VOCABULARY_TREE = {"items": [], "total": 0, "totalWords": 123, "refreshedAt": "2026-09-26T00:00:00.000Z"}
+VOCABULARY_WORDS = {"page": 1, "pageSize": 30, "total": 7, "items": [], "refreshedAt": "2026-09-26T00:00:00.000Z"}
 SESSION = {
 SESSION = {
     "objectId": "perf-test",
     "objectId": "perf-test",
     "username": "perf-test",
     "username": "perf-test",
@@ -85,14 +87,22 @@ async def measure(browser, base_url, page_name):
 
 
     async def mock_gateway(route):
     async def mock_gateway(route):
         await asyncio.sleep(0.15)
         await asyncio.sleep(0.15)
-        payload = REPORTS if page_name == "learning-reports" else MEMBERS
+        if page_name == "vocabulary":
+            operation = (route.request.post_data_json or {}).get("params", {}).get("operation")
+            payload = VOCABULARY_TREE if operation == "ops/vocabulary/tree" else VOCABULARY_WORDS
+        else:
+            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 route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": True, "data": payload}))
 
 
     await page.route("**/cloud-functions/**", mock_gateway)
     await page.route("**/cloud-functions/**", mock_gateway)
     try:
     try:
         await page.goto(base_url + "/admin/" + page_name, wait_until="commit")
         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)
+        if page_name == "vocabulary":
+            await page.get_by_text("完整管理 123 条词汇", exact=False).first.wait_for(timeout=30000)
+            await page.get_by_text("共 7 条符合条件的词汇", exact=False).first.wait_for(timeout=30000)
+        else:
+            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("""() => {
         result = await page.evaluate("""() => {
           const js = performance.getEntriesByType('resource').filter(item => item.name.endsWith('.js'));
           const js = performance.getEntriesByType('resource').filter(item => item.name.endsWith('.js'));
           return {
           return {
@@ -111,7 +121,7 @@ async def main():
     parser = argparse.ArgumentParser()
     parser = argparse.ArgumentParser()
     parser.add_argument("--baseline", type=Path, required=True)
     parser.add_argument("--baseline", type=Path, required=True)
     parser.add_argument("--candidate", type=Path)
     parser.add_argument("--candidate", type=Path)
-    parser.add_argument("--page", choices=["members", "learning-reports"], default="members")
+    parser.add_argument("--page", choices=["members", "learning-reports", "vocabulary"], default="members")
     parser.add_argument("--runs", type=int, default=10)
     parser.add_argument("--runs", type=int, default=10)
     parser.add_argument("--out", type=Path)
     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("--max-js-bytes", type=int, help="Fail if the candidate loads more JavaScript bytes")

+ 129 - 0
perf/bench/admin-menu-switch.py

@@ -0,0 +1,129 @@
+#!/usr/bin/env python3
+"""Measure first sidebar navigation to each operations module after the dashboard settles.
+
+Runs the production bundles against a local, fixed gateway stub. This measures
+route-code availability and first page heading; it is not a live API benchmark.
+"""
+
+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
+
+SESSION = {
+    "objectId": "perf-test", "username": "perf-test", "sessionToken": "local-perf-test",
+    "displayName": "测速账号", "roles": [], "operationsRole": "ops-manager", "isSuperAdmin": False,
+}
+MODULES = {
+    "stores": "门店管理", "schedule": "排课中心", "coaches": "陪练老师",
+    "payroll": "工资结算", "vocabulary": "词库管理", "special-training": "专项训练",
+}
+
+
+class AppHandler(SimpleHTTPRequestHandler):
+    def do_GET(self):
+        if not (Path(self.directory) / urlsplit(self.path).path.lstrip("/")).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 p75(values):
+    return round(statistics.quantiles(sorted(values), n=100, method="inclusive")[74], 1)
+
+
+async def measure(browser, base_url):
+    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": False})
+    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)
+        await route.fulfill(status=200, content_type="application/json", body=json.dumps({"success": True, "data": {}}))
+
+    await page.route("**/cloud-functions/**", mock_gateway)
+    try:
+        await page.goto(base_url + "/admin/dashboard", wait_until="commit")
+        await page.locator('#admin-sidebar a[href="/admin/stores"]').wait_for(timeout=30000)
+        await page.wait_for_timeout(4500)
+        results = {}
+        for route, title in MODULES.items():
+            before = await page.evaluate("performance.now()")
+            await page.locator(f'#admin-sidebar a[href="/admin/{route}"]').click()
+            await page.get_by_role("heading", name=title, exact=True).first.wait_for(timeout=30000)
+            after = await page.evaluate("performance.now()")
+            results[route] = round(after - before, 1)
+        return results
+    finally:
+        await context.close()
+
+
+async def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--baseline", type=Path, required=True)
+    parser.add_argument("--candidate", type=Path, required=True)
+    parser.add_argument("--runs", type=int, default=10)
+    parser.add_argument("--out", type=Path)
+    parser.add_argument("--ceilings", type=Path, help="JSON map of maximum candidate p75 milliseconds by route")
+    args = parser.parse_args()
+    servers = {"A": serve(args.baseline), "B": serve(args.candidate)}
+    urls = {name: f"http://127.0.0.1:{server.server_port}" for name, server in servers.items()}
+    runs = {name: {route: [] for route in MODULES} for name in servers}
+    try:
+        async with async_playwright() as playwright:
+            browser = await playwright.chromium.launch()
+            try:
+                for index in range(args.runs):
+                    for name in (("A", "B") if index % 2 == 0 else ("B", "A")):
+                        result = await measure(browser, urls[name])
+                        for route, value in result.items():
+                            runs[name][route].append(value)
+            finally:
+                await browser.close()
+    finally:
+        for server in servers.values():
+            server.shutdown()
+    summary = {route: {name: {"p75Ms": p75(runs[name][route]), "runs": runs[name][route]}
+                       for name in servers} for route in MODULES}
+    print(json.dumps({route: {name: entry["p75Ms"] for name, entry in results.items()}
+                      for route, results 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")
+    if args.ceilings:
+        ceilings = json.loads(args.ceilings.read_text(encoding="utf-8"))
+        failures = [f"{route}: {summary[route]['B']['p75Ms']} > {maximum} ms"
+                    for route, maximum in ceilings.items() if summary[route]["B"]["p75Ms"] > maximum]
+        if failures:
+            print("Menu-switch ceiling exceeded: " + "; ".join(failures), file=sys.stderr)
+            raise SystemExit(1)
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 2 - 1
perf/bench/ceilings.json

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

+ 538 - 0
perf/bench/learning-reports-regression-2026-09-26.json

@@ -0,0 +1,538 @@
+{
+  "A": {
+    "n": 10,
+    "readyP50Ms": 1402.2,
+    "readyP75Ms": 1877.7,
+    "jsRequests": 17,
+    "jsTransferBytes": 477352,
+    "jsNames": [
+      "chunk-BXO2KQCV.js",
+      "chunk-HCD3K6I6.js",
+      "chunk-P6RNKZIG.js",
+      "chunk-ELV3GC2M.js",
+      "chunk-ANGEQHAP.js",
+      "chunk-PR5A7Y5E.js",
+      "chunk-FK42CRUA.js",
+      "polyfills-FFHMD2TL.js",
+      "main-DEVX6XYG.js",
+      "chunk-WPYC6ITQ.js",
+      "chunk-AEGZAY4M.js",
+      "chunk-6GDE3FJ7.js",
+      "chunk-EJ73CB26.js",
+      "chunk-XCRYXN4C.js",
+      "chunk-DZBK56IP.js",
+      "chunk-Z5ZRSJBH.js",
+      "chunk-ZBKIO5UF.js"
+    ],
+    "runs": [
+      {
+        "readyMs": 1406.5,
+        "jsRequests": 17,
+        "jsTransferBytes": 477352,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-AEGZAY4M.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-XCRYXN4C.js",
+          "chunk-DZBK56IP.js",
+          "chunk-ZBKIO5UF.js",
+          "chunk-Z5ZRSJBH.js"
+        ]
+      },
+      {
+        "readyMs": 1393.300000000745,
+        "jsRequests": 17,
+        "jsTransferBytes": 477352,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-AEGZAY4M.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-XCRYXN4C.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      },
+      {
+        "readyMs": 1884,
+        "jsRequests": 17,
+        "jsTransferBytes": 477352,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-AEGZAY4M.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-XCRYXN4C.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      },
+      {
+        "readyMs": 1878.1000000014901,
+        "jsRequests": 17,
+        "jsTransferBytes": 477352,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-AEGZAY4M.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-XCRYXN4C.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      },
+      {
+        "readyMs": 1391.8999999985099,
+        "jsRequests": 17,
+        "jsTransferBytes": 477352,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-AEGZAY4M.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-XCRYXN4C.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      },
+      {
+        "readyMs": 1883.300000000745,
+        "jsRequests": 17,
+        "jsTransferBytes": 477352,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-FK42CRUA.js",
+          "chunk-PR5A7Y5E.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-AEGZAY4M.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-XCRYXN4C.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      },
+      {
+        "readyMs": 1394.7999999988824,
+        "jsRequests": 17,
+        "jsTransferBytes": 477352,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "main-DEVX6XYG.js",
+          "polyfills-FFHMD2TL.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-AEGZAY4M.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-XCRYXN4C.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      },
+      {
+        "readyMs": 1398,
+        "jsRequests": 17,
+        "jsTransferBytes": 477352,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-AEGZAY4M.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-XCRYXN4C.js",
+          "chunk-DZBK56IP.js",
+          "chunk-ZBKIO5UF.js",
+          "chunk-Z5ZRSJBH.js"
+        ]
+      },
+      {
+        "readyMs": 1387.5999999996275,
+        "jsRequests": 17,
+        "jsTransferBytes": 477352,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-AEGZAY4M.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-XCRYXN4C.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      },
+      {
+        "readyMs": 1876.6000000014901,
+        "jsRequests": 17,
+        "jsTransferBytes": 477352,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-AEGZAY4M.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-XCRYXN4C.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      }
+    ]
+  },
+  "B": {
+    "n": 10,
+    "readyP50Ms": 1880.3,
+    "readyP75Ms": 1881.5,
+    "jsRequests": 17,
+    "jsTransferBytes": 481771,
+    "jsNames": [
+      "chunk-CH2C3JLB.js",
+      "chunk-YFNQOSRZ.js",
+      "chunk-YCAZW72J.js",
+      "chunk-KCYBAJ7Z.js",
+      "chunk-GVSU4OGS.js",
+      "chunk-FK42CRUA.js",
+      "chunk-G6WG6RNH.js",
+      "polyfills-FFHMD2TL.js",
+      "main-EEGTEHHJ.js",
+      "chunk-TFN4UWMH.js",
+      "chunk-6OKQIL7K.js",
+      "chunk-GISPN563.js",
+      "chunk-ZR6WC7MO.js",
+      "chunk-FLQ7VSTF.js",
+      "chunk-KAMQPPBH.js",
+      "chunk-FIJEVPJP.js",
+      "chunk-BMZOMQA5.js"
+    ],
+    "runs": [
+      {
+        "readyMs": 1881.4000000003725,
+        "jsRequests": 17,
+        "jsTransferBytes": 481771,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-FK42CRUA.js",
+          "chunk-G6WG6RNH.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-6OKQIL7K.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-FLQ7VSTF.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      },
+      {
+        "readyMs": 1879.7999999988824,
+        "jsRequests": 17,
+        "jsTransferBytes": 481771,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-6OKQIL7K.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-FLQ7VSTF.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      },
+      {
+        "readyMs": 1883.4000000003725,
+        "jsRequests": 17,
+        "jsTransferBytes": 481771,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-6OKQIL7K.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-FLQ7VSTF.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      },
+      {
+        "readyMs": 1879,
+        "jsRequests": 17,
+        "jsTransferBytes": 481771,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-6OKQIL7K.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-FLQ7VSTF.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      },
+      {
+        "readyMs": 1881.5,
+        "jsRequests": 17,
+        "jsTransferBytes": 481771,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-6OKQIL7K.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-FLQ7VSTF.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      },
+      {
+        "readyMs": 1883.199999999255,
+        "jsRequests": 17,
+        "jsTransferBytes": 481771,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "main-EEGTEHHJ.js",
+          "polyfills-FFHMD2TL.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-6OKQIL7K.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-FLQ7VSTF.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      },
+      {
+        "readyMs": 1880.4000000003725,
+        "jsRequests": 17,
+        "jsTransferBytes": 481771,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-6OKQIL7K.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-FLQ7VSTF.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-BMZOMQA5.js",
+          "chunk-FIJEVPJP.js"
+        ]
+      },
+      {
+        "readyMs": 1878.5999999996275,
+        "jsRequests": 17,
+        "jsTransferBytes": 481771,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-6OKQIL7K.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-FLQ7VSTF.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      },
+      {
+        "readyMs": 1877.2999999988824,
+        "jsRequests": 17,
+        "jsTransferBytes": 481771,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-6OKQIL7K.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-FLQ7VSTF.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      },
+      {
+        "readyMs": 1880.199999999255,
+        "jsRequests": 17,
+        "jsTransferBytes": 481771,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-FK42CRUA.js",
+          "chunk-G6WG6RNH.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-6OKQIL7K.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-FLQ7VSTF.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      }
+    ]
+  }
+}

+ 560 - 0
perf/bench/members-regression-2026-09-26.json

@@ -0,0 +1,560 @@
+{
+  "A": {
+    "n": 10,
+    "readyP50Ms": 1883.4,
+    "readyP75Ms": 1891.1,
+    "jsRequests": 18,
+    "jsTransferBytes": 479783,
+    "jsNames": [
+      "chunk-BXO2KQCV.js",
+      "chunk-HCD3K6I6.js",
+      "chunk-P6RNKZIG.js",
+      "chunk-ANGEQHAP.js",
+      "chunk-ELV3GC2M.js",
+      "chunk-PR5A7Y5E.js",
+      "chunk-FK42CRUA.js",
+      "polyfills-FFHMD2TL.js",
+      "main-DEVX6XYG.js",
+      "chunk-WPYC6ITQ.js",
+      "chunk-VJFKDWZW.js",
+      "chunk-6GDE3FJ7.js",
+      "chunk-EJ73CB26.js",
+      "chunk-MEY6XM3E.js",
+      "chunk-XCRYXN4C.js",
+      "chunk-DZBK56IP.js",
+      "chunk-Z5ZRSJBH.js",
+      "chunk-ZBKIO5UF.js"
+    ],
+    "runs": [
+      {
+        "readyMs": 1899.5,
+        "jsRequests": 18,
+        "jsTransferBytes": 479783,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-FK42CRUA.js",
+          "chunk-PR5A7Y5E.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-VJFKDWZW.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-MEY6XM3E.js",
+          "chunk-XCRYXN4C.js",
+          "chunk-DZBK56IP.js",
+          "chunk-ZBKIO5UF.js",
+          "chunk-Z5ZRSJBH.js"
+        ]
+      },
+      {
+        "readyMs": 1888.5999999996275,
+        "jsRequests": 18,
+        "jsTransferBytes": 479783,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "main-DEVX6XYG.js",
+          "polyfills-FFHMD2TL.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-VJFKDWZW.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-XCRYXN4C.js",
+          "chunk-MEY6XM3E.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      },
+      {
+        "readyMs": 1891.9000000003725,
+        "jsRequests": 18,
+        "jsTransferBytes": 479783,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-VJFKDWZW.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-MEY6XM3E.js",
+          "chunk-XCRYXN4C.js",
+          "chunk-DZBK56IP.js",
+          "chunk-ZBKIO5UF.js",
+          "chunk-Z5ZRSJBH.js"
+        ]
+      },
+      {
+        "readyMs": 1879.5,
+        "jsRequests": 18,
+        "jsTransferBytes": 479783,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-VJFKDWZW.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-MEY6XM3E.js",
+          "chunk-XCRYXN4C.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      },
+      {
+        "readyMs": 1884,
+        "jsRequests": 18,
+        "jsTransferBytes": 479783,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-VJFKDWZW.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-MEY6XM3E.js",
+          "chunk-XCRYXN4C.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      },
+      {
+        "readyMs": 1877.5999999996275,
+        "jsRequests": 18,
+        "jsTransferBytes": 479783,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-FK42CRUA.js",
+          "chunk-PR5A7Y5E.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-VJFKDWZW.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-MEY6XM3E.js",
+          "chunk-XCRYXN4C.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      },
+      {
+        "readyMs": 1882.800000000745,
+        "jsRequests": 18,
+        "jsTransferBytes": 479783,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-VJFKDWZW.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-MEY6XM3E.js",
+          "chunk-XCRYXN4C.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      },
+      {
+        "readyMs": 1893.4000000003725,
+        "jsRequests": 18,
+        "jsTransferBytes": 479783,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-VJFKDWZW.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-MEY6XM3E.js",
+          "chunk-XCRYXN4C.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      },
+      {
+        "readyMs": 1504.699999999255,
+        "jsRequests": 18,
+        "jsTransferBytes": 479783,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-VJFKDWZW.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-MEY6XM3E.js",
+          "chunk-XCRYXN4C.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      },
+      {
+        "readyMs": 1881.300000000745,
+        "jsRequests": 18,
+        "jsTransferBytes": 479783,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-VJFKDWZW.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-MEY6XM3E.js",
+          "chunk-XCRYXN4C.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      }
+    ]
+  },
+  "B": {
+    "n": 10,
+    "readyP50Ms": 1887.2,
+    "readyP75Ms": 1891.3,
+    "jsRequests": 18,
+    "jsTransferBytes": 484202,
+    "jsNames": [
+      "chunk-CH2C3JLB.js",
+      "chunk-YFNQOSRZ.js",
+      "chunk-YCAZW72J.js",
+      "chunk-GVSU4OGS.js",
+      "chunk-KCYBAJ7Z.js",
+      "chunk-FK42CRUA.js",
+      "chunk-G6WG6RNH.js",
+      "polyfills-FFHMD2TL.js",
+      "main-EEGTEHHJ.js",
+      "chunk-TFN4UWMH.js",
+      "chunk-C74JT4K7.js",
+      "chunk-GISPN563.js",
+      "chunk-ZR6WC7MO.js",
+      "chunk-ZEMBAAPH.js",
+      "chunk-FLQ7VSTF.js",
+      "chunk-KAMQPPBH.js",
+      "chunk-BMZOMQA5.js",
+      "chunk-FIJEVPJP.js"
+    ],
+    "runs": [
+      {
+        "readyMs": 1895,
+        "jsRequests": 18,
+        "jsTransferBytes": 484202,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-C74JT4K7.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-FLQ7VSTF.js",
+          "chunk-ZEMBAAPH.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      },
+      {
+        "readyMs": 1890.9000000003725,
+        "jsRequests": 18,
+        "jsTransferBytes": 484202,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-C74JT4K7.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-ZEMBAAPH.js",
+          "chunk-FLQ7VSTF.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      },
+      {
+        "readyMs": 1891.4000000003725,
+        "jsRequests": 18,
+        "jsTransferBytes": 484202,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-C74JT4K7.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-ZEMBAAPH.js",
+          "chunk-FLQ7VSTF.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      },
+      {
+        "readyMs": 1882.7999999988824,
+        "jsRequests": 18,
+        "jsTransferBytes": 484202,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-C74JT4K7.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-ZEMBAAPH.js",
+          "chunk-FLQ7VSTF.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      },
+      {
+        "readyMs": 1884.5,
+        "jsRequests": 18,
+        "jsTransferBytes": 484202,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-C74JT4K7.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-ZEMBAAPH.js",
+          "chunk-FLQ7VSTF.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      },
+      {
+        "readyMs": 1878.4000000003725,
+        "jsRequests": 18,
+        "jsTransferBytes": 484202,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-C74JT4K7.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-ZEMBAAPH.js",
+          "chunk-FLQ7VSTF.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      },
+      {
+        "readyMs": 1886,
+        "jsRequests": 18,
+        "jsTransferBytes": 484202,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-C74JT4K7.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-ZEMBAAPH.js",
+          "chunk-FLQ7VSTF.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      },
+      {
+        "readyMs": 1888.4000000003725,
+        "jsRequests": 18,
+        "jsTransferBytes": 484202,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-C74JT4K7.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-ZEMBAAPH.js",
+          "chunk-FLQ7VSTF.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-BMZOMQA5.js",
+          "chunk-FIJEVPJP.js"
+        ]
+      },
+      {
+        "readyMs": 1896,
+        "jsRequests": 18,
+        "jsTransferBytes": 484202,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-FK42CRUA.js",
+          "chunk-G6WG6RNH.js",
+          "main-EEGTEHHJ.js",
+          "polyfills-FFHMD2TL.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-C74JT4K7.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-ZEMBAAPH.js",
+          "chunk-FLQ7VSTF.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-BMZOMQA5.js",
+          "chunk-FIJEVPJP.js"
+        ]
+      },
+      {
+        "readyMs": 1884,
+        "jsRequests": 18,
+        "jsTransferBytes": 484202,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-FK42CRUA.js",
+          "chunk-G6WG6RNH.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-C74JT4K7.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-ZEMBAAPH.js",
+          "chunk-FLQ7VSTF.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-BMZOMQA5.js",
+          "chunk-FIJEVPJP.js"
+        ]
+      }
+    ]
+  }
+}

+ 8 - 0
perf/bench/menu-ceilings.json

@@ -0,0 +1,8 @@
+{
+  "stores": 220,
+  "schedule": 175,
+  "coaches": 120,
+  "payroll": 150,
+  "vocabulary": 120,
+  "special-training": 110
+}

+ 194 - 0
perf/bench/menu-switch-2026-09-26.json

@@ -0,0 +1,194 @@
+{
+  "stores": {
+    "A": {
+      "p75Ms": 378.5,
+      "runs": [
+        380,
+        351.4,
+        396.7,
+        372.7,
+        367.5,
+        374.1,
+        366.9,
+        366.3,
+        381.2,
+        373.2
+      ]
+    },
+    "B": {
+      "p75Ms": 188.4,
+      "runs": [
+        218.1,
+        198.5,
+        151.2,
+        169.0,
+        176.3,
+        173.7,
+        171.5,
+        180.7,
+        191.0,
+        170.4
+      ]
+    }
+  },
+  "schedule": {
+    "A": {
+      "p75Ms": 353.1,
+      "runs": [
+        338.5,
+        371.1,
+        356.9,
+        353.9,
+        340.5,
+        346.0,
+        331.5,
+        350.6,
+        344.0,
+        341.1
+      ]
+    },
+    "B": {
+      "p75Ms": 121.5,
+      "runs": [
+        143.9,
+        101.9,
+        115.7,
+        122.5,
+        109.1,
+        114.3,
+        118.4,
+        115.4,
+        125.1,
+        104.9
+      ]
+    }
+  },
+  "coaches": {
+    "A": {
+      "p75Ms": 231.6,
+      "runs": [
+        230.9,
+        231.1,
+        240.7,
+        231.8,
+        228.9,
+        228.2,
+        217.5,
+        230.9,
+        216.0,
+        236.7
+      ]
+    },
+    "B": {
+      "p75Ms": 74.9,
+      "runs": [
+        81.7,
+        75.9,
+        72.0,
+        63.8,
+        70.3,
+        83.3,
+        68.2,
+        65.3,
+        63.5,
+        68.1
+      ]
+    }
+  },
+  "payroll": {
+    "A": {
+      "p75Ms": 345.2,
+      "runs": [
+        342.7,
+        338.8,
+        331.7,
+        341.7,
+        344.1,
+        344.7,
+        342.3,
+        345.4,
+        346.4,
+        348.0
+      ]
+    },
+    "B": {
+      "p75Ms": 100.9,
+      "runs": [
+        95.8,
+        94.8,
+        106.1,
+        97.5,
+        96.2,
+        110.5,
+        96.8,
+        93.4,
+        95.9,
+        102.0
+      ]
+    }
+  },
+  "vocabulary": {
+    "A": {
+      "p75Ms": 219.0,
+      "runs": [
+        219.2,
+        221.7,
+        216.1,
+        218.4,
+        216.9,
+        215.0,
+        218.0,
+        217.1,
+        214.1,
+        228.6
+      ]
+    },
+    "B": {
+      "p75Ms": 83.2,
+      "runs": [
+        78.1,
+        83.4,
+        79.3,
+        79.5,
+        81.1,
+        79.3,
+        80.1,
+        82.8,
+        88.5,
+        99.1
+      ]
+    }
+  },
+  "special-training": {
+    "A": {
+      "p75Ms": 233.2,
+      "runs": [
+        233.1,
+        233.3,
+        233.8,
+        216.3,
+        233.4,
+        216.6,
+        220.3,
+        231.5,
+        215.5,
+        231.6
+      ]
+    },
+    "B": {
+      "p75Ms": 71.2,
+      "runs": [
+        70.1,
+        72.1,
+        66.4,
+        60.4,
+        67.8,
+        66.1,
+        67.5,
+        71.5,
+        63.7,
+        77.8
+      ]
+    }
+  }
+}

+ 516 - 0
perf/bench/vocabulary-2026-09-26.json

@@ -0,0 +1,516 @@
+{
+  "A": {
+    "n": 10,
+    "readyP50Ms": 1566.2,
+    "readyP75Ms": 1572.5,
+    "jsRequests": 16,
+    "jsTransferBytes": 468594,
+    "jsNames": [
+      "chunk-BXO2KQCV.js",
+      "chunk-HCD3K6I6.js",
+      "chunk-P6RNKZIG.js",
+      "chunk-ELV3GC2M.js",
+      "chunk-ANGEQHAP.js",
+      "chunk-PR5A7Y5E.js",
+      "chunk-FK42CRUA.js",
+      "polyfills-FFHMD2TL.js",
+      "main-DEVX6XYG.js",
+      "chunk-WPYC6ITQ.js",
+      "chunk-RYG4PMSW.js",
+      "chunk-6GDE3FJ7.js",
+      "chunk-EJ73CB26.js",
+      "chunk-DZBK56IP.js",
+      "chunk-Z5ZRSJBH.js",
+      "chunk-ZBKIO5UF.js"
+    ],
+    "runs": [
+      {
+        "readyMs": 1581.3999999985099,
+        "jsRequests": 16,
+        "jsTransferBytes": 468594,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-RYG4PMSW.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      },
+      {
+        "readyMs": 1556.7000000011176,
+        "jsRequests": 16,
+        "jsTransferBytes": 468594,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "main-DEVX6XYG.js",
+          "polyfills-FFHMD2TL.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-RYG4PMSW.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-DZBK56IP.js",
+          "chunk-ZBKIO5UF.js",
+          "chunk-Z5ZRSJBH.js"
+        ]
+      },
+      {
+        "readyMs": 1578.9000000003725,
+        "jsRequests": 16,
+        "jsTransferBytes": 468594,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-RYG4PMSW.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-DZBK56IP.js",
+          "chunk-ZBKIO5UF.js",
+          "chunk-Z5ZRSJBH.js"
+        ]
+      },
+      {
+        "readyMs": 1557.7999999988824,
+        "jsRequests": 16,
+        "jsTransferBytes": 468594,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-RYG4PMSW.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      },
+      {
+        "readyMs": 1560,
+        "jsRequests": 16,
+        "jsTransferBytes": 468594,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-RYG4PMSW.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      },
+      {
+        "readyMs": 1566.300000000745,
+        "jsRequests": 16,
+        "jsTransferBytes": 468594,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-RYG4PMSW.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      },
+      {
+        "readyMs": 1563.2000000011176,
+        "jsRequests": 16,
+        "jsTransferBytes": 468594,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-RYG4PMSW.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      },
+      {
+        "readyMs": 1572.5999999996275,
+        "jsRequests": 16,
+        "jsTransferBytes": 468594,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-RYG4PMSW.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      },
+      {
+        "readyMs": 1572.4000000003725,
+        "jsRequests": 16,
+        "jsTransferBytes": 468594,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-RYG4PMSW.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      },
+      {
+        "readyMs": 1566.199999999255,
+        "jsRequests": 16,
+        "jsTransferBytes": 468594,
+        "jsNames": [
+          "chunk-BXO2KQCV.js",
+          "chunk-HCD3K6I6.js",
+          "chunk-P6RNKZIG.js",
+          "chunk-ELV3GC2M.js",
+          "chunk-ANGEQHAP.js",
+          "chunk-PR5A7Y5E.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-DEVX6XYG.js",
+          "chunk-WPYC6ITQ.js",
+          "chunk-RYG4PMSW.js",
+          "chunk-6GDE3FJ7.js",
+          "chunk-EJ73CB26.js",
+          "chunk-DZBK56IP.js",
+          "chunk-Z5ZRSJBH.js",
+          "chunk-ZBKIO5UF.js"
+        ]
+      }
+    ]
+  },
+  "B": {
+    "n": 10,
+    "readyP50Ms": 1387.1,
+    "readyP75Ms": 1389.5,
+    "jsRequests": 16,
+    "jsTransferBytes": 473393,
+    "jsNames": [
+      "chunk-CH2C3JLB.js",
+      "chunk-YFNQOSRZ.js",
+      "chunk-YCAZW72J.js",
+      "chunk-KCYBAJ7Z.js",
+      "chunk-GVSU4OGS.js",
+      "chunk-G6WG6RNH.js",
+      "chunk-FK42CRUA.js",
+      "polyfills-FFHMD2TL.js",
+      "main-EEGTEHHJ.js",
+      "chunk-TFN4UWMH.js",
+      "chunk-SXKZ3DVX.js",
+      "chunk-GISPN563.js",
+      "chunk-ZR6WC7MO.js",
+      "chunk-KAMQPPBH.js",
+      "chunk-FIJEVPJP.js",
+      "chunk-BMZOMQA5.js"
+    ],
+    "runs": [
+      {
+        "readyMs": 1389.5999999996275,
+        "jsRequests": 16,
+        "jsTransferBytes": 473393,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-SXKZ3DVX.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      },
+      {
+        "readyMs": 1384.3999999985099,
+        "jsRequests": 16,
+        "jsTransferBytes": 473393,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "main-EEGTEHHJ.js",
+          "polyfills-FFHMD2TL.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-SXKZ3DVX.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-BMZOMQA5.js",
+          "chunk-FIJEVPJP.js"
+        ]
+      },
+      {
+        "readyMs": 1391.5999999996275,
+        "jsRequests": 16,
+        "jsTransferBytes": 473393,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-SXKZ3DVX.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      },
+      {
+        "readyMs": 1386.0999999996275,
+        "jsRequests": 16,
+        "jsTransferBytes": 473393,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-SXKZ3DVX.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-BMZOMQA5.js",
+          "chunk-FIJEVPJP.js"
+        ]
+      },
+      {
+        "readyMs": 1394.199999999255,
+        "jsRequests": 16,
+        "jsTransferBytes": 473393,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-SXKZ3DVX.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-BMZOMQA5.js",
+          "chunk-FIJEVPJP.js"
+        ]
+      },
+      {
+        "readyMs": 1389.0999999996275,
+        "jsRequests": 16,
+        "jsTransferBytes": 473393,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-SXKZ3DVX.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      },
+      {
+        "readyMs": 1384.5999999996275,
+        "jsRequests": 16,
+        "jsTransferBytes": 473393,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-SXKZ3DVX.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      },
+      {
+        "readyMs": 1388.1000000014901,
+        "jsRequests": 16,
+        "jsTransferBytes": 473393,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-FK42CRUA.js",
+          "chunk-G6WG6RNH.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-SXKZ3DVX.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      },
+      {
+        "readyMs": 1380.9000000003725,
+        "jsRequests": 16,
+        "jsTransferBytes": 473393,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "main-EEGTEHHJ.js",
+          "polyfills-FFHMD2TL.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-SXKZ3DVX.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      },
+      {
+        "readyMs": 1382.300000000745,
+        "jsRequests": 16,
+        "jsTransferBytes": 473393,
+        "jsNames": [
+          "chunk-CH2C3JLB.js",
+          "chunk-YFNQOSRZ.js",
+          "chunk-YCAZW72J.js",
+          "chunk-KCYBAJ7Z.js",
+          "chunk-GVSU4OGS.js",
+          "chunk-G6WG6RNH.js",
+          "chunk-FK42CRUA.js",
+          "polyfills-FFHMD2TL.js",
+          "main-EEGTEHHJ.js",
+          "chunk-TFN4UWMH.js",
+          "chunk-SXKZ3DVX.js",
+          "chunk-GISPN563.js",
+          "chunk-ZR6WC7MO.js",
+          "chunk-KAMQPPBH.js",
+          "chunk-FIJEVPJP.js",
+          "chunk-BMZOMQA5.js"
+        ]
+      }
+    ]
+  }
+}

+ 15 - 0
perf/基线.md

@@ -12,3 +12,18 @@
 构建 `stats.json` 指出会员页面会引入 `xlsx/xlsx.mjs` 约 429 KB;学习报表页面在列表阶段引入 PDF 生成相关代码。它们都不参与首次显示列表,是此次选中的稳定代理指标。后台初始包约 350.65 KB,两个页面按路由懒加载,但路由内部仍含上述导出依赖。
 构建 `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`,不作为仓库源文件提交。
 测量脚本:`perf/bench/admin-frontend.py`。它在本机启动旧、新两套静态服务,固定接口响应,并记录列表真正出现的时间。实验数据暂存 `/tmp/xiaoshu-admin-perf-final-members.json` 和 `/tmp/xiaoshu-admin-perf-pair-reports.json`,不作为仓库源文件提交。
+
+## 2026-09-26 其他运营模块基线
+
+基线源代码:`2d30cb825de0206bb7bc63a3ee22328261130131`。生产包保存在 `/tmp/xiaoshu-admin-perf-other-baseline/browser`。在工作台打开并静置 4.5 秒后,从侧栏首次切入目标模块,记录目标页面标题出现的时间。旧、新版交替各 10 轮;本指标只代表路由代码可用,不代表业务数据加载完成。
+
+| 模块 | 旧版切入 p75 |
+| --- | ---: |
+| 门店管理 | 378.5 ms |
+| 排课中心 | 353.1 ms |
+| 陪练老师 | 231.6 ms |
+| 工资结算 | 345.2 ms |
+| 词库管理 | 219.0 ms |
+| 专项训练 | 233.2 ms |
+
+词库真正可用的独立基线:目录总数和词条分页总数均显示后 p75 为 1572.5 ms,固定模拟接口分别延迟 150 ms。会员管理与学习报表作为冷启动回退对照,基线分别为 1891.1 ms 与 1877.7 ms。

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

@@ -14,3 +14,17 @@
 - 工资结算和词库的前端导入链已改变,但没有单独取得真实可用耗时,不给它们填写耗时降幅。
 - 工资结算和词库的前端导入链已改变,但没有单独取得真实可用耗时,不给它们填写耗时降幅。
 
 
 两次实验都符合“代理指标下降、列表真正可用耗时也下降”。耗时受浏览器与测量机负载影响,JS 字节阈值用作回退检查,p75 仅用于本次配对结论。
 两次实验都符合“代理指标下降、列表真正可用耗时也下降”。耗时受浏览器与测量机负载影响,JS 字节阈值用作回退检查,p75 仅用于本次配对结论。
+
+## 3. 运营菜单后台预热
+
+- 假设:各页面已按路由拆包;第一次点击侧栏时仍要下载和解析目标页面代码。当前页面稳定后预热九个运营主菜单路由,能缩短首次切入等待。
+- 改动:仅认证会话的运营菜单在路由结束 3 秒后预加载。省流量或 2G 网络不预热;不预取业务数据、详情页、技术后台或导出库。
+- 代理指标:固定模拟接口、本地 Fast 4G、1440×900、先在工作台静置 4.5 秒,再首次点击六个目标菜单。配对 10 轮,六个模块 p75 均下降 50.2%–70.8%。
+- 限制:这里计到目标页面标题出现。门店、排课、陪练、工资和专项训练的数据可用时间仍取决于其接口,不能把标题时间当作业务可用时间。
+- 回退检查:会员与学习报表的真正可用 p75 变化分别是 1891.1→1891.3 ms、1877.7→1881.5 ms;初始 JS 约增加 4.4 KB,仍低于 500 KB 门槛。
+
+## 4. 词库首屏并发请求
+
+- 假设:默认查看全部词条时,目录与第一页词条互不依赖,串行等待会增加一个接口往返。
+- 改动:同时请求目录与第一页,分别保留成功结果和错误提示;已有单元测试增加了并发启动断言。
+- 真正可用 p75:1572.5→1389.5 ms,下降 11.6%,配对 10 轮。首屏 JS 468594→473393 字节,仍低于 500 KB 门槛。

+ 19 - 0
perf/报告.md

@@ -17,3 +17,22 @@ python3 perf/bench/admin-frontend.py --baseline "$BASELINE_BROWSER" --candidate
 ```
 ```
 
 
 本报告的时间和字节来自本地模拟条件。线上 CDN 压缩、真实接口响应、真实管理员数据量和实际设备体验尚未验收;推送代码也不等于确认线上部署。若需回滚,回退本次动态导入改动即可恢复原加载行为。
 本报告的时间和字节来自本地模拟条件。线上 CDN 压缩、真实接口响应、真实管理员数据量和实际设备体验尚未验收;推送代码也不等于确认线上部署。若需回滚,回退本次动态导入改动即可恢复原加载行为。
+
+## 2026-09-26 其余运营菜单
+
+登录后当前页面稳定 4.5 秒再从侧栏首次切入。下表是**目标页面标题出现**的 p75,代表路由代码切换速度;各模块的业务数据耗时未包含在内。
+
+| 模块 | 原版 | 新版 | 降幅 |
+| --- | ---: | ---: | ---: |
+| 门店管理 | 378.5 ms | 188.4 ms | 50.2% |
+| 排课中心 | 353.1 ms | 121.5 ms | 65.6% |
+| 陪练老师 | 231.6 ms | 74.9 ms | 67.7% |
+| 工资结算 | 345.2 ms | 100.9 ms | 70.8% |
+| 词库管理 | 219.0 ms | 83.2 ms | 62.0% |
+| 专项训练 | 233.2 ms | 71.2 ms | 69.5% |
+
+词库另测到目录与第一页词条数据均到齐的真正可用 p75:1572.5→1389.5 ms(11.6%)。工作台是本轮预热的起点,没有单独可归因的首次打开提速;会员与学习报表冷启动 p75 基本持平,分别是 1891.1→1891.3 ms、1877.7→1881.5 ms。
+
+运营主菜单只预加载页面代码,不发额外业务接口;登录页、详情页、技术后台、Excel/PDF 导出代码不预热。3 秒延迟保护当前页,省流量及 2G 连接跳过。`perf/bench/admin-menu-switch.py` 和 `admin-frontend.py` 可重跑,10 轮原始结果在同目录的 `*-2026-09-26.json`。`menu-ceilings.json` 和 `ceilings.json` 分别锁住菜单切换 p75 与首次 JS 字节上限。
+
+验证范围:本地生产构建、后台单元测试、固定模拟接口与网络条件。真实管理员权限、真实数据量、线上网络和部署后的体验尚未测量,因此以上数字不代表线上实际提速。

+ 23 - 0
projects/xiaoshu-admin/src/app/admin-menu-preloading.strategy.ts

@@ -0,0 +1,23 @@
+import { inject, Injectable } from '@angular/core';
+import { PreloadingStrategy, Route } from '@angular/router';
+import { Observable, of, timer } from 'rxjs';
+import { catchError, switchMap } from 'rxjs/operators';
+import { AdminSessionService } from './admin-session.service';
+
+/** Warm only the operations menu after the current page has had time to become usable. */
+@Injectable({ providedIn: 'root' })
+export class AdminMenuPreloadingStrategy implements PreloadingStrategy {
+  private readonly sessions = inject(AdminSessionService);
+
+  preload(route: Route, load: () => Observable<unknown>): Observable<unknown> {
+    if (!route.data?.['preloadOperationsMenu']) return of(null);
+    return timer(3000).pipe(
+      switchMap(() => {
+        const connection = (navigator as Navigator & { connection?: { saveData?: boolean; effectiveType?: string } }).connection;
+        if (!this.sessions.isAuthenticated() || connection?.saveData || /^(slow-2g|2g)$/.test(connection?.effectiveType || '')) return of(null);
+        return load();
+      }),
+      catchError(() => of(null)),
+    );
+  }
+}

+ 9 - 9
projects/xiaoshu-admin/src/app/admin.routes.ts

@@ -3,21 +3,21 @@ import { adminGuard, operatorManagerGuard, superAdminGuard } from './admin.guard
 
 
 const operationsChildren: Routes = [
 const operationsChildren: Routes = [
   { path: '', pathMatch: 'full', redirectTo: 'dashboard' },
   { path: '', pathMatch: 'full', redirectTo: 'dashboard' },
-  { path: 'dashboard', data: { title: '工作台', parentUrl: null }, loadComponent: () => import('./pages/admin-dashboard.component').then((module) => module.AdminDashboardComponent) },
+  { path: 'dashboard', data: { title: '工作台', parentUrl: null, preloadOperationsMenu: true }, loadComponent: () => import('./pages/admin-dashboard.component').then((module) => module.AdminDashboardComponent) },
   { path: 'users', pathMatch: 'full', redirectTo: 'members' },
   { path: 'users', pathMatch: 'full', redirectTo: 'members' },
   { path: 'users/:objectId', redirectTo: 'members/:objectId' },
   { path: 'users/:objectId', redirectTo: 'members/:objectId' },
-  { path: 'members', data: { title: '会员管理', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/operations-users.component').then((module) => module.OperationsUsersComponent) },
+  { path: 'members', data: { title: '会员管理', parentUrl: '/admin/dashboard', preloadOperationsMenu: true }, loadComponent: () => import('./pages/operations-users.component').then((module) => module.OperationsUsersComponent) },
   { path: 'members/:objectId', data: { title: '会员详情', parentUrl: '/admin/members', parentTitle: '会员管理' }, loadComponent: () => import('./pages/operations-user-detail.component').then((module) => module.OperationsUserDetailComponent) },
   { path: 'members/:objectId', data: { title: '会员详情', parentUrl: '/admin/members', parentTitle: '会员管理' }, loadComponent: () => import('./pages/operations-user-detail.component').then((module) => module.OperationsUserDetailComponent) },
-  { path: 'stores', data: { title: '门店管理', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/operations-stores.component').then((module) => module.OperationsStoresComponent) },
+  { path: 'stores', data: { title: '门店管理', parentUrl: '/admin/dashboard', preloadOperationsMenu: true }, loadComponent: () => import('./pages/operations-stores.component').then((module) => module.OperationsStoresComponent) },
   { path: 'stores/:objectId', data: { title: '门店详情', parentUrl: '/admin/stores', parentTitle: '门店管理' }, loadComponent: () => import('./pages/operations-store-detail.component').then((module) => module.OperationsStoreDetailComponent) },
   { path: 'stores/:objectId', data: { title: '门店详情', parentUrl: '/admin/stores', parentTitle: '门店管理' }, loadComponent: () => import('./pages/operations-store-detail.component').then((module) => module.OperationsStoreDetailComponent) },
-  { path: 'schedule', data: { title: '排课中心', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/operations-schedule.component').then((module) => module.OperationsScheduleComponent) },
-  { path: 'learning-reports', data: { title: '学习报表', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/operations-learning-reports.component').then((module) => module.OperationsLearningReportsComponent) },
+  { path: 'schedule', data: { title: '排课中心', parentUrl: '/admin/dashboard', preloadOperationsMenu: true }, loadComponent: () => import('./pages/operations-schedule.component').then((module) => module.OperationsScheduleComponent) },
+  { path: 'learning-reports', data: { title: '学习报表', parentUrl: '/admin/dashboard', preloadOperationsMenu: true }, loadComponent: () => import('./pages/operations-learning-reports.component').then((module) => module.OperationsLearningReportsComponent) },
   { path: 'appointments', pathMatch: 'full', redirectTo: 'schedule' },
   { path: 'appointments', pathMatch: 'full', redirectTo: 'schedule' },
-  { path: 'coaches', data: { title: '陪练老师', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/operations-coaches.component').then((module) => module.OperationsCoachesComponent) },
+  { path: 'coaches', data: { title: '陪练老师', parentUrl: '/admin/dashboard', preloadOperationsMenu: true }, loadComponent: () => import('./pages/operations-coaches.component').then((module) => module.OperationsCoachesComponent) },
   { path: 'coaches/:objectId', data: { title: '老师详情', parentUrl: '/admin/coaches', parentTitle: '陪练老师' }, loadComponent: () => import('./pages/operations-coach-detail.component').then((module) => module.OperationsCoachDetailComponent) },
   { path: 'coaches/:objectId', data: { title: '老师详情', parentUrl: '/admin/coaches', parentTitle: '陪练老师' }, loadComponent: () => import('./pages/operations-coach-detail.component').then((module) => module.OperationsCoachDetailComponent) },
-  { path: 'payroll', data: { title: '工资结算', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/operations-payroll.component').then((module) => module.OperationsPayrollComponent) },
-  { path: 'vocabulary', data: { title: '词库管理', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/operations-vocabulary.component').then((module) => module.OperationsVocabularyComponent) },
-  { path: 'special-training', data: { title: '专项训练', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/operations-special-training.component').then((module) => module.OperationsSpecialTrainingComponent) },
+  { path: 'payroll', data: { title: '工资结算', parentUrl: '/admin/dashboard', preloadOperationsMenu: true }, loadComponent: () => import('./pages/operations-payroll.component').then((module) => module.OperationsPayrollComponent) },
+  { path: 'vocabulary', data: { title: '词库管理', parentUrl: '/admin/dashboard', preloadOperationsMenu: true }, loadComponent: () => import('./pages/operations-vocabulary.component').then((module) => module.OperationsVocabularyComponent) },
+  { path: 'special-training', data: { title: '专项训练', parentUrl: '/admin/dashboard', preloadOperationsMenu: true }, loadComponent: () => import('./pages/operations-special-training.component').then((module) => module.OperationsSpecialTrainingComponent) },
   { path: 'operator-accounts', data: { title: '运营账号管理', parentUrl: '/admin/dashboard' }, canActivate: [operatorManagerGuard], loadComponent: () => import('./pages/operator-accounts.component').then(module => module.OperatorAccountsComponent) },
   { path: 'operator-accounts', data: { title: '运营账号管理', parentUrl: '/admin/dashboard' }, canActivate: [operatorManagerGuard], loadComponent: () => import('./pages/operator-accounts.component').then(module => module.OperatorAccountsComponent) },
   { path: 'change-password', data: { title: '修改密码', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/admin-change-password.component').then((module) => module.AdminChangePasswordComponent) },
   { path: 'change-password', data: { title: '修改密码', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/admin-change-password.component').then((module) => module.AdminChangePasswordComponent) },
   { path: 'forbidden', data: { title: '无访问权限', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/admin-forbidden.component').then((module) => module.AdminForbiddenComponent) },
   { path: 'forbidden', data: { title: '无访问权限', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/admin-forbidden.component').then((module) => module.AdminForbiddenComponent) },

+ 3 - 2
projects/xiaoshu-admin/src/app/app.config.ts

@@ -1,6 +1,7 @@
 import { provideHttpClient, withInterceptors } from '@angular/common/http';
 import { provideHttpClient, withInterceptors } from '@angular/common/http';
 import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
 import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
-import { provideRouter } from '@angular/router';
+import { provideRouter, withPreloading } from '@angular/router';
+import { AdminMenuPreloadingStrategy } from './admin-menu-preloading.strategy';
 import { adminRequestActivityInterceptor } from './admin-request-activity.interceptor';
 import { adminRequestActivityInterceptor } from './admin-request-activity.interceptor';
 import { routes } from './app.routes';
 import { routes } from './app.routes';
 
 
@@ -8,6 +9,6 @@ export const appConfig: ApplicationConfig = {
   providers: [
   providers: [
     provideZoneChangeDetection({ eventCoalescing: true }),
     provideZoneChangeDetection({ eventCoalescing: true }),
     provideHttpClient(withInterceptors([adminRequestActivityInterceptor])),
     provideHttpClient(withInterceptors([adminRequestActivityInterceptor])),
-    provideRouter(routes),
+    provideRouter(routes, withPreloading(AdminMenuPreloadingStrategy)),
   ],
   ],
 };
 };

+ 12 - 0
projects/xiaoshu-admin/src/app/pages/operations-vocabulary.component.spec.ts

@@ -1,5 +1,6 @@
 import { ComponentFixture, TestBed } from '@angular/core/testing';
 import { ComponentFixture, TestBed } from '@angular/core/testing';
 import { OperationsService } from '../operations.service';
 import { OperationsService } from '../operations.service';
+import { VocabularyTree } from '../admin.models';
 import { OperationsVocabularyComponent } from './operations-vocabulary.component';
 import { OperationsVocabularyComponent } from './operations-vocabulary.component';
 
 
 describe('OperationsVocabularyComponent', () => {
 describe('OperationsVocabularyComponent', () => {
@@ -29,6 +30,17 @@ describe('OperationsVocabularyComponent', () => {
     expect(operations.vocabulary).toHaveBeenCalledWith(1, 30, '', { nodeId: 0, status: '', missingAudio: false });
     expect(operations.vocabulary).toHaveBeenCalledWith(1, 30, '', { nodeId: 0, status: '', missingAudio: false });
   });
   });
 
 
+  it('starts loading the word list while the catalog request is still pending', async () => {
+    let finishTree!: (tree: VocabularyTree) => void;
+    operations.vocabularyTree.and.returnValue(new Promise<VocabularyTree>((resolve) => { finishTree = resolve; }));
+    operations.vocabulary.calls.reset();
+    const loading = component.initialLoad();
+    expect(operations.vocabulary).toHaveBeenCalledWith(1, 30, '', { nodeId: 0, status: '', missingAudio: false });
+    finishTree({ items: [], total: 0, totalWords: 89620, refreshedAt: '2026-08-22T12:00:00.000Z' });
+    await loading;
+    expect(component.loading()).toBeFalse();
+  });
+
   it('normalizes old audio filenames and UploadFiles paths through the legacy asset host', () => {
   it('normalizes old audio filenames and UploadFiles paths through the legacy asset host', () => {
     expect(component.audioSource('apple.mp3')).toBe('/backend/UploadFiles/apple.mp3');
     expect(component.audioSource('apple.mp3')).toBe('/backend/UploadFiles/apple.mp3');
     expect(component.audioSource('UploadFiles/unit/apple.mp3')).toBe('/backend/UploadFiles/unit/apple.mp3');
     expect(component.audioSource('UploadFiles/unit/apple.mp3')).toBe('/backend/UploadFiles/unit/apple.mp3');

+ 16 - 5
projects/xiaoshu-admin/src/app/pages/operations-vocabulary.component.ts

@@ -64,12 +64,23 @@ export class OperationsVocabularyComponent implements OnInit {
   ngOnInit(): void { void this.initialLoad(); }
   ngOnInit(): void { void this.initialLoad(); }
   async initialLoad(): Promise<void> {
   async initialLoad(): Promise<void> {
     this.loading.set(true); this.error.set('');
     this.loading.set(true); this.error.set('');
-    try {
-      const result = await this.operations.vocabularyTree(); this.tree.set(result.items); this.totalWords.set(result.totalWords || 0);
-      this.selectedNodeId.set(0);
+    this.selectedNodeId.set(0);
+    const [tree, words] = await Promise.allSettled([
+      this.operations.vocabularyTree(),
+      this.operations.vocabulary(1, this.pageSize(), this.query(), { nodeId: 0, status: this.status(), missingAudio: this.missingAudio() }),
+    ]);
+    if (tree.status === 'fulfilled') {
+      const result = tree.value;
+      this.tree.set(result.items); this.totalWords.set(result.totalWords || 0);
       this.expandedNodeIds.set(new Set(result.items.filter((node) => !result.items.some((candidate) => candidate.nodeId === node.parentId)).map((node) => node.nodeId)));
       this.expandedNodeIds.set(new Set(result.items.filter((node) => !result.items.some((candidate) => candidate.nodeId === node.parentId)).map((node) => node.nodeId)));
-      await this.loadWords(1, false);
-    } catch (error) { this.error.set(this.errorText(error, '词库加载失败')); this.loading.set(false); }
+    }
+    if (words.status === 'fulfilled') this.page.set(words.value);
+    const failures = [
+      tree.status === 'rejected' ? this.errorText(tree.reason, '词库目录加载失败') : '',
+      words.status === 'rejected' ? this.errorText(words.reason, '词条加载失败') : '',
+    ].filter(Boolean);
+    if (failures.length) this.error.set(failures.join(';'));
+    this.loading.set(false);
   }
   }
   async refresh(): Promise<void> { await this.initialLoad(); this.message.set(`已从服务端刷新 · ${new Date().toLocaleTimeString('zh-CN')}`); }
   async refresh(): Promise<void> { await this.initialLoad(); this.message.set(`已从服务端刷新 · ${new Date().toLocaleTimeString('zh-CN')}`); }
   async loadWords(page = 1, setLoading = true): Promise<void> {
   async loadWords(page = 1, setLoading = true): Promise<void> {