Quellcode durchsuchen

perf(admin): retain loaded operations pages and refresh on change

彭峰 vor 1 Tag
Ursprung
Commit
a0a12e8e87
26 geänderte Dateien mit 1855 neuen und 25 gelöschten Zeilen
  1. 8 1
      perf/BRIEF.md
  2. 161 0
      perf/bench/admin-dashboard-return.py
  3. 184 0
      perf/bench/admin-menu-return.py
  4. 96 0
      perf/bench/dashboard-login-2026-09-26.json
  5. 118 0
      perf/bench/dashboard-return-2026-09-26.json
  6. 920 0
      perf/bench/menu-return-2026-09-26.json
  7. 11 0
      perf/bench/menu-return-ceilings.json
  8. 18 0
      perf/基线.md
  9. 13 0
      perf/实验日志.md
  10. 26 0
      perf/报告.md
  11. 9 9
      projects/xiaoshu-admin/src/app/admin.routes.ts
  12. 6 1
      projects/xiaoshu-admin/src/app/app.component.spec.ts
  13. 12 2
      projects/xiaoshu-admin/src/app/app.component.ts
  14. 3 1
      projects/xiaoshu-admin/src/app/app.config.ts
  15. 26 0
      projects/xiaoshu-admin/src/app/operations-live-refresh.service.spec.ts
  16. 8 0
      projects/xiaoshu-admin/src/app/operations-live-refresh.service.ts
  17. 48 0
      projects/xiaoshu-admin/src/app/operations-route-reuse.strategy.spec.ts
  18. 59 0
      projects/xiaoshu-admin/src/app/operations-route-reuse.strategy.ts
  19. 31 2
      projects/xiaoshu-admin/src/app/operations.service.spec.ts
  20. 29 1
      projects/xiaoshu-admin/src/app/operations.service.ts
  21. 2 1
      projects/xiaoshu-admin/src/app/pages/admin-dashboard.component.html
  22. 0 0
      projects/xiaoshu-admin/src/app/pages/admin-dashboard.component.scss
  23. 40 1
      projects/xiaoshu-admin/src/app/pages/admin-dashboard.component.spec.ts
  24. 18 4
      projects/xiaoshu-admin/src/app/pages/admin-dashboard.component.ts
  25. 8 1
      projects/xiaoshu-admin/src/app/pages/admin-login.component.ts
  26. 1 1
      scripts/deploy-admin-functions.mjs

+ 8 - 1
perf/BRIEF.md

@@ -4,7 +4,7 @@
 - 选择:会员管理、学习报表的首次打开;工资结算和词库管理沿用相同的按需加载修复。
 - 真正可用:模拟接口返回后,会员空列表或学习报表空列表已经渲染,页面可继续筛选与操作。仅显示页面标题不计入。
 - 目标:默认争取把上述操作的 p75 耗时减半;达不到时如实报告已验证的收益。
-- 约束:不改云函数、数据库、登录协议、可见布局、导入导出内容或第三方统计;现有迁移资料不参与本次提交。
+- 最初前端加载批次的约束:不改云函数、数据库、登录协议、可见布局、导入导出内容或第三方统计;现有迁移资料不参与提交。后续工作台批次仅精简现有云函数的重复查询,不改接口协议。
 - 发布:按仓库 `AGENTS.md` 的既有要求,完成检查后只提交并推送本次文件;线上部署和真实账号验收单独记录。
 
 ## 2026-09-26 扩展范围
@@ -13,3 +13,10 @@
 - 当前切入任务:登录后从工作台首次点击一个运营菜单,目标页面标题已渲染。先测路由代码等待;它不代表业务数据已到齐。
 - 词库真正可用:目录总数和词条第一页总数均已显示,可查询和选择目录。
 - 约束:仅预加载运营菜单页面代码,等待当前页 3 秒;省流量和 2G 网络不预热;业务接口和 Excel/PDF 引擎继续按需加载。
+
+## 2026-09-26 已加载页面返回
+
+- 范围:九个运营主菜单页面。首次进入时正常获取数据;离开后保留页面实例、筛选、分页和已渲染内容,返回时立即恢复。
+- 真正可用:工作台以欢迎内容和排课趋势实际出现为准;其余页面用原页面实例可见且没有重复业务请求验证。页面标题耗时单独标注为导航代理指标。
+- 更新:当前页面轻量检测业务版本;切走后暂停轮询,回来时补查。有变更才后台刷新。工作台快照超过 30 秒或跨日时后台刷新;内容页继续使用现有手动刷新。
+- 安全边界:切换或退出登录会销毁旧会话保留的页面;详情页、技术后台和登录页不复用。

+ 161 - 0
perf/bench/admin-dashboard-return.py

@@ -0,0 +1,161 @@
+#!/usr/bin/env python3
+"""Paired local dashboard first-open and return benchmark with a slow fixed API.
+
+The mock session and gateway are synthetic. The marker is the real dashboard
+welcome and chart, not the title or loading placeholder.
+"""
+
+import argparse
+import asyncio
+import json
+import statistics
+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}
+TREND = {"period": "week", "periodLabel": "近 7 天", "dateFrom": "2026-09-20", "dateTo": "2026-09-26",
+         "points": [{"key": "2026-09-26", "label": "9月26日", "shortLabel": "周六", "courseCount": 2,
+                     "studentCount": 2, "completedCount": 1, "isToday": True}],
+         "todayCourseCount": 2, "todayStudentCount": 2, "totalCourseCount": 2, "totalStudentCount": 2,
+         "maxValue": 2, "refreshedAt": "2026-09-26T06:00:00.000Z", "liveSourceAvailable": True,
+         "liveSourceMessage": "本地模拟数据"}
+DASHBOARD = {"identity": {"objectId": "perf-test", "username": "perf-test", "displayName": "运营经理",
+                          "roles": [], "operationsRole": "ops-manager", "isSuperAdmin": False},
+             "date": "2026-09-26", "metrics": [], "alerts": [], "scheduleTrend": TREND,
+             "refreshedAt": "2026-09-26T06:00:00.000Z", "liveSourceAvailable": True}
+
+
+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 percentile(values, n):
+    return round(statistics.quantiles(sorted(values), n=100, method="inclusive")[n - 1], 1)
+
+
+async def measure(browser, url, from_login=False):
+    context = await browser.new_context(viewport={"width": 1440, "height": 900})
+    if not from_login:
+        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,
+    })
+    dashboard_requests = 0
+
+    async def mock_gateway(route):
+        nonlocal dashboard_requests
+        operation = (route.request.post_data_json or {}).get("params", {}).get("operation", "")
+        if route.request.url.endswith('/xiaoshu/app/gateway'):
+            await asyncio.sleep(0.3)
+            await route.fulfill(status=200, content_type="application/json",
+                                body=json.dumps({"retcode": 0, "result": {"objectId": SESSION["objectId"],
+                                      "userName": SESSION["username"], "sessionToken": SESSION["sessionToken"]}}))
+            return
+        if operation == "meta":
+            await asyncio.sleep(0.5)
+            payload = {"identity": DASHBOARD["identity"]}
+        elif operation == "ops/dashboard/summary":
+            dashboard_requests += 1
+            await asyncio.sleep(1.2)
+            payload = DASHBOARD
+        elif operation == "ops/live/versions":
+            payload = {"version": "fixed-v1", "scopeVersions": {}, "refreshedAt": "2026-09-26T06:00:00.000Z",
+                       "syncStatus": {"state": "current"}}
+        else:
+            payload = {}
+        await route.fulfill(status=200, content_type="application/json",
+                            body=json.dumps({"success": True, "data": payload}))
+
+    await page.route("**/cloud-functions/**", mock_gateway)
+    try:
+        if from_login:
+            await page.goto(url + "/admin/login", wait_until="commit")
+            await page.locator('input[name="username"]').fill("perf-test")
+            await page.locator('input[name="password"]').fill("test-password")
+            before = await page.evaluate("performance.now()")
+            await page.get_by_role("button", name="登录管理后台").click()
+            await page.get_by_role("heading", name="运营经理,欢迎回来").wait_for(timeout=30000)
+            await page.locator(".trend-stat.courses strong").wait_for(timeout=30000)
+            return {"loginMs": round(await page.evaluate("performance.now()") - before, 1),
+                    "dashboardRequests": dashboard_requests}
+        await page.goto(url + "/admin/dashboard", wait_until="commit")
+        await page.get_by_role("heading", name="运营经理,欢迎回来").wait_for(timeout=30000)
+        await page.locator(".trend-stat.courses strong").wait_for(timeout=30000)
+        first_ms = round(await page.evaluate("performance.now()"), 1)
+        await page.locator('a[href="/admin/schedule"]').first.click()
+        await page.get_by_role("heading", name="排课中心", exact=True).wait_for(timeout=30000)
+        before = await page.evaluate("performance.now()")
+        await page.locator('#admin-sidebar a[href="/admin/dashboard"]').last.click()
+        await page.get_by_role("heading", name="运营经理,欢迎回来").wait_for(timeout=30000)
+        await page.locator(".trend-stat.courses strong").wait_for(timeout=30000)
+        returned_ms = round(await page.evaluate("performance.now()") - before, 1)
+        return {"firstMs": first_ms, "returnMs": returned_ms, "dashboardRequests": dashboard_requests}
+    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("--runs", type=int, default=10)
+    parser.add_argument("--out", type=Path)
+    parser.add_argument("--from-login", action="store_true")
+    args = parser.parse_args()
+    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.from_login))
+            finally:
+                await browser.close()
+    finally:
+        for server in servers.values():
+            server.shutdown()
+    keys = ["loginMs"] if args.from_login else ["firstMs", "returnMs"]
+    summary = {name: {"n": len(items), **{key + "P75": percentile([item[key] for item in items], 75)
+                                           for key in keys},
+                      "dashboardRequests": max(item["dashboardRequests"] for item in items), "runs": items}
+               for name, items in runs.items()}
+    print(json.dumps({name: {k: v for k, v in item.items() if k != "runs"}
+                      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")
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 184 - 0
perf/bench/admin-menu-return.py

@@ -0,0 +1,184 @@
+#!/usr/bin/env python3
+"""Paired measurement of returning to each already opened operations page.
+
+The gateway is fixed and synthetic. We time the visible heading and count
+business data requests before and after return, excluding lightweight version
+checks. This is a navigation and redundant-request test, not a live-data test.
+"""
+
+import argparse
+import asyncio
+import json
+import statistics
+import sys
+import threading
+from collections import Counter
+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}
+DASHBOARD = {
+    "identity": {"objectId": "perf-test", "username": "perf-test", "displayName": "运营经理",
+                 "roles": [], "operationsRole": "ops-manager", "isSuperAdmin": False},
+    "date": "2026-09-26", "metrics": [], "alerts": [], "refreshedAt": "2026-09-26T06:00:00.000Z",
+    "liveSourceAvailable": True,
+    "scheduleTrend": {"period": "week", "periodLabel": "近 7 天", "dateFrom": "2026-09-20",
+                      "dateTo": "2026-09-26", "points": [{"key": "2026-09-26", "label": "9月26日",
+                      "shortLabel": "周六", "courseCount": 2, "studentCount": 2,
+                      "completedCount": 1, "isToday": True}], "todayCourseCount": 2,
+                      "todayStudentCount": 2, "totalCourseCount": 2, "totalStudentCount": 2,
+                      "maxValue": 2, "refreshedAt": "2026-09-26T06:00:00.000Z",
+                      "liveSourceAvailable": True},
+}
+
+MODULES = {
+    "dashboard": "运营经理,欢迎回来",
+    "members": "会员管理",
+    "stores": "门店管理",
+    "schedule": "排课中心",
+    "learning-reports": "学习报表",
+    "coaches": "陪练老师",
+    "payroll": "工资结算",
+    "vocabulary": "词库管理",
+    "special-training": "专项训练",
+}
+SELECTORS = {module: ("app-admin-dashboard" if module == "dashboard" else f"app-operations-{module}")
+             for module in MODULES}
+SELECTORS["members"] = "app-operations-users"
+
+
+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):
+    if len(values) == 1:
+        return values[0]
+    return round(statistics.quantiles(sorted(values), n=100, method="inclusive")[74], 1)
+
+
+async def measure(browser, 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()
+    counts = Counter()
+    errors = []
+    page.on("pageerror", lambda error: errors.append(str(error)))
+
+    async def mock_gateway(route):
+        operation = (route.request.post_data_json or {}).get("params", {}).get("operation", "")
+        if operation not in ("meta", "ops/live/versions"):
+            counts[operation] += 1
+        await asyncio.sleep(0.12 if operation != "ops/dashboard/summary" else 0.7)
+        if operation == "meta":
+            payload = {"identity": DASHBOARD["identity"]}
+        elif operation == "ops/dashboard/summary":
+            payload = DASHBOARD
+        elif operation == "ops/live/versions":
+            payload = {"version": "fixed-v1", "scopeVersions": {}, "refreshedAt": "2026-09-26T06:00:00.000Z",
+                       "syncStatus": {"state": "current"}}
+        else:
+            payload = {"items": [], "total": 0, "page": 1, "pageSize": 20}
+        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(url + "/admin/dashboard", wait_until="commit")
+        await page.get_by_role("heading", name=MODULES["dashboard"]).wait_for(timeout=30000)
+        await page.locator(".trend-stat.courses strong").wait_for(timeout=30000)
+        results = {}
+        for module, title in MODULES.items():
+            target = page.locator(f'#admin-sidebar a[href="/admin/{module}"]').last
+            first_counts = counts.copy()
+            if module != "dashboard":
+                await target.click()
+                await page.locator(SELECTORS[module]).wait_for(timeout=30000)
+                await page.wait_for_timeout(220)
+            target_operations = set((counts - first_counts).keys()) if module != "dashboard" else {"ops/dashboard/summary"}
+            await page.locator('#admin-sidebar a[href="/admin/dashboard"]').last.click()
+            await page.locator(SELECTORS["dashboard"]).wait_for(timeout=30000)
+            if module == "dashboard":
+                await page.locator('#admin-sidebar a[href="/admin/schedule"]').last.click()
+                await page.locator(SELECTORS["schedule"]).wait_for(timeout=30000)
+            before_counts = counts.copy()
+            start = await page.evaluate("performance.now()")
+            await target.click()
+            await page.locator(SELECTORS[module]).wait_for(timeout=30000)
+            if module == "dashboard":
+                await page.locator(".trend-stat.courses strong").wait_for(timeout=30000)
+            elapsed = round(await page.evaluate("performance.now()") - start, 1)
+            await page.wait_for_timeout(220)
+            results[module] = {"returnMs": elapsed, "repeatBusinessRequests":
+                               sum((counts - before_counts)[operation] for operation in target_operations)}
+        return {"modules": results, "pageErrors": errors}
+    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)
+    args = parser.parse_args()
+    roots = {"A": args.baseline, "B": args.candidate}
+    servers = {name: serve(path) for name, path in roots.items()}
+    results = {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 (("A", "B") if index % 2 == 0 else ("B", "A")):
+                        results[name].append(await measure(browser, f"http://127.0.0.1:{servers[name].server_port}"))
+            finally:
+                await browser.close()
+    finally:
+        for server in servers.values():
+            server.shutdown()
+    summary = {module: {name: {
+        "p75Ms": p75([run["modules"][module]["returnMs"] for run in results[name]]),
+        "repeatBusinessRequests": max(run["modules"][module]["repeatBusinessRequests"] for run in results[name]),
+    } for name in roots} for module in MODULES}
+    print(json.dumps(summary, ensure_ascii=False, indent=2))
+    failures = [f"{module}: {entry['B']}" for module, entry in summary.items()
+                if entry["B"]["repeatBusinessRequests"] != 0]
+    if any(run["pageErrors"] for run in results["B"]):
+        failures.append("candidate browser page errors")
+    if args.ceilings:
+        limits = json.loads(args.ceilings.read_text(encoding="utf-8"))
+        failures.extend(f"{module}: {summary[module]['B']['p75Ms']} > {maximum} ms"
+                        for module, maximum in limits.items() if summary[module]["B"]["p75Ms"] > maximum)
+    if args.out:
+        args.out.parent.mkdir(parents=True, exist_ok=True)
+        args.out.write_text(json.dumps({"summary": summary, "runs": results}, ensure_ascii=False, indent=2),
+                            encoding="utf-8")
+    if failures:
+        print("Return-navigation ceiling exceeded: " + "; ".join(failures), file=sys.stderr)
+        raise SystemExit(1)
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 96 - 0
perf/bench/dashboard-login-2026-09-26.json

@@ -0,0 +1,96 @@
+{
+  "A": {
+    "n": 10,
+    "loginMsP75": 2389.8,
+    "dashboardRequests": 1,
+    "runs": [
+      {
+        "loginMs": 2383.4,
+        "dashboardRequests": 1
+      },
+      {
+        "loginMs": 2391.4,
+        "dashboardRequests": 1
+      },
+      {
+        "loginMs": 2384.4,
+        "dashboardRequests": 1
+      },
+      {
+        "loginMs": 2378.7,
+        "dashboardRequests": 1
+      },
+      {
+        "loginMs": 2371.1,
+        "dashboardRequests": 1
+      },
+      {
+        "loginMs": 2393.7,
+        "dashboardRequests": 1
+      },
+      {
+        "loginMs": 2385.2,
+        "dashboardRequests": 1
+      },
+      {
+        "loginMs": 2380.0,
+        "dashboardRequests": 1
+      },
+      {
+        "loginMs": 2433.4,
+        "dashboardRequests": 1
+      },
+      {
+        "loginMs": 2377.4,
+        "dashboardRequests": 1
+      }
+    ]
+  },
+  "B": {
+    "n": 10,
+    "loginMsP75": 1872.8,
+    "dashboardRequests": 1,
+    "runs": [
+      {
+        "loginMs": 1856.1,
+        "dashboardRequests": 1
+      },
+      {
+        "loginMs": 1869.7,
+        "dashboardRequests": 1
+      },
+      {
+        "loginMs": 1886.9,
+        "dashboardRequests": 1
+      },
+      {
+        "loginMs": 1891.1,
+        "dashboardRequests": 1
+      },
+      {
+        "loginMs": 1865.3,
+        "dashboardRequests": 1
+      },
+      {
+        "loginMs": 1861.0,
+        "dashboardRequests": 1
+      },
+      {
+        "loginMs": 1865.5,
+        "dashboardRequests": 1
+      },
+      {
+        "loginMs": 1852.3,
+        "dashboardRequests": 1
+      },
+      {
+        "loginMs": 1864.4,
+        "dashboardRequests": 1
+      },
+      {
+        "loginMs": 1873.9,
+        "dashboardRequests": 1
+      }
+    ]
+  }
+}

+ 118 - 0
perf/bench/dashboard-return-2026-09-26.json

@@ -0,0 +1,118 @@
+{
+  "A": {
+    "n": 10,
+    "firstMsP75": 2403.3,
+    "returnMsP75": 1326.1,
+    "dashboardRequests": 2,
+    "runs": [
+      {
+        "firstMs": 2414.5,
+        "returnMs": 1324.9,
+        "dashboardRequests": 2
+      },
+      {
+        "firstMs": 2404.9,
+        "returnMs": 1347.6,
+        "dashboardRequests": 2
+      },
+      {
+        "firstMs": 2398.7,
+        "returnMs": 1323.5,
+        "dashboardRequests": 2
+      },
+      {
+        "firstMs": 2397.6,
+        "returnMs": 1315.0,
+        "dashboardRequests": 2
+      },
+      {
+        "firstMs": 2389.2,
+        "returnMs": 1327.7,
+        "dashboardRequests": 2
+      },
+      {
+        "firstMs": 2428.4,
+        "returnMs": 1320.3,
+        "dashboardRequests": 2
+      },
+      {
+        "firstMs": 2391.1,
+        "returnMs": 1326.5,
+        "dashboardRequests": 2
+      },
+      {
+        "firstMs": 2388.2,
+        "returnMs": 1311.5,
+        "dashboardRequests": 2
+      },
+      {
+        "firstMs": 2391,
+        "returnMs": 1316,
+        "dashboardRequests": 2
+      },
+      {
+        "firstMs": 2392.4,
+        "returnMs": 1317.1,
+        "dashboardRequests": 2
+      }
+    ]
+  },
+  "B": {
+    "n": 10,
+    "firstMsP75": 2397.4,
+    "returnMsP75": 60.5,
+    "dashboardRequests": 1,
+    "runs": [
+      {
+        "firstMs": 2388.9,
+        "returnMs": 61.1,
+        "dashboardRequests": 1
+      },
+      {
+        "firstMs": 2391.4,
+        "returnMs": 60.8,
+        "dashboardRequests": 1
+      },
+      {
+        "firstMs": 2388.9,
+        "returnMs": 59.6,
+        "dashboardRequests": 1
+      },
+      {
+        "firstMs": 2404.1,
+        "returnMs": 51.4,
+        "dashboardRequests": 1
+      },
+      {
+        "firstMs": 2390,
+        "returnMs": 61.0,
+        "dashboardRequests": 1
+      },
+      {
+        "firstMs": 2390.4,
+        "returnMs": 58.4,
+        "dashboardRequests": 1
+      },
+      {
+        "firstMs": 2413.1,
+        "returnMs": 58.4,
+        "dashboardRequests": 1
+      },
+      {
+        "firstMs": 2388.3,
+        "returnMs": 54.5,
+        "dashboardRequests": 1
+      },
+      {
+        "firstMs": 2399.1,
+        "returnMs": 58.2,
+        "dashboardRequests": 1
+      },
+      {
+        "firstMs": 2392.5,
+        "returnMs": 56.3,
+        "dashboardRequests": 1
+      }
+    ]
+  }
+}

+ 920 - 0
perf/bench/menu-return-2026-09-26.json

@@ -0,0 +1,920 @@
+{
+  "summary": {
+    "dashboard": {
+      "A": {
+        "p75Ms": 845.0,
+        "repeatBusinessRequests": 1
+      },
+      "B": {
+        "p75Ms": 58.6,
+        "repeatBusinessRequests": 0
+      }
+    },
+    "members": {
+      "A": {
+        "p75Ms": 70.4,
+        "repeatBusinessRequests": 1
+      },
+      "B": {
+        "p75Ms": 53.7,
+        "repeatBusinessRequests": 0
+      }
+    },
+    "stores": {
+      "A": {
+        "p75Ms": 62.8,
+        "repeatBusinessRequests": 1
+      },
+      "B": {
+        "p75Ms": 53.5,
+        "repeatBusinessRequests": 0
+      }
+    },
+    "schedule": {
+      "A": {
+        "p75Ms": 70.7,
+        "repeatBusinessRequests": 1
+      },
+      "B": {
+        "p75Ms": 58.0,
+        "repeatBusinessRequests": 0
+      }
+    },
+    "learning-reports": {
+      "A": {
+        "p75Ms": 61.3,
+        "repeatBusinessRequests": 1
+      },
+      "B": {
+        "p75Ms": 53.9,
+        "repeatBusinessRequests": 0
+      }
+    },
+    "coaches": {
+      "A": {
+        "p75Ms": 59.5,
+        "repeatBusinessRequests": 1
+      },
+      "B": {
+        "p75Ms": 50.4,
+        "repeatBusinessRequests": 0
+      }
+    },
+    "payroll": {
+      "A": {
+        "p75Ms": 54.8,
+        "repeatBusinessRequests": 5
+      },
+      "B": {
+        "p75Ms": 53.9,
+        "repeatBusinessRequests": 0
+      }
+    },
+    "vocabulary": {
+      "A": {
+        "p75Ms": 66.4,
+        "repeatBusinessRequests": 2
+      },
+      "B": {
+        "p75Ms": 49.2,
+        "repeatBusinessRequests": 0
+      }
+    },
+    "special-training": {
+      "A": {
+        "p75Ms": 62.7,
+        "repeatBusinessRequests": 1
+      },
+      "B": {
+        "p75Ms": 48.9,
+        "repeatBusinessRequests": 0
+      }
+    }
+  },
+  "runs": {
+    "A": [
+      {
+        "modules": {
+          "dashboard": {
+            "returnMs": 838.5,
+            "repeatBusinessRequests": 1
+          },
+          "members": {
+            "returnMs": 63.9,
+            "repeatBusinessRequests": 1
+          },
+          "stores": {
+            "returnMs": 58.5,
+            "repeatBusinessRequests": 1
+          },
+          "schedule": {
+            "returnMs": 71.1,
+            "repeatBusinessRequests": 1
+          },
+          "learning-reports": {
+            "returnMs": 59.4,
+            "repeatBusinessRequests": 1
+          },
+          "coaches": {
+            "returnMs": 63.6,
+            "repeatBusinessRequests": 1
+          },
+          "payroll": {
+            "returnMs": 49.1,
+            "repeatBusinessRequests": 5
+          },
+          "vocabulary": {
+            "returnMs": 65.8,
+            "repeatBusinessRequests": 2
+          },
+          "special-training": {
+            "returnMs": 62.9,
+            "repeatBusinessRequests": 1
+          }
+        },
+        "pageErrors": []
+      },
+      {
+        "modules": {
+          "dashboard": {
+            "returnMs": 839.0,
+            "repeatBusinessRequests": 1
+          },
+          "members": {
+            "returnMs": 70.8,
+            "repeatBusinessRequests": 1
+          },
+          "stores": {
+            "returnMs": 54.7,
+            "repeatBusinessRequests": 1
+          },
+          "schedule": {
+            "returnMs": 67.1,
+            "repeatBusinessRequests": 1
+          },
+          "learning-reports": {
+            "returnMs": 55.7,
+            "repeatBusinessRequests": 1
+          },
+          "coaches": {
+            "returnMs": 48.7,
+            "repeatBusinessRequests": 1
+          },
+          "payroll": {
+            "returnMs": 56.0,
+            "repeatBusinessRequests": 5
+          },
+          "vocabulary": {
+            "returnMs": 66.4,
+            "repeatBusinessRequests": 2
+          },
+          "special-training": {
+            "returnMs": 69.4,
+            "repeatBusinessRequests": 1
+          }
+        },
+        "pageErrors": []
+      },
+      {
+        "modules": {
+          "dashboard": {
+            "returnMs": 841.2,
+            "repeatBusinessRequests": 1
+          },
+          "members": {
+            "returnMs": 62.7,
+            "repeatBusinessRequests": 1
+          },
+          "stores": {
+            "returnMs": 52.3,
+            "repeatBusinessRequests": 1
+          },
+          "schedule": {
+            "returnMs": 69.4,
+            "repeatBusinessRequests": 1
+          },
+          "learning-reports": {
+            "returnMs": 63.5,
+            "repeatBusinessRequests": 1
+          },
+          "coaches": {
+            "returnMs": 60.6,
+            "repeatBusinessRequests": 1
+          },
+          "payroll": {
+            "returnMs": 49.6,
+            "repeatBusinessRequests": 5
+          },
+          "vocabulary": {
+            "returnMs": 68.4,
+            "repeatBusinessRequests": 2
+          },
+          "special-training": {
+            "returnMs": 57.0,
+            "repeatBusinessRequests": 1
+          }
+        },
+        "pageErrors": []
+      },
+      {
+        "modules": {
+          "dashboard": {
+            "returnMs": 843.8,
+            "repeatBusinessRequests": 1
+          },
+          "members": {
+            "returnMs": 69.1,
+            "repeatBusinessRequests": 1
+          },
+          "stores": {
+            "returnMs": 64.2,
+            "repeatBusinessRequests": 1
+          },
+          "schedule": {
+            "returnMs": 70.2,
+            "repeatBusinessRequests": 1
+          },
+          "learning-reports": {
+            "returnMs": 61.9,
+            "repeatBusinessRequests": 1
+          },
+          "coaches": {
+            "returnMs": 51.0,
+            "repeatBusinessRequests": 1
+          },
+          "payroll": {
+            "returnMs": 62.1,
+            "repeatBusinessRequests": 5
+          },
+          "vocabulary": {
+            "returnMs": 60.7,
+            "repeatBusinessRequests": 2
+          },
+          "special-training": {
+            "returnMs": 52.1,
+            "repeatBusinessRequests": 1
+          }
+        },
+        "pageErrors": []
+      },
+      {
+        "modules": {
+          "dashboard": {
+            "returnMs": 851.9,
+            "repeatBusinessRequests": 1
+          },
+          "members": {
+            "returnMs": 71.9,
+            "repeatBusinessRequests": 1
+          },
+          "stores": {
+            "returnMs": 58.5,
+            "repeatBusinessRequests": 1
+          },
+          "schedule": {
+            "returnMs": 70.3,
+            "repeatBusinessRequests": 1
+          },
+          "learning-reports": {
+            "returnMs": 52.2,
+            "repeatBusinessRequests": 1
+          },
+          "coaches": {
+            "returnMs": 59.4,
+            "repeatBusinessRequests": 1
+          },
+          "payroll": {
+            "returnMs": 34.0,
+            "repeatBusinessRequests": 5
+          },
+          "vocabulary": {
+            "returnMs": 62.8,
+            "repeatBusinessRequests": 2
+          },
+          "special-training": {
+            "returnMs": 52.6,
+            "repeatBusinessRequests": 1
+          }
+        },
+        "pageErrors": []
+      },
+      {
+        "modules": {
+          "dashboard": {
+            "returnMs": 839.1,
+            "repeatBusinessRequests": 1
+          },
+          "members": {
+            "returnMs": 69.1,
+            "repeatBusinessRequests": 1
+          },
+          "stores": {
+            "returnMs": 65.6,
+            "repeatBusinessRequests": 1
+          },
+          "schedule": {
+            "returnMs": 70.8,
+            "repeatBusinessRequests": 1
+          },
+          "learning-reports": {
+            "returnMs": 52.9,
+            "repeatBusinessRequests": 1
+          },
+          "coaches": {
+            "returnMs": 59.5,
+            "repeatBusinessRequests": 1
+          },
+          "payroll": {
+            "returnMs": 50.7,
+            "repeatBusinessRequests": 5
+          },
+          "vocabulary": {
+            "returnMs": 63.3,
+            "repeatBusinessRequests": 2
+          },
+          "special-training": {
+            "returnMs": 59.2,
+            "repeatBusinessRequests": 1
+          }
+        },
+        "pageErrors": []
+      },
+      {
+        "modules": {
+          "dashboard": {
+            "returnMs": 843.3,
+            "repeatBusinessRequests": 1
+          },
+          "members": {
+            "returnMs": 65.8,
+            "repeatBusinessRequests": 1
+          },
+          "stores": {
+            "returnMs": 64.9,
+            "repeatBusinessRequests": 1
+          },
+          "schedule": {
+            "returnMs": 69.4,
+            "repeatBusinessRequests": 1
+          },
+          "learning-reports": {
+            "returnMs": 63.1,
+            "repeatBusinessRequests": 1
+          },
+          "coaches": {
+            "returnMs": 58.9,
+            "repeatBusinessRequests": 1
+          },
+          "payroll": {
+            "returnMs": 51.3,
+            "repeatBusinessRequests": 5
+          },
+          "vocabulary": {
+            "returnMs": 61.2,
+            "repeatBusinessRequests": 2
+          },
+          "special-training": {
+            "returnMs": 64.0,
+            "repeatBusinessRequests": 1
+          }
+        },
+        "pageErrors": []
+      },
+      {
+        "modules": {
+          "dashboard": {
+            "returnMs": 849.7,
+            "repeatBusinessRequests": 1
+          },
+          "members": {
+            "returnMs": 63.7,
+            "repeatBusinessRequests": 1
+          },
+          "stores": {
+            "returnMs": 49.4,
+            "repeatBusinessRequests": 1
+          },
+          "schedule": {
+            "returnMs": 69.1,
+            "repeatBusinessRequests": 1
+          },
+          "learning-reports": {
+            "returnMs": 52.2,
+            "repeatBusinessRequests": 1
+          },
+          "coaches": {
+            "returnMs": 59.1,
+            "repeatBusinessRequests": 1
+          },
+          "payroll": {
+            "returnMs": 44.3,
+            "repeatBusinessRequests": 5
+          },
+          "vocabulary": {
+            "returnMs": 66.7,
+            "repeatBusinessRequests": 2
+          },
+          "special-training": {
+            "returnMs": 36.5,
+            "repeatBusinessRequests": 1
+          }
+        },
+        "pageErrors": []
+      },
+      {
+        "modules": {
+          "dashboard": {
+            "returnMs": 845.4,
+            "repeatBusinessRequests": 1
+          },
+          "members": {
+            "returnMs": 72.2,
+            "repeatBusinessRequests": 1
+          },
+          "stores": {
+            "returnMs": 56.8,
+            "repeatBusinessRequests": 1
+          },
+          "schedule": {
+            "returnMs": 72.9,
+            "repeatBusinessRequests": 1
+          },
+          "learning-reports": {
+            "returnMs": 51.3,
+            "repeatBusinessRequests": 1
+          },
+          "coaches": {
+            "returnMs": 58.6,
+            "repeatBusinessRequests": 1
+          },
+          "payroll": {
+            "returnMs": 69.5,
+            "repeatBusinessRequests": 5
+          },
+          "vocabulary": {
+            "returnMs": 51.4,
+            "repeatBusinessRequests": 2
+          },
+          "special-training": {
+            "returnMs": 62.2,
+            "repeatBusinessRequests": 1
+          }
+        },
+        "pageErrors": []
+      },
+      {
+        "modules": {
+          "dashboard": {
+            "returnMs": 842.8,
+            "repeatBusinessRequests": 1
+          },
+          "members": {
+            "returnMs": 66.3,
+            "repeatBusinessRequests": 1
+          },
+          "stores": {
+            "returnMs": 44.8,
+            "repeatBusinessRequests": 1
+          },
+          "schedule": {
+            "returnMs": 67.7,
+            "repeatBusinessRequests": 1
+          },
+          "learning-reports": {
+            "returnMs": 50.7,
+            "repeatBusinessRequests": 1
+          },
+          "coaches": {
+            "returnMs": 58.9,
+            "repeatBusinessRequests": 1
+          },
+          "payroll": {
+            "returnMs": 48.7,
+            "repeatBusinessRequests": 5
+          },
+          "vocabulary": {
+            "returnMs": 66.4,
+            "repeatBusinessRequests": 2
+          },
+          "special-training": {
+            "returnMs": 59.7,
+            "repeatBusinessRequests": 1
+          }
+        },
+        "pageErrors": []
+      }
+    ],
+    "B": [
+      {
+        "modules": {
+          "dashboard": {
+            "returnMs": 55.8,
+            "repeatBusinessRequests": 0
+          },
+          "members": {
+            "returnMs": 53.1,
+            "repeatBusinessRequests": 0
+          },
+          "stores": {
+            "returnMs": 51.3,
+            "repeatBusinessRequests": 0
+          },
+          "schedule": {
+            "returnMs": 55.8,
+            "repeatBusinessRequests": 0
+          },
+          "learning-reports": {
+            "returnMs": 53.2,
+            "repeatBusinessRequests": 0
+          },
+          "coaches": {
+            "returnMs": 50.3,
+            "repeatBusinessRequests": 0
+          },
+          "payroll": {
+            "returnMs": 56.8,
+            "repeatBusinessRequests": 0
+          },
+          "vocabulary": {
+            "returnMs": 47.5,
+            "repeatBusinessRequests": 0
+          },
+          "special-training": {
+            "returnMs": 44.4,
+            "repeatBusinessRequests": 0
+          }
+        },
+        "pageErrors": []
+      },
+      {
+        "modules": {
+          "dashboard": {
+            "returnMs": 57.9,
+            "repeatBusinessRequests": 0
+          },
+          "members": {
+            "returnMs": 53.7,
+            "repeatBusinessRequests": 0
+          },
+          "stores": {
+            "returnMs": 51.8,
+            "repeatBusinessRequests": 0
+          },
+          "schedule": {
+            "returnMs": 53.7,
+            "repeatBusinessRequests": 0
+          },
+          "learning-reports": {
+            "returnMs": 49.3,
+            "repeatBusinessRequests": 0
+          },
+          "coaches": {
+            "returnMs": 49.2,
+            "repeatBusinessRequests": 0
+          },
+          "payroll": {
+            "returnMs": 57.1,
+            "repeatBusinessRequests": 0
+          },
+          "vocabulary": {
+            "returnMs": 46.8,
+            "repeatBusinessRequests": 0
+          },
+          "special-training": {
+            "returnMs": 49.3,
+            "repeatBusinessRequests": 0
+          }
+        },
+        "pageErrors": []
+      },
+      {
+        "modules": {
+          "dashboard": {
+            "returnMs": 60.2,
+            "repeatBusinessRequests": 0
+          },
+          "members": {
+            "returnMs": 53.2,
+            "repeatBusinessRequests": 0
+          },
+          "stores": {
+            "returnMs": 52.7,
+            "repeatBusinessRequests": 0
+          },
+          "schedule": {
+            "returnMs": 54.3,
+            "repeatBusinessRequests": 0
+          },
+          "learning-reports": {
+            "returnMs": 54.8,
+            "repeatBusinessRequests": 0
+          },
+          "coaches": {
+            "returnMs": 50.3,
+            "repeatBusinessRequests": 0
+          },
+          "payroll": {
+            "returnMs": 51.8,
+            "repeatBusinessRequests": 0
+          },
+          "vocabulary": {
+            "returnMs": 51.8,
+            "repeatBusinessRequests": 0
+          },
+          "special-training": {
+            "returnMs": 46.1,
+            "repeatBusinessRequests": 0
+          }
+        },
+        "pageErrors": []
+      },
+      {
+        "modules": {
+          "dashboard": {
+            "returnMs": 55.6,
+            "repeatBusinessRequests": 0
+          },
+          "members": {
+            "returnMs": 52.3,
+            "repeatBusinessRequests": 0
+          },
+          "stores": {
+            "returnMs": 52.3,
+            "repeatBusinessRequests": 0
+          },
+          "schedule": {
+            "returnMs": 54.3,
+            "repeatBusinessRequests": 0
+          },
+          "learning-reports": {
+            "returnMs": 56.4,
+            "repeatBusinessRequests": 0
+          },
+          "coaches": {
+            "returnMs": 50.4,
+            "repeatBusinessRequests": 0
+          },
+          "payroll": {
+            "returnMs": 50.6,
+            "repeatBusinessRequests": 0
+          },
+          "vocabulary": {
+            "returnMs": 46.6,
+            "repeatBusinessRequests": 0
+          },
+          "special-training": {
+            "returnMs": 47.7,
+            "repeatBusinessRequests": 0
+          }
+        },
+        "pageErrors": []
+      },
+      {
+        "modules": {
+          "dashboard": {
+            "returnMs": 57.2,
+            "repeatBusinessRequests": 0
+          },
+          "members": {
+            "returnMs": 65.0,
+            "repeatBusinessRequests": 0
+          },
+          "stores": {
+            "returnMs": 53.3,
+            "repeatBusinessRequests": 0
+          },
+          "schedule": {
+            "returnMs": 58.7,
+            "repeatBusinessRequests": 0
+          },
+          "learning-reports": {
+            "returnMs": 52.1,
+            "repeatBusinessRequests": 0
+          },
+          "coaches": {
+            "returnMs": 47.0,
+            "repeatBusinessRequests": 0
+          },
+          "payroll": {
+            "returnMs": 52.3,
+            "repeatBusinessRequests": 0
+          },
+          "vocabulary": {
+            "returnMs": 48.8,
+            "repeatBusinessRequests": 0
+          },
+          "special-training": {
+            "returnMs": 46.4,
+            "repeatBusinessRequests": 0
+          }
+        },
+        "pageErrors": []
+      },
+      {
+        "modules": {
+          "dashboard": {
+            "returnMs": 57.5,
+            "repeatBusinessRequests": 0
+          },
+          "members": {
+            "returnMs": 53.7,
+            "repeatBusinessRequests": 0
+          },
+          "stores": {
+            "returnMs": 53.5,
+            "repeatBusinessRequests": 0
+          },
+          "schedule": {
+            "returnMs": 54.2,
+            "repeatBusinessRequests": 0
+          },
+          "learning-reports": {
+            "returnMs": 52.7,
+            "repeatBusinessRequests": 0
+          },
+          "coaches": {
+            "returnMs": 50.4,
+            "repeatBusinessRequests": 0
+          },
+          "payroll": {
+            "returnMs": 50.8,
+            "repeatBusinessRequests": 0
+          },
+          "vocabulary": {
+            "returnMs": 48.0,
+            "repeatBusinessRequests": 0
+          },
+          "special-training": {
+            "returnMs": 52.1,
+            "repeatBusinessRequests": 0
+          }
+        },
+        "pageErrors": []
+      },
+      {
+        "modules": {
+          "dashboard": {
+            "returnMs": 58.9,
+            "repeatBusinessRequests": 0
+          },
+          "members": {
+            "returnMs": 53,
+            "repeatBusinessRequests": 0
+          },
+          "stores": {
+            "returnMs": 54.2,
+            "repeatBusinessRequests": 0
+          },
+          "schedule": {
+            "returnMs": 67.1,
+            "repeatBusinessRequests": 0
+          },
+          "learning-reports": {
+            "returnMs": 50.4,
+            "repeatBusinessRequests": 0
+          },
+          "coaches": {
+            "returnMs": 49.4,
+            "repeatBusinessRequests": 0
+          },
+          "payroll": {
+            "returnMs": 54.1,
+            "repeatBusinessRequests": 0
+          },
+          "vocabulary": {
+            "returnMs": 47.0,
+            "repeatBusinessRequests": 0
+          },
+          "special-training": {
+            "returnMs": 49.9,
+            "repeatBusinessRequests": 0
+          }
+        },
+        "pageErrors": []
+      },
+      {
+        "modules": {
+          "dashboard": {
+            "returnMs": 54.9,
+            "repeatBusinessRequests": 0
+          },
+          "members": {
+            "returnMs": 53.3,
+            "repeatBusinessRequests": 0
+          },
+          "stores": {
+            "returnMs": 55.4,
+            "repeatBusinessRequests": 0
+          },
+          "schedule": {
+            "returnMs": 52.3,
+            "repeatBusinessRequests": 0
+          },
+          "learning-reports": {
+            "returnMs": 53.9,
+            "repeatBusinessRequests": 0
+          },
+          "coaches": {
+            "returnMs": 52.2,
+            "repeatBusinessRequests": 0
+          },
+          "payroll": {
+            "returnMs": 52.0,
+            "repeatBusinessRequests": 0
+          },
+          "vocabulary": {
+            "returnMs": 43,
+            "repeatBusinessRequests": 0
+          },
+          "special-training": {
+            "returnMs": 43.2,
+            "repeatBusinessRequests": 0
+          }
+        },
+        "pageErrors": []
+      },
+      {
+        "modules": {
+          "dashboard": {
+            "returnMs": 58.8,
+            "repeatBusinessRequests": 0
+          },
+          "members": {
+            "returnMs": 52.6,
+            "repeatBusinessRequests": 0
+          },
+          "stores": {
+            "returnMs": 51.2,
+            "repeatBusinessRequests": 0
+          },
+          "schedule": {
+            "returnMs": 51.6,
+            "repeatBusinessRequests": 0
+          },
+          "learning-reports": {
+            "returnMs": 51.8,
+            "repeatBusinessRequests": 0
+          },
+          "coaches": {
+            "returnMs": 50.9,
+            "repeatBusinessRequests": 0
+          },
+          "payroll": {
+            "returnMs": 53.1,
+            "repeatBusinessRequests": 0
+          },
+          "vocabulary": {
+            "returnMs": 49.3,
+            "repeatBusinessRequests": 0
+          },
+          "special-training": {
+            "returnMs": 45.4,
+            "repeatBusinessRequests": 0
+          }
+        },
+        "pageErrors": []
+      },
+      {
+        "modules": {
+          "dashboard": {
+            "returnMs": 52.9,
+            "repeatBusinessRequests": 0
+          },
+          "members": {
+            "returnMs": 54.0,
+            "repeatBusinessRequests": 0
+          },
+          "stores": {
+            "returnMs": 51.1,
+            "repeatBusinessRequests": 0
+          },
+          "schedule": {
+            "returnMs": 60.4,
+            "repeatBusinessRequests": 0
+          },
+          "learning-reports": {
+            "returnMs": 53.8,
+            "repeatBusinessRequests": 0
+          },
+          "coaches": {
+            "returnMs": 49.1,
+            "repeatBusinessRequests": 0
+          },
+          "payroll": {
+            "returnMs": 51.9,
+            "repeatBusinessRequests": 0
+          },
+          "vocabulary": {
+            "returnMs": 49.3,
+            "repeatBusinessRequests": 0
+          },
+          "special-training": {
+            "returnMs": 47.3,
+            "repeatBusinessRequests": 0
+          }
+        },
+        "pageErrors": []
+      }
+    ]
+  }
+}

+ 11 - 0
perf/bench/menu-return-ceilings.json

@@ -0,0 +1,11 @@
+{
+  "dashboard": 150,
+  "members": 150,
+  "stores": 150,
+  "schedule": 150,
+  "learning-reports": 150,
+  "coaches": 150,
+  "payroll": 150,
+  "vocabulary": 150,
+  "special-training": 150
+}

+ 18 - 0
perf/基线.md

@@ -27,3 +27,21 @@
 | 专项训练 | 233.2 ms |
 
 词库真正可用的独立基线:目录总数和词条分页总数均显示后 p75 为 1572.5 ms,固定模拟接口分别延迟 150 ms。会员管理与学习报表作为冷启动回退对照,基线分别为 1891.1 ms 与 1877.7 ms。
+
+## 2026-09-26 已加载页面返回基线
+
+基线生产包为优化前的 `/tmp/xiaoshu-dashboard-baseline/browser`,对照同日候选生产包。固定本地网关、同机浏览器、1440×900,A/B 交替各 10 轮。返回页面可见的 p75 和返回时重发的该页业务请求如下;此耗时不表示旧版业务数据已经加载完毕。
+
+| 页面 | 旧版返回 p75 | 旧版重复业务请求 |
+| --- | ---: | ---: |
+| 工作台 | 845.0 ms | 1 |
+| 会员管理 | 70.4 ms | 1 |
+| 门店管理 | 62.8 ms | 1 |
+| 排课中心 | 70.7 ms | 1 |
+| 学习报表 | 61.3 ms | 1 |
+| 陪练老师 | 59.5 ms | 1 |
+| 工资结算 | 54.8 ms | 5 |
+| 词库管理 | 66.4 ms | 2 |
+| 专项训练 | 62.7 ms | 1 |
+
+旧版工作台欢迎内容需要等 700 ms 模拟接口,其他页的标题可在业务接口完成前出现。因此这些页面主要用重复请求归零检验回访收益。

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

@@ -28,3 +28,16 @@
 - 假设:默认查看全部词条时,目录与第一页词条互不依赖,串行等待会增加一个接口往返。
 - 改动:同时请求目录与第一页,分别保留成功结果和错误提示;已有单元测试增加了并发启动断言。
 - 真正可用 p75:1572.5→1389.5 ms,下降 11.6%,配对 10 轮。首屏 JS 468594→473393 字节,仍低于 500 KB 门槛。
+
+## 5. 工作台实际可用和回访
+
+- 首次登录后并行取得工作台数据、预热路由代码;同会话请求合并,保留成功快照。工作台摘要的云端快速路径移除当前页面用不到的工资批次、评价奖励查询,云端改动尚未部署。
+- 固定 1.2 秒工作台接口的配对 10 轮:登录到内容与图表可用 p75 2389.8→1872.8 ms;直接冷开 p75 2403.3→2397.4 ms;离开再返回 p75 1326.1→60.5 ms,摘要请求 2→1。
+- 快照当日且不满 30 秒可直接恢复;超过 30 秒继续显示旧数据并后台更新,错误时不清空内容。
+
+## 6. 九个运营主菜单页面复用
+
+- 假设:同会话内反复打开菜单不应重新创建组件,也不应重发整页业务请求。保留已加载页面实例,恢复其筛选、分页和视图状态。
+- 当前页面每 5 秒轻量检查版本;离开后停止检查,返回时立即补查。版本变化时才刷新。工作台跨日或快照超过 30 秒时后台刷新;内容页保留现有手动刷新。
+- 固定本地网关 A/B 交替各 10 轮:九页的重复业务请求全部归零;工作台返回内容 p75 845.0→58.6 ms,其他页返回可见 p75 48.9–58.0 ms。10 轮浏览器脚本错误均为零。
+- `perf/bench/admin-menu-return.py` 记录原始数据并检查返回导航 p75 不超过 150 ms、重复业务请求为零。页面标题/实例时间只是导航代理;除工作台外未声称业务内容可用 p75 的降幅。

+ 26 - 0
perf/报告.md

@@ -36,3 +36,29 @@ python3 perf/bench/admin-frontend.py --baseline "$BASELINE_BROWSER" --candidate
 运营主菜单只预加载页面代码,不发额外业务接口;登录页、详情页、技术后台、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 字节上限。
 
 验证范围:本地生产构建、后台单元测试、固定模拟接口与网络条件。真实管理员权限、真实数据量、线上网络和部署后的体验尚未测量,因此以上数字不代表线上实际提速。
+
+## 2026-09-26 页面回访
+
+九个运营主菜单页面首次加载后,返回时保留原实例和原筛选状态,不重新请求整页数据。旧版每次返回会重复 1–5 个业务请求;新版九页均为 0。当前页面仍每 5 秒轻量检查版本,离开时暂停,返回时补查;发现数据变化后在后台更新。工作台快照超过 30 秒或日期变化时也在后台更新。词库和专项训练维持页面上的手动刷新。
+
+| 页面 | 旧版返回 p75 | 新版返回 p75 | 重复业务请求 |
+| --- | ---: | ---: | ---: |
+| 工作台 | 845.0 ms | 58.6 ms | 1 → 0 |
+| 会员管理 | 70.4 ms | 53.7 ms | 1 → 0 |
+| 门店管理 | 62.8 ms | 53.5 ms | 1 → 0 |
+| 排课中心 | 70.7 ms | 58.0 ms | 1 → 0 |
+| 学习报表 | 61.3 ms | 53.9 ms | 1 → 0 |
+| 陪练老师 | 59.5 ms | 50.4 ms | 1 → 0 |
+| 工资结算 | 54.8 ms | 53.9 ms | 5 → 0 |
+| 词库管理 | 66.4 ms | 49.2 ms | 2 → 0 |
+| 专项训练 | 62.7 ms | 48.9 ms | 1 → 0 |
+
+工作台计到欢迎内容及趋势图真正可用;其余页面计到原组件可见,旧版业务数据一般仍在加载,不能将表内耗时解释成旧版业务可用时间。测试为同机固定模拟接口、A/B 交替各 10 轮;浏览器脚本错误为零。登录后工作台可用 p75 2389.8→1872.8 ms;直接冷开 2403.3→2397.4 ms。旧版和新版静态包及数据见 `perf/bench/dashboard-*-2026-09-26.json` 与 `menu-return-2026-09-26.json`。
+
+回归命令:
+
+```bash
+python3 perf/bench/admin-menu-return.py --baseline /tmp/xiaoshu-dashboard-baseline/browser --candidate dist/xiaoshu-admin/browser --runs 10 --ceilings perf/bench/menu-return-ceilings.json
+```
+
+本次无真实管理员数据、线上网络或上线后的测量。云端工作台查询精简已通过静态校验,只有在云函数发布后才会在线上生效。

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

@@ -3,21 +3,21 @@ import { adminGuard, operatorManagerGuard, superAdminGuard } from './admin.guard
 
 const operationsChildren: Routes = [
   { path: '', pathMatch: 'full', redirectTo: 'dashboard' },
-  { path: 'dashboard', data: { title: '工作台', parentUrl: null, preloadOperationsMenu: true }, loadComponent: () => import('./pages/admin-dashboard.component').then((module) => module.AdminDashboardComponent) },
+  { path: 'dashboard', data: { title: '工作台', parentUrl: null, preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/admin-dashboard.component').then((module) => module.AdminDashboardComponent) },
   { path: 'users', pathMatch: 'full', redirectTo: 'members' },
   { path: 'users/:objectId', redirectTo: 'members/:objectId' },
-  { path: 'members', data: { title: '会员管理', parentUrl: '/admin/dashboard', preloadOperationsMenu: true }, loadComponent: () => import('./pages/operations-users.component').then((module) => module.OperationsUsersComponent) },
+  { path: 'members', data: { title: '会员管理', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: 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: 'stores', data: { title: '门店管理', parentUrl: '/admin/dashboard', preloadOperationsMenu: true }, loadComponent: () => import('./pages/operations-stores.component').then((module) => module.OperationsStoresComponent) },
+  { path: 'stores', data: { title: '门店管理', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: 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: '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: 'schedule', data: { title: '排课中心', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-schedule.component').then((module) => module.OperationsScheduleComponent) },
+  { path: 'learning-reports', data: { title: '学习报表', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-learning-reports.component').then((module) => module.OperationsLearningReportsComponent) },
   { path: 'appointments', pathMatch: 'full', redirectTo: 'schedule' },
-  { path: 'coaches', data: { title: '陪练老师', parentUrl: '/admin/dashboard', preloadOperationsMenu: true }, loadComponent: () => import('./pages/operations-coaches.component').then((module) => module.OperationsCoachesComponent) },
+  { path: 'coaches', data: { title: '陪练老师', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: 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: '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: 'payroll', data: { title: '工资结算', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-payroll.component').then((module) => module.OperationsPayrollComponent) },
+  { path: 'vocabulary', data: { title: '词库管理', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/operations-vocabulary.component').then((module) => module.OperationsVocabularyComponent) },
+  { path: 'special-training', data: { title: '专项训练', parentUrl: '/admin/dashboard', preloadOperationsMenu: true, reuseOperationsMenu: 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: '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) },

+ 6 - 1
projects/xiaoshu-admin/src/app/app.component.spec.ts

@@ -1,12 +1,17 @@
 import { TestBed } from '@angular/core/testing';
 import { provideRouter } from '@angular/router';
 import { AppComponent } from './app.component';
+import { AdminSessionService } from './admin-session.service';
+import { OperationsService } from './operations.service';
 
 describe('Admin AppComponent', () => {
   it('creates the independent admin application shell', async () => {
     await TestBed.configureTestingModule({
       imports: [AppComponent],
-      providers: [provideRouter([])],
+      providers: [provideRouter([]),
+        { provide: AdminSessionService, useValue: { isAuthenticated: () => false } },
+        { provide: OperationsService, useValue: { dashboard: jasmine.createSpy('dashboard') } },
+      ],
     }).compileComponents();
 
     expect(TestBed.createComponent(AppComponent).componentInstance).toBeTruthy();

+ 12 - 2
projects/xiaoshu-admin/src/app/app.component.ts

@@ -1,5 +1,7 @@
-import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
 import { RouterOutlet } from '@angular/router';
+import { AdminSessionService } from './admin-session.service';
+import { OperationsService } from './operations.service';
 
 @Component({
   selector: 'app-root',
@@ -9,4 +11,12 @@ import { RouterOutlet } from '@angular/router';
   styles: ':host { display: block; min-height: 100%; }',
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class AppComponent {}
+export class AppComponent {
+  constructor() {
+    const sessions = inject(AdminSessionService);
+    const operations = inject(OperationsService);
+    if (sessions.isAuthenticated() && globalThis.location.pathname === '/admin/dashboard') {
+      void operations.dashboard().catch(() => undefined);
+    }
+  }
+}

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

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

+ 26 - 0
projects/xiaoshu-admin/src/app/operations-live-refresh.service.spec.ts

@@ -1,5 +1,6 @@
 import { fakeAsync, flushMicrotasks, TestBed, tick } from '@angular/core/testing';
 import { Subject } from 'rxjs';
+import { NavigationEnd, Router } from '@angular/router';
 import { OperationsService } from './operations.service';
 import { OperationsLiveRefreshService } from './operations-live-refresh.service';
 import { ADMIN_SESSION_KEY } from './admin.constants';
@@ -7,15 +8,20 @@ import { ADMIN_SESSION_KEY } from './admin.constants';
 describe('OperationsLiveRefreshService', () => {
   let changes: Subject<'local' | 'broadcast'>;
   let request: jasmine.Spy;
+  let routerEvents: Subject<NavigationEnd>;
+  let router: { url: string; events: Subject<NavigationEnd>; getCurrentNavigation: () => null; parseUrl: (url: string) => { toString: () => string } };
 
   beforeEach(() => {
     changes = new Subject();
     request = jasmine.createSpy('request');
+    routerEvents = new Subject<NavigationEnd>();
+    router = { url: '/admin/members', events: routerEvents, getCurrentNavigation: () => null, parseUrl: (url) => ({ toString: () => url }) };
     sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ sessionToken: 'test-session' }));
     TestBed.configureTestingModule({
       providers: [
         OperationsLiveRefreshService,
         { provide: OperationsService, useValue: { request, dataChanges$: changes } },
+        { provide: Router, useValue: router },
       ],
     });
   });
@@ -78,6 +84,26 @@ describe('OperationsLiveRefreshService', () => {
     expect(refresh).toHaveBeenCalledTimes(2);
     stop();
   }));
+
+  it('pauses a detached page and checks for changes when it is reattached', fakeAsync(() => {
+    request.and.returnValues(Promise.resolve(version('v1')), Promise.resolve(version('v2')));
+    const refresh = jasmine.createSpy('refresh');
+    const stop = TestBed.inject(OperationsLiveRefreshService).watch(['learning'], refresh);
+    flushMicrotasks();
+    router.url = '/admin/schedule';
+    routerEvents.next(new NavigationEnd(1, '/admin/members', '/admin/schedule'));
+    tick(10_000);
+    flushMicrotasks();
+    expect(request).toHaveBeenCalledTimes(1);
+    changes.next('local');
+    expect(refresh).not.toHaveBeenCalled();
+    router.url = '/admin/members';
+    routerEvents.next(new NavigationEnd(2, '/admin/schedule', '/admin/members'));
+    flushMicrotasks();
+    expect(request).toHaveBeenCalledTimes(2);
+    expect(refresh).toHaveBeenCalledTimes(1);
+    stop();
+  }));
 });
 
 function version(value: string) {

+ 8 - 0
projects/xiaoshu-admin/src/app/operations-live-refresh.service.ts

@@ -1,6 +1,8 @@
 import { DOCUMENT } from '@angular/common';
 import { inject, Injectable, NgZone } from '@angular/core';
+import { NavigationEnd, Router } from '@angular/router';
 import { Subscription } from 'rxjs';
+import { filter } from 'rxjs/operators';
 import { OperationsService } from './operations.service';
 import { ADMIN_SESSION_KEY } from './admin.constants';
 
@@ -18,11 +20,14 @@ export class OperationsLiveRefreshService {
   private readonly operations = inject(OperationsService);
   private readonly document = inject(DOCUMENT);
   private readonly zone = inject(NgZone);
+  private readonly router = inject(Router);
 
   watch(scopes: OperationsLiveScope[], refresh: () => boolean | void): () => void {
     let stopped = false;
     let checking = false;
     let version = '';
+    const navigation = this.router.getCurrentNavigation();
+    const ownerPath = (navigation?.finalUrl || navigation?.extractedUrl || this.router.parseUrl(this.router.url)).toString().split('?')[0];
     const authenticated = () => {
       try {
         const stored = globalThis.sessionStorage?.getItem(ADMIN_SESSION_KEY);
@@ -32,6 +37,7 @@ export class OperationsLiveRefreshService {
       }
     };
     const visible = () => authenticated()
+      && this.router.url.split('?')[0] === ownerPath
       && this.document.visibilityState !== 'hidden'
       && (typeof navigator === 'undefined' || navigator.onLine !== false);
     const check = async () => {
@@ -54,6 +60,7 @@ export class OperationsLiveRefreshService {
       void check();
     }) ?? new Subscription();
     const resume = () => { if (visible()) void check(); };
+    const navigationChanges = this.router.events.pipe(filter((event): event is NavigationEnd => event instanceof NavigationEnd)).subscribe(resume);
     this.document.addEventListener('visibilitychange', resume);
     globalThis.addEventListener?.('pageshow', resume);
     globalThis.addEventListener?.('online', resume);
@@ -62,6 +69,7 @@ export class OperationsLiveRefreshService {
     return () => {
       stopped = true;
       changes.unsubscribe();
+      navigationChanges.unsubscribe();
       globalThis.clearInterval(timer);
       this.document.removeEventListener('visibilitychange', resume);
       globalThis.removeEventListener?.('pageshow', resume);

+ 48 - 0
projects/xiaoshu-admin/src/app/operations-route-reuse.strategy.spec.ts

@@ -0,0 +1,48 @@
+import { ActivatedRouteSnapshot, DetachedRouteHandle } from '@angular/router';
+import { ADMIN_SESSION_KEY } from './admin.constants';
+import { OperationsRouteReuseStrategy } from './operations-route-reuse.strategy';
+
+describe('OperationsRouteReuseStrategy', () => {
+  const route = (path: string, reuseOperationsMenu = true) => ({
+    routeConfig: { path },
+    data: { reuseOperationsMenu },
+  }) as unknown as ActivatedRouteSnapshot;
+  const handle = () => ({ componentRef: {
+    destroy: jasmine.createSpy('destroy'),
+    instance: { onOperationsRouteAttach: jasmine.createSpy('onOperationsRouteAttach') },
+  } }) as unknown as DetachedRouteHandle;
+  let strategy: OperationsRouteReuseStrategy;
+
+  beforeEach(() => {
+    sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ sessionToken: 'session-one' }));
+    strategy = new OperationsRouteReuseStrategy();
+  });
+
+  afterEach(() => sessionStorage.removeItem(ADMIN_SESSION_KEY));
+
+  it('keeps a menu page available for the same signed-in session', () => {
+    const members = route('members');
+    const saved = handle();
+    expect(strategy.shouldDetach(members)).toBeTrue();
+    strategy.store(members, saved);
+    expect(strategy.shouldAttach(members)).toBeTrue();
+    expect(strategy.retrieve(members)).toBe(saved);
+    expect((saved as { componentRef: { instance: { onOperationsRouteAttach: jasmine.Spy } } }).componentRef.instance.onOperationsRouteAttach).toHaveBeenCalledTimes(1);
+    strategy.store(members, null);
+    expect(strategy.shouldAttach(members)).toBeFalse();
+    expect((saved as { componentRef: { destroy: jasmine.Spy } }).componentRef.destroy).not.toHaveBeenCalled();
+  });
+
+  it('destroys saved views after logout or account change', () => {
+    const members = route('members');
+    const saved = handle();
+    strategy.store(members, saved);
+    sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ sessionToken: 'session-two' }));
+    expect(strategy.shouldAttach(members)).toBeFalse();
+    expect((saved as { componentRef: { destroy: jasmine.Spy } }).componentRef.destroy).toHaveBeenCalledTimes(1);
+  });
+
+  it('does not cache detail pages', () => {
+    expect(strategy.shouldDetach(route('members/:objectId', false))).toBeFalse();
+  });
+});

+ 59 - 0
projects/xiaoshu-admin/src/app/operations-route-reuse.strategy.ts

@@ -0,0 +1,59 @@
+import { ComponentRef, Injectable } from '@angular/core';
+import { ActivatedRouteSnapshot, BaseRouteReuseStrategy, DetachedRouteHandle } from '@angular/router';
+import { ADMIN_SESSION_KEY } from './admin.constants';
+
+/** Keep the nine main operations views alive for fast back-and-forth navigation. */
+@Injectable()
+export class OperationsRouteReuseStrategy extends BaseRouteReuseStrategy {
+  private readonly saved = new Map<string, { token: string; handle: DetachedRouteHandle }>();
+  private activeToken = '';
+
+  override shouldDetach(route: ActivatedRouteSnapshot): boolean {
+    const token = this.token();
+    this.clearIfSessionChanged(token);
+    return Boolean(token && route.data['reuseOperationsMenu']);
+  }
+
+  override store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle | null): void {
+    const key = route.routeConfig?.path || '';
+    if (!route.data['reuseOperationsMenu']) return;
+    if (!handle) { this.saved.delete(key); return; }
+    const token = this.token();
+    if (!token) { this.destroy(handle); return; }
+    this.clearIfSessionChanged(token);
+    const previous = this.saved.get(key);
+    if (previous) this.destroy(previous.handle);
+    this.saved.set(key, { token, handle });
+  }
+
+  override shouldAttach(route: ActivatedRouteSnapshot): boolean {
+    const token = this.token();
+    this.clearIfSessionChanged(token);
+    return Boolean(token && route.data['reuseOperationsMenu'] && this.saved.get(route.routeConfig?.path || '')?.token === token);
+  }
+
+  override retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null {
+    const saved = this.saved.get(route.routeConfig?.path || '');
+    if (!saved || saved.token !== this.token()) return null;
+    const component = (saved.handle as { componentRef?: ComponentRef<unknown> }).componentRef?.instance as
+      { onOperationsRouteAttach?: () => void } | undefined;
+    component?.onOperationsRouteAttach?.();
+    return saved.handle;
+  }
+
+  private token(): string {
+    try { return JSON.parse(sessionStorage.getItem(ADMIN_SESSION_KEY) || 'null')?.sessionToken || ''; }
+    catch { return ''; }
+  }
+
+  private clearIfSessionChanged(token: string): void {
+    if (token === this.activeToken) return;
+    for (const { handle } of this.saved.values()) this.destroy(handle);
+    this.saved.clear();
+    this.activeToken = token;
+  }
+
+  private destroy(handle: DetachedRouteHandle): void {
+    (handle as { componentRef?: ComponentRef<unknown> }).componentRef?.destroy();
+  }
+}

+ 31 - 2
projects/xiaoshu-admin/src/app/operations.service.spec.ts

@@ -1,17 +1,46 @@
 import { TestBed } from '@angular/core/testing';
 import { CloudFunctionsService } from './cloud-functions.service';
+import { AdminSessionService } from './admin-session.service';
 import { OperationsService } from './operations.service';
+import { Subject } from 'rxjs';
 
 describe('OperationsService', () => {
   let service: OperationsService;
   let functions: jasmine.SpyObj<CloudFunctionsService>;
+  let sessionToken = 'test-session';
+  let changes: Subject<'local' | 'broadcast'>;
 
   beforeEach(() => {
-    functions = jasmine.createSpyObj<CloudFunctionsService>('CloudFunctionsService', ['operations']);
-    TestBed.configureTestingModule({ providers: [OperationsService, { provide: CloudFunctionsService, useValue: functions }] });
+    sessionToken = 'test-session';
+    changes = new Subject<'local' | 'broadcast'>();
+    functions = jasmine.createSpyObj<CloudFunctionsService>('CloudFunctionsService', ['operations'], { dataChanges$: changes });
+    TestBed.configureTestingModule({ providers: [OperationsService, { provide: CloudFunctionsService, useValue: functions }, { provide: AdminSessionService, useValue: { session: () => ({ sessionToken }) } }] });
     service = TestBed.inject(OperationsService);
   });
 
+  it('keeps a same-session dashboard snapshot and invalidates it after an operations change', async () => {
+    const data = { date: new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }), identity: {}, metrics: [], alerts: [] } as any;
+    functions.operations.and.resolveTo(data);
+    await service.dashboard();
+    expect(service.dashboardSnapshot()?.data).toBe(data);
+    sessionToken = 'another-session';
+    expect(service.dashboardSnapshot()).toBeNull();
+    sessionToken = 'test-session';
+    changes.next('local');
+    expect(service.dashboardSnapshot()).toBeNull();
+  });
+
+  it('shares an in-flight dashboard request with an early prefetch', async () => {
+    let finish!: (data: any) => void;
+    functions.operations.and.returnValue(new Promise((resolve) => { finish = resolve; }));
+    const first = service.dashboard();
+    const second = service.dashboard();
+    expect(first).toBe(second);
+    expect(functions.operations).toHaveBeenCalledTimes(1);
+    finish({ date: new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }), identity: {}, metrics: [], alerts: [] });
+    await first;
+  });
+
   it('uses the dedicated operations gateway for user search', async () => {
     functions.operations.and.resolveTo({ page: 1, pageSize: 20, total: 0, items: [] });
     await service.users(1, 20, '万宇昊', { status: 'active' }, { field: 'registeredAt', direction: 'desc' });

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

@@ -1,16 +1,44 @@
 import { inject, Injectable } from '@angular/core';
 import { CoachAvailability, CoachDetailData, CoachPage, LearningReportDetail, LearningReportPage, LegacySyncRunResult, LegacySyncStatus, MediaUploadTicket, MemberImportPreview, MemberImportResult, MemberListSort, MemberPage, OperationsDashboardData, OperationsPage, OperationsUserDetail, PayRate, PayrollDetail, PayrollExportData, PayrollOverview, PayrollSummary, ScheduleCalendarData, ScheduleTrendData, ScheduleTrendPeriod, SpecialTrainingPage, StoreDetailData, StorePage, TeachingStandardsData, VocabularyImportPreview, VocabularyTree, VocabularyWord } from './admin.models';
 import { CloudFunctionsService } from './cloud-functions.service';
+import { AdminSessionService } from './admin-session.service';
 
 export type OperationsListKind = 'relations' | 'course-bindings' | 'appointments' | 'lessons' | 'learning-records' | 'finance' | 'money-logs';
 
 @Injectable({ providedIn: 'root' })
 export class OperationsService {
   private readonly functions = inject(CloudFunctionsService);
+  private readonly sessions = inject(AdminSessionService);
   readonly dataChanges$ = this.functions.dataChanges$;
+  private dashboardCache: { token: string; data: OperationsDashboardData; savedAt: number } | null = null;
+  private dashboardInFlight: { token: string; promise: Promise<OperationsDashboardData> } | null = null;
+  private dashboardRevision = 0;
+
+  constructor() {
+    this.dataChanges$?.subscribe(() => { this.dashboardRevision++; this.dashboardCache = null; this.dashboardInFlight = null; });
+  }
+
+  dashboardSnapshot(): { data: OperationsDashboardData; ageMs: number } | null {
+    const cached = this.dashboardCache;
+    const token = this.sessions.session()?.sessionToken;
+    const today = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
+    return cached && token && cached.token === token && cached.data.date === today
+      ? { data: cached.data, ageMs: Date.now() - cached.savedAt }
+      : null;
+  }
 
   dashboard(): Promise<OperationsDashboardData> {
-    return this.functions.operations<OperationsDashboardData>('ops/dashboard/summary');
+    const token = this.sessions.session()?.sessionToken || '';
+    if (this.dashboardInFlight?.token === token) return this.dashboardInFlight.promise;
+    const revision = this.dashboardRevision;
+    const promise = this.functions.operations<OperationsDashboardData>('ops/dashboard/summary').then((data) => {
+      if (token && this.sessions.session()?.sessionToken === token && revision === this.dashboardRevision) {
+        this.dashboardCache = { token, data, savedAt: Date.now() };
+      }
+      return data;
+    }).finally(() => { if (this.dashboardInFlight?.promise === promise) this.dashboardInFlight = null; });
+    this.dashboardInFlight = { token, promise };
+    return promise;
   }
 
   syncStatus(probe = true): Promise<LegacySyncStatus> {

+ 2 - 1
projects/xiaoshu-admin/src/app/pages/admin-dashboard.component.html

@@ -5,9 +5,10 @@
 
 @if (loading() && !data()) {
   <div class="state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="28" />正在汇总运营数据…</div>
-} @else if (error()) {
+} @else if (error() && !data()) {
   <div class="state error" role="alert"><strong>暂时无法载入运营工作台</strong><span>{{ error() }}</span><button type="button" (click)="load()">重新加载</button></div>
 } @else {
+  @if (error()) { <p class="refresh-error" role="alert">更新失败,正在显示上次成功结果:{{ error() }}。可点击“刷新数据”重试。</p> }
   @if (data(); as dashboard) {
   <section class="welcome">
     <div><span>{{ dashboard.date }}</span><h2>{{ dashboard.identity.displayName || dashboard.identity.username }},欢迎回来</h2><p>当前角色:{{ dashboard.identity.operationsRole === 'ops-manager' ? '运营管理者' : dashboard.identity.operationsRole === 'ops-staff' ? '普通运营' : '只读审计' }} · {{ dashboard.sync?.label || (dashboard.liveSourceAvailable?'实时排课已读取':'当前使用迁移库备用数据') }}</p></div>

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
projects/xiaoshu-admin/src/app/pages/admin-dashboard.component.scss


+ 40 - 1
projects/xiaoshu-admin/src/app/pages/admin-dashboard.component.spec.ts

@@ -10,7 +10,8 @@ describe('AdminDashboardComponent', () => {
   let sessions: jasmine.SpyObj<AdminSessionService>;
 
   beforeEach(async () => {
-    operations = jasmine.createSpyObj<OperationsService>('OperationsService', ['dashboard', 'scheduleTrend']);
+    operations = jasmine.createSpyObj<OperationsService>('OperationsService', ['dashboard', 'dashboardSnapshot', 'scheduleTrend']);
+    operations.dashboardSnapshot.and.returnValue(null);
     sessions = jasmine.createSpyObj<AdminSessionService>('AdminSessionService', ['applyIdentity']);
     operations.dashboard.and.resolveTo({
       identity: { objectId: 'admin-1', username: 'ops', displayName: '运营经理', roles: ['admin', 'ops-manager'], isSuperAdmin: false, operationsRole: 'ops-manager' },
@@ -46,6 +47,44 @@ describe('AdminDashboardComponent', () => {
     expect(sessions.applyIdentity).toHaveBeenCalledWith(jasmine.objectContaining({ operationsRole: 'ops-manager' }));
   });
 
+  it('restores a recent dashboard without repeating its summary request', async () => {
+    const data = await operations.dashboard();
+    operations.dashboard.calls.reset();
+    operations.dashboardSnapshot.and.returnValue({ data, ageMs: 1_000 });
+    fixture.detectChanges();
+    await fixture.whenStable();
+    fixture.detectChanges();
+    expect(operations.dashboard).not.toHaveBeenCalled();
+    expect(fixture.nativeElement.querySelector('.welcome')?.textContent).toContain('运营经理');
+    expect(fixture.componentInstance.loading()).toBeFalse();
+  });
+
+  it('keeps the previous dashboard visible when a stale snapshot cannot refresh', async () => {
+    const data = await operations.dashboard();
+    operations.dashboard.calls.reset();
+    operations.dashboard.and.rejectWith(new Error('暂时不可用'));
+    operations.dashboardSnapshot.and.returnValue({ data, ageMs: 31_000 });
+    fixture.detectChanges();
+    await fixture.whenStable();
+    fixture.detectChanges();
+    expect(operations.dashboard).toHaveBeenCalledTimes(1);
+    expect(fixture.nativeElement.querySelector('.welcome')?.textContent).toContain('运营经理');
+    expect(fixture.nativeElement.querySelector('.refresh-error')?.textContent).toContain('暂时不可用');
+  });
+
+  it('checks an old dashboard in the background when returning to its saved route', async () => {
+    const data = await operations.dashboard();
+    operations.dashboard.calls.reset();
+    operations.dashboardSnapshot.and.returnValue({ data, ageMs: 1_000 });
+    fixture.detectChanges();
+    await fixture.whenStable();
+    operations.dashboardSnapshot.and.returnValue({ data, ageMs: 31_000 });
+    fixture.componentInstance.onOperationsRouteAttach();
+    expect(fixture.componentInstance.loading()).toBeFalse();
+    await fixture.whenStable();
+    expect(operations.dashboard).toHaveBeenCalledTimes(1);
+  });
+
   it('loads the default weekly schedule trend and switches to month', async () => {
     fixture.detectChanges();
     await fixture.whenStable();

+ 18 - 4
projects/xiaoshu-admin/src/app/pages/admin-dashboard.component.ts

@@ -34,22 +34,36 @@ export class AdminDashboardComponent implements OnInit, OnDestroy {
   private stopLiveRefresh?: () => void;
   private loadRequest = 0;
   ngOnInit(): void {
-    void this.load();
+    const snapshot = this.operations.dashboardSnapshot();
+    if (snapshot) {
+      void this.useData(snapshot.data);
+      this.loading.set(false);
+      if (snapshot.ageMs >= 30_000) void this.load(true);
+    } else void this.load();
     this.stopLiveRefresh = this.liveRefresh.watch(['learning', 'courses', 'appointments', 'review', 'account'], () => { if (this.loading() || this.trendLoading()) return false; void this.load(true); return true; });
   }
   ngOnDestroy(): void { this.stopLiveRefresh?.(); }
+
+  onOperationsRouteAttach(): void {
+    const snapshot = this.operations.dashboardSnapshot();
+    if (!snapshot || snapshot.ageMs >= 30_000) void this.load(true);
+  }
   async load(background = false): Promise<void> {
     const request = ++this.loadRequest;
     if (!background) this.loading.set(true); this.error.set('');
     try {
-      const data = await this.operations.dashboard(); if (request !== this.loadRequest) return; this.data.set(data); this.sessions.applyIdentity(data.identity);
-      if (this.trendPeriod() === 'week' && data.scheduleTrend) this.applyTrend(data.scheduleTrend);
-      else if (this.trendPeriod() !== 'week') await this.loadTrend(this.trendPeriod());
+      const data = await this.operations.dashboard(); if (request !== this.loadRequest) return; await this.useData(data);
     }
     catch (error) { if (request === this.loadRequest) this.error.set(error instanceof Error ? error.message : '运营工作台加载失败'); }
     finally { if (!background && request === this.loadRequest) this.loading.set(false); }
   }
 
+  private async useData(data: OperationsDashboardData): Promise<void> {
+    this.data.set(data); this.sessions.applyIdentity(data.identity);
+    if (this.trendPeriod() === 'week' && data.scheduleTrend) this.applyTrend(data.scheduleTrend);
+    else if (this.trendPeriod() !== 'week') await this.loadTrend(this.trendPeriod());
+  }
+
   async selectTrendPeriod(period: ScheduleTrendPeriod): Promise<void> {
     if (this.trendPeriod() === period && this.trend()?.period === period && !this.trendError()) return;
     this.trendPeriod.set(period);

+ 8 - 1
projects/xiaoshu-admin/src/app/pages/admin-login.component.ts

@@ -6,6 +6,7 @@ import { Eye, EyeOff, LoaderCircle, LockKeyhole, LucideAngularModule, UserRound
 import { AdminSessionService } from '../admin-session.service';
 import { CloudFunctionsService } from '../cloud-functions.service';
 import { AdminIdentity } from '../admin.models';
+import { OperationsService } from '../operations.service';
 
 @Component({
   selector: 'app-admin-login',
@@ -28,6 +29,7 @@ export class AdminLoginComponent {
   private readonly functions = inject(CloudFunctionsService);
   private readonly router = inject(Router);
   private readonly route = inject(ActivatedRoute);
+  private readonly operations = inject(OperationsService);
 
   constructor() { document.title = '后台登录 · 小树陪练'; if (this.route.snapshot.queryParamMap.get('passwordChanged') === '1') this.notice.set('密码已修改,所有旧会话均已注销,请使用新密码登录。'); }
 
@@ -40,9 +42,14 @@ export class AdminLoginComponent {
     this.error.set('');
     try {
       await this.sessions.login(this.username, this.password);
+      const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') || '/admin/dashboard';
+      if (returnUrl.split('?')[0] === '/admin/dashboard') {
+        void this.operations.dashboard().catch(() => undefined);
+        void import('../layout/admin-shell.component');
+        void import('./admin-dashboard.component');
+      }
       const meta = await this.functions.admin<{ identity: AdminIdentity }>('meta');
       this.sessions.applyIdentity(meta.identity);
-      const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') || '/admin/dashboard';
       await this.router.navigateByUrl(returnUrl);
     } catch (error) {
       this.sessions.logout(false);

+ 1 - 1
scripts/deploy-admin-functions.mjs

@@ -3498,7 +3498,7 @@ async function professionalDashboardFast(context,input){
     Psql.query('SELECT "identityType","isDisabled","legacyUserData" FROM "_User" WHERE ($1::text IS NULL OR "company"=$1) AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) AND COALESCE("isAdmin",FALSE)=FALSE',[companyId]),
     Psql.one('SELECT COUNT(*) FILTER (WHERE day=$2)::int AS today_count,COUNT(*) FILTER (WHERE day=$2 AND COALESCE(NULLIF(CAST(a."pl" AS text),\'\'),NULLIF(CAST(a."fxpl" AS text),\'\'),\'\')=\'\')::int AS unassigned,COUNT(DISTINCT CAST(a."szyh" AS text)) FILTER (WHERE CAST(COALESCE(a."dszt",\'0\') AS text) NOT IN (\'0\',\'10\',\'11\',\'20\',\'30\',\'40\'))::int AS abnormal_members,ARRAY_AGG(DISTINCT CAST(a."szyh" AS text)) FILTER (WHERE CAST(COALESCE(a."dszt",\'0\') AS text) IN (\'0\',\'10\',\'11\',\'20\')) AS pending_members FROM (SELECT a.*,LEFT(REPLACE(COALESCE(NULLIF(CAST(a."yysj" AS text),\'\'),NULLIF(CAST(a."bxrq" AS text),\'\')),\'/\',\'-\'),10) AS day FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND CAST(a."id" AS text)=CAST(c."itemId" AS text) WHERE ($1::text IS NULL OR c."company"=$1) AND CAST(c."modelId" AS text)=\'54\' AND COALESCE(CAST(c."status" AS text),\'99\')<>\'-2\') a',[companyId,date]),
     Psql.one('SELECT COUNT(*)::int AS total FROM "_User" WHERE ($1::text IS NULL OR "company"=$1) AND "identityType"=\'coach\' AND COALESCE("isDisabled",FALSE)=FALSE AND ("isDeleted" IS NULL OR "isDeleted"=FALSE)',[companyId]),
-    payrollOverviewData(context,{...input,month,storeId:0,search:'',refresh:false}),
+    payrollPreviewData(context,{...input,month,storeId:0,search:'',refresh:false},month,true,false),
     payrollPage(context,{...input,page:1,pageSize:100}),
     scheduleTrendFast(context,input,'week'),
     operationalSyncIndicator(context,input),

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.