@pactor-app/skills 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/package.json +26 -0
  4. package/pactor/SKILL.md +373 -0
  5. package/pactor/references/dsl-authoring.md +448 -0
  6. package/prd-to-app/SKILL.md +75 -0
  7. package/prd-to-app/references/acceptance-checklist.md +47 -0
  8. package/prd-to-app/references/component-mapping.md +41 -0
  9. package/prd-to-app/references/mock-data-routes.md +66 -0
  10. package/prd-to-app/references/page-templates.md +274 -0
  11. package/prd-to-app/references/project-structure.md +89 -0
  12. package/prd-to-app/references/theme-and-style.md +41 -0
  13. package/prd-to-app/template/README.md +26 -0
  14. package/prd-to-app/template/app/[[...slug]]/page.tsx +7 -0
  15. package/prd-to-app/template/app/api/auth/login/route.ts +32 -0
  16. package/prd-to-app/template/app/api/auth/logout/route.ts +22 -0
  17. package/prd-to-app/template/app/api/data/[...parts]/route.ts +118 -0
  18. package/prd-to-app/template/app/api/public/pages/[id]/route.ts +40 -0
  19. package/prd-to-app/template/app/api/sitemap/route.ts +21 -0
  20. package/prd-to-app/template/app/api/users/route.ts +57 -0
  21. package/prd-to-app/template/app/favicon.ico +0 -0
  22. package/prd-to-app/template/app/globals.css +248 -0
  23. package/prd-to-app/template/app/layout.tsx +49 -0
  24. package/prd-to-app/template/app/login.css +122 -0
  25. package/prd-to-app/template/eslint.config.mjs +18 -0
  26. package/prd-to-app/template/next-env.d.ts +7 -0
  27. package/prd-to-app/template/next.config.js +32 -0
  28. package/prd-to-app/template/package.json +34 -0
  29. package/prd-to-app/template/pnpm-lock.yaml +6854 -0
  30. package/prd-to-app/template/pnpm-workspace.yaml +7 -0
  31. package/prd-to-app/template/postcss.config.mjs +7 -0
  32. package/prd-to-app/template/public/file.svg +1 -0
  33. package/prd-to-app/template/public/globe.svg +1 -0
  34. package/prd-to-app/template/public/next.svg +1 -0
  35. package/prd-to-app/template/public/skins/chenwunaifu.css +111 -0
  36. package/prd-to-app/template/public/skins/taijingshenlin.css +109 -0
  37. package/prd-to-app/template/public/skins/xuanzhimoyun.css +110 -0
  38. package/prd-to-app/template/public/vercel.svg +1 -0
  39. package/prd-to-app/template/public/window.svg +1 -0
  40. package/prd-to-app/template/scripts/build.mjs +35 -0
  41. package/prd-to-app/template/src/pactor/App.tsx +55 -0
  42. package/prd-to-app/template/src/pactor/actions.ts +438 -0
  43. package/prd-to-app/template/src/pactor/components/card-links.tsx +86 -0
  44. package/prd-to-app/template/src/pactor/components/chip-filter.tsx +89 -0
  45. package/prd-to-app/template/src/pactor/components/chip-node.tsx +46 -0
  46. package/prd-to-app/template/src/pactor/components/debug-chat.tsx +189 -0
  47. package/prd-to-app/template/src/pactor/components/index.ts +28 -0
  48. package/prd-to-app/template/src/pactor/components/log-status.tsx +64 -0
  49. package/prd-to-app/template/src/pactor/components/status-badge.tsx +46 -0
  50. package/prd-to-app/template/src/pactor/components/theme-switcher.tsx +136 -0
  51. package/prd-to-app/template/src/pactor/config.ts +28 -0
  52. package/prd-to-app/template/src/pactor/mocks/audit-mock.ts +403 -0
  53. package/prd-to-app/template/src/pactor/mocks/call-logs-mock.ts +875 -0
  54. package/prd-to-app/template/src/pactor/mocks/client-debug-mock.ts +188 -0
  55. package/prd-to-app/template/src/pactor/mocks/client-templates-mock.ts +497 -0
  56. package/prd-to-app/template/src/pactor/mocks/clients-mock.ts +483 -0
  57. package/prd-to-app/template/src/pactor/mocks/costs-mock.ts +406 -0
  58. package/prd-to-app/template/src/pactor/mocks/dashboard-mock.ts +192 -0
  59. package/prd-to-app/template/src/pactor/mocks/model-debug-mock.ts +144 -0
  60. package/prd-to-app/template/src/pactor/mocks/models-mock.ts +799 -0
  61. package/prd-to-app/template/src/pactor/mocks/ops-usage-mock.ts +806 -0
  62. package/prd-to-app/template/src/pactor/mocks/providers-mock.ts +424 -0
  63. package/prd-to-app/template/src/pactor/mocks/routes-mock.ts +604 -0
  64. package/prd-to-app/template/src/pactor/services.ts +185 -0
  65. package/prd-to-app/template/src/pactor/skin.ts +52 -0
  66. package/prd-to-app/template/src/server/accounts.ts +63 -0
  67. package/prd-to-app/template/src/server/cors.ts +34 -0
  68. package/prd-to-app/template/src/server/sitemap.ts +176 -0
  69. package/prd-to-app/template/src/server/yaml/common/dashboard.yaml +498 -0
  70. package/prd-to-app/template/src/server/yaml/common/login.yaml +84 -0
  71. package/prd-to-app/template/src/server/yaml/provider/provider-create.yaml +340 -0
  72. package/prd-to-app/template/src/server/yaml/provider/provider-detail.yaml +780 -0
  73. package/prd-to-app/template/src/server/yaml/provider/provider-edit.yaml +366 -0
  74. package/prd-to-app/template/src/server/yaml/provider/provider-list.yaml +1989 -0
  75. package/prd-to-app/template/src/server/yaml/route/route-create.yaml +511 -0
  76. package/prd-to-app/template/src/server/yaml/route/route-detail.yaml +610 -0
  77. package/prd-to-app/template/src/server/yaml/route/route-edit.yaml +809 -0
  78. package/prd-to-app/template/src/server/yaml/route/route-list.yaml +1793 -0
  79. package/prd-to-app/template/src/server/yaml/sitemap.yaml +250 -0
  80. package/prd-to-app/template/tsconfig.json +34 -0
@@ -0,0 +1,185 @@
1
+ /**
2
+ * 页面 DSL 的 service 数据源注册表(
3
+ * `services={{...}}` 包装层),经 SitemapApp 的 `services` prop 全局注册。
4
+ * 实现为 mock(src/pactor/mocks/);
5
+ * 列表类服务负责把 DSL 的一族布尔快筛参数还原为数组(表达式引擎不支持数组方法)。
6
+ */
7
+ import type { ServiceHandler } from "@pactor-app/runtime";
8
+
9
+ import { queryDashboard } from "./mocks/dashboard-mock";
10
+ import {
11
+ getProvider,
12
+ pullProviderModels,
13
+ queryProviders,
14
+ } from "./mocks/providers-mock";
15
+ import { getModel, queryModels } from "./mocks/models-mock";
16
+ import { getRoute, queryRoutes } from "./mocks/routes-mock";
17
+ import {
18
+ CLIENT_TAG_FILTERS,
19
+ clientsByTemplate,
20
+ getClient,
21
+ queryClients,
22
+ } from "./mocks/clients-mock";
23
+ import {
24
+ CLIENT_TEMPLATE_TAG_FILTERS,
25
+ getClientTemplate,
26
+ queryClientTemplates,
27
+ } from "./mocks/client-templates-mock";
28
+ import { getCallLog, queryCallLogs } from "./mocks/call-logs-mock";
29
+ import { getCost, queryCosts } from "./mocks/costs-mock";
30
+ import { getAudit, listAuditOperators, queryAudits } from "./mocks/audit-mock";
31
+ import {
32
+ queryAvailability,
33
+ queryClientUsage,
34
+ queryModelUsage,
35
+ queryProviderUsage,
36
+ } from "./mocks/ops-usage-mock";
37
+
38
+ function str(value: unknown): string {
39
+ return typeof value === "string" ? value : "";
40
+ }
41
+
42
+ function opt(value: unknown): string | undefined {
43
+ return typeof value === "string" ? value : undefined;
44
+ }
45
+
46
+ /** 标签 state 键:连字符转下划线(如 team-default → tag_team_default) */
47
+ const tagKey = (value: string) => `tag_${value.replace(/-/g, "_")}`;
48
+
49
+ /** 快筛值数组:service 直调为数组,http query 序列化后为逗号串;空值返回 undefined */
50
+ function strList(value: unknown): string[] | undefined {
51
+ if (Array.isArray(value)) {
52
+ return value.filter((v): v is string => typeof v === "string" && v !== "");
53
+ }
54
+ if (typeof value === "string") return value.split(",").filter(Boolean);
55
+ return undefined;
56
+ }
57
+
58
+ export const pactorServices: Record<string, ServiceHandler> = {
59
+ "dashboard.query": (params) =>
60
+ queryDashboard(str(params.start), str(params.end)),
61
+
62
+ // 类型多选:ChipFilter 以 types 数组(http 侧序列化为逗号串)提交;
63
+ // 旧版 DSL 一族一个布尔参数的写法保留兼容,这里还原为 family 数组
64
+ "provider.list": (params) =>
65
+ queryProviders({
66
+ keyword: str(params.keyword),
67
+ status: str(params.status) || "all",
68
+ types:
69
+ strList(params.types) ??
70
+ ["openai", "anthropic", "gemini", "dashscope"].filter(
71
+ (f) => params[f] === true,
72
+ ),
73
+ }),
74
+ "provider.detail": (params) => getProvider(str(params.code)),
75
+ "provider.pullModels": (params) => pullProviderModels(str(params.code)),
76
+
77
+ // 厂商多选:ChipFilter 以 providers 数组(http 侧序列化为逗号串)提交;
78
+ // 旧版 DSL 一个 Provider 一个布尔参数的写法保留兼容
79
+ "model.list": (params) =>
80
+ queryModels({
81
+ type: str(params.type) || "all",
82
+ providers:
83
+ strList(params.providers) ??
84
+ ["dashscope", "gemini", "openai"].filter((p) => params[p] === true),
85
+ }),
86
+ "model.detail": (params) => getModel(str(params.code)),
87
+
88
+ // 标签多选:ChipFilter 以 tags 数组(http 侧序列化为逗号串)提交;
89
+ // 旧版 DSL 一个标签一个布尔参数(连字符转下划线)的写法保留兼容
90
+ "route.list": (params) =>
91
+ queryRoutes({
92
+ status: str(params.status) || "all",
93
+ type: str(params.type) || "all",
94
+ tags:
95
+ strList(params.tags) ??
96
+ [
97
+ ["reasoning", "tag_reasoning"],
98
+ ["premium", "tag_premium"],
99
+ ["general", "tag_general"],
100
+ ["coding", "tag_coding"],
101
+ ["low-cost", "tag_low_cost"],
102
+ ["mobile", "tag_mobile"],
103
+ ["vision", "tag_vision"],
104
+ ["embedding", "tag_embedding"],
105
+ ]
106
+ .filter(([, k]) => params[k] === true)
107
+ .map(([t]) => t),
108
+ }),
109
+ "route.detail": (params) => getRoute(str(params.code)),
110
+
111
+ "client.list": (params) =>
112
+ queryClients({
113
+ status: str(params.status) || "all",
114
+ name: str(params.name),
115
+ code: str(params.code),
116
+ // ChipFilter 以 tags 数组(http 侧为逗号串)提交;旧布尔族写法保留兼容
117
+ tags:
118
+ strList(params.tags) ??
119
+ CLIENT_TAG_FILTERS.filter((t) => params[tagKey(t.value)] === true).map(
120
+ (t) => t.value,
121
+ ),
122
+ }),
123
+ "client.detail": (params) => getClient(str(params.code)),
124
+
125
+ "clientTemplate.list": (params) =>
126
+ queryClientTemplates({
127
+ status: str(params.status) || "all",
128
+ keyword: str(params.keyword),
129
+ tags:
130
+ strList(params.tags) ??
131
+ CLIENT_TEMPLATE_TAG_FILTERS.filter(
132
+ (t) => params[tagKey(t.value)] === true,
133
+ ).map((t) => t.value),
134
+ }),
135
+ "clientTemplate.detail": (params) => getClientTemplate(str(params.code)),
136
+ "clientTemplate.createdClients": (params) =>
137
+ clientsByTemplate(str(params.code)),
138
+
139
+ "callLog.list": (params) =>
140
+ queryCallLogs({
141
+ start: opt(params.start),
142
+ end: opt(params.end),
143
+ status: str(params.status) || "all",
144
+ model: str(params.model) || "all",
145
+ provider: str(params.provider) || "all",
146
+ protocol: str(params.protocol) || "all",
147
+ routeGroup: str(params.routeGroup) || "all",
148
+ email: str(params.email),
149
+ appKey: str(params.appKey),
150
+ }),
151
+ "callLog.detail": (params) => getCallLog(str(params.id)),
152
+
153
+ "cost.list": (params) =>
154
+ queryCosts({
155
+ application: str(params.application),
156
+ appKey: str(params.appKey),
157
+ alias: str(params.alias),
158
+ model: str(params.model) || "all",
159
+ provider: str(params.provider) || "all",
160
+ start: opt(params.start),
161
+ end: opt(params.end),
162
+ }),
163
+ "cost.detail": (params) => getCost(str(params.id)),
164
+
165
+ "audit.list": (params) =>
166
+ queryAudits({
167
+ start: opt(params.start),
168
+ end: opt(params.end),
169
+ operator: str(params.operator) || "all",
170
+ operation: str(params.operation) || "all",
171
+ resourceType: str(params.resourceType) || "all",
172
+ resourceId: str(params.resourceId),
173
+ }),
174
+ "audit.detail": (params) => getAudit(str(params.id)) ?? null,
175
+ "audit.operators": () => listAuditOperators(),
176
+
177
+ "ops.modelUsage": (params) =>
178
+ queryModelUsage({ start: opt(params.start), end: opt(params.end) }),
179
+ "ops.providerUsage": (params) =>
180
+ queryProviderUsage({ start: opt(params.start), end: opt(params.end) }),
181
+ "ops.clientUsage": (params) =>
182
+ queryClientUsage({ start: opt(params.start), end: opt(params.end) }),
183
+ "ops.availability": (params) =>
184
+ queryAvailability({ start: opt(params.start), end: opt(params.end) }),
185
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 皮肤加载:Theme Studio 导出的皮肤 CSS(public/skins/<key>.css)经
3
+ * `<link rel="stylesheet" data-pactor-skin>` 在 @pactor-app/ui/styles.css
4
+ * 之后加载(后加载者胜出)。令牌经 var() 运行时解析,替换 link 节点即可
5
+ * 即时换肤,无需刷新页面(对齐 pactor 文档 guides/theming「运行时切换皮肤」)。
6
+ *
7
+ * 首屏由 layout.tsx 内联脚本在 <head> 中同步注入对应 link(避免闪烁),
8
+ * 本模块供 ThemeSwitcher 在运行时执行换肤 swap。
9
+ */
10
+
11
+ export const SKIN_STORAGE_KEY = "mc-skin";
12
+
13
+ export interface SkinOption {
14
+ value: string;
15
+ label: string;
16
+ color: string;
17
+ }
18
+
19
+ export const SKINS: SkinOption[] = [
20
+ { value: "chenwunaifu", label: "晨雾奶芙", color: "#c98a5e" },
21
+ { value: "taijingshenlin", label: "苔径深林", color: "#3f6b4f" },
22
+ { value: "xuanzhimoyun", label: "宣纸墨韵", color: "#2f3437" },
23
+ ];
24
+
25
+ export const DEFAULT_SKIN = SKINS[0].value;
26
+
27
+ export function skinHref(skin: string): string {
28
+ return `/skins/${skin}.css`;
29
+ }
30
+
31
+ /**
32
+ * 运行时换肤:新皮肤 link 加载完成后再移除旧的,避免无样式闪烁。
33
+ * link 以 data-pactor-skin 属性标记,供本函数识别清理。
34
+ */
35
+ export function applySkinStylesheet(skin: string): void {
36
+ const href = skinHref(skin);
37
+ if (document.querySelector(`link[data-pactor-skin][href="${href}"]`)) {
38
+ return;
39
+ }
40
+ const next = document.createElement("link");
41
+ next.rel = "stylesheet";
42
+ next.href = href;
43
+ next.dataset.pactorSkin = skin;
44
+ next.onload = () => {
45
+ document
46
+ .querySelectorAll("link[data-pactor-skin]")
47
+ .forEach((el) => {
48
+ if (el !== next) el.remove();
49
+ });
50
+ };
51
+ document.head.append(next);
52
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * 演示账号(mock):真实项目由后端用户体系替换。
3
+ * 会话经 httpOnly cookie 传递(pactor_demo_session),sitemap 端点据此裁剪内容。
4
+ */
5
+ export interface DemoUser {
6
+ id: string;
7
+ username: string;
8
+ name: string;
9
+ roles: string[];
10
+ permissions: string[];
11
+ }
12
+
13
+ export const DEMO_ACCOUNTS: (DemoUser & { password: string })[] = [
14
+ {
15
+ id: "u-1",
16
+ username: "admin",
17
+ password: "pactor123",
18
+ name: "演示管理员",
19
+ roles: ["admin"],
20
+ permissions: ["report:view", "user:view"],
21
+ },
22
+ {
23
+ id: "u-2",
24
+ username: "guest",
25
+ password: "guest123",
26
+ name: "访客",
27
+ roles: ["guest"],
28
+ permissions: [],
29
+ },
30
+ ];
31
+
32
+ export const SESSION_COOKIE = "pactor_demo_session";
33
+
34
+ function toDemoUser(account: (typeof DEMO_ACCOUNTS)[number]): DemoUser {
35
+ return {
36
+ id: account.id,
37
+ username: account.username,
38
+ name: account.name,
39
+ roles: account.roles,
40
+ permissions: account.permissions,
41
+ };
42
+ }
43
+
44
+ export function findAccount(
45
+ username: string,
46
+ password: string,
47
+ ): DemoUser | null {
48
+ const hit = DEMO_ACCOUNTS.find(
49
+ (a) => a.username === username && a.password === password,
50
+ );
51
+ return hit ? toDemoUser(hit) : null;
52
+ }
53
+
54
+ /** 会话 cookie 值 = username(演示环境明文,生产换成签名 token / session id) */
55
+ export function findUserBySession(
56
+ cookieValue: string | undefined,
57
+ ): DemoUser | null {
58
+ if (!cookieValue) {
59
+ return null;
60
+ }
61
+ const hit = DEMO_ACCOUNTS.find((a) => a.username === cookieValue);
62
+ return hit ? toDemoUser(hit) : null;
63
+ }
@@ -0,0 +1,34 @@
1
+ import { NextResponse } from "next/server";
2
+
3
+ /**
4
+ * CORS(演示环境放开跨源):真实项目由后端框架 / 网关统一配置。
5
+ * 会话经 cookie 传递——Allow-Origin 必须回显请求源(带凭据时不能用 *)
6
+ * 且带 Allow-Credentials;POST JSON 会触发预检(OPTIONS)。
7
+ */
8
+ export function corsHeaders(request: Request): Record<string, string> {
9
+ const origin = request.headers.get("origin");
10
+ return {
11
+ "access-control-allow-origin": origin ?? "*",
12
+ "access-control-allow-credentials": "true",
13
+ "access-control-allow-methods": "GET, POST, OPTIONS",
14
+ "access-control-allow-headers": "content-type, authorization",
15
+ vary: "origin",
16
+ };
17
+ }
18
+
19
+ export function corsJson(
20
+ request: Request,
21
+ data: unknown,
22
+ init?: ResponseInit,
23
+ ): NextResponse {
24
+ const response = NextResponse.json(data, init);
25
+ for (const [key, value] of Object.entries(corsHeaders(request))) {
26
+ response.headers.set(key, value);
27
+ }
28
+ return response;
29
+ }
30
+
31
+ /** 预检响应(路由中 `export const OPTIONS = corsPreflight`) */
32
+ export function corsPreflight(request: Request): Response {
33
+ return new Response(null, { status: 204, headers: corsHeaders(request) });
34
+ }
@@ -0,0 +1,176 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+
4
+ import type { PageDsl, PermissionConfig } from "@pactor-app/core";
5
+ import { parseDocument } from "yaml";
6
+
7
+ import type { LayoutDsl, SitemapResource } from "pactor-app";
8
+
9
+ import type { DemoUser } from "./accounts";
10
+
11
+ /**
12
+ * Mock sitemap 数据源(design/dynamic-page.md 方案 B 示例实现)。
13
+ * 真实项目由后端接口返回同构对象——端点按会话裁剪:
14
+ * 匿名 → public 页(登录/注册);登录后 → 该角色可见的 menus / pages / permissions。
15
+ *
16
+ * 入口数据(元数据 / layouts / pages / menus 全量)在 yaml/sitemap.yaml,
17
+ * 页面 DSL 在 yaml/<域>/<id>.yaml(route / provider / audit / model /
18
+ * 等,按 PRD 模块树划分);本文件只负责:
19
+ * 加载 YAML、按会话裁剪可见性(public / permission / 需登录)、注入会话
20
+ * 动态字段(updatedAt / user / permissions)。
21
+ */
22
+
23
+ /* ---------------- YAML DSL 文件加载(src/server/yaml/,启动时读入内存) ---------------- */
24
+
25
+ const YAML_DIR = join(process.cwd(), "src/server/yaml");
26
+
27
+ /** 解析 YAML 文件;语法错误时 fail-fast(parseDocument 默认静默容错,结构会悄悄残缺) */
28
+ function loadYaml<T>(file: string): T {
29
+ const doc = parseDocument(readFileSync(join(YAML_DIR, file), "utf8"));
30
+ if (doc.errors.length > 0) {
31
+ throw new Error(
32
+ `${file} 解析失败:${doc.errors.map((e) => e.message.split("\n")[0]).join(";")}`,
33
+ );
34
+ }
35
+ return doc.toJS() as T;
36
+ }
37
+
38
+ /** sitemap.yaml 中的页面声明:可见性标记(public / permission)+ DSL 加载方式(lazy) */
39
+ interface PageEntry {
40
+ id: string;
41
+ path: string;
42
+ title: string;
43
+ version: number;
44
+ public?: boolean;
45
+ permission?: string;
46
+ /** 不随 sitemap 内联 pageDsl,客户端经 /api/public/pages/{id} 懒加载 */
47
+ lazy?: boolean;
48
+ }
49
+
50
+ interface SitemapFile {
51
+ id: string;
52
+ resourceType: "sitemap";
53
+ version: number;
54
+ dslVersion: string;
55
+ app: { name: string };
56
+ layout: string;
57
+ auth: { loginPath: string };
58
+ layouts: Record<string, LayoutDsl>;
59
+ pages: PageEntry[];
60
+ menus: SitemapResource["menus"];
61
+ }
62
+
63
+ const site = loadYaml<SitemapFile>("sitemap.yaml");
64
+
65
+ /** 递归收集 yaml 下所有页面 DSL(含 route / provider / ops 等域子目录;跳过 sitemap.yaml),按 id → 相对路径建索引 */
66
+ function collectPageFiles(dir: string): Map<string, string> {
67
+ return new Map(
68
+ readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
69
+ const path = join(dir, entry.name);
70
+ if (entry.isDirectory()) return [...collectPageFiles(path)];
71
+ if (!entry.name.endsWith(".yaml") || entry.name === "sitemap.yaml") return [];
72
+ const id = entry.name.slice(0, -".yaml".length);
73
+ return [[id, relative(YAML_DIR, path)]];
74
+ }),
75
+ );
76
+ }
77
+
78
+ const pageFiles = collectPageFiles(YAML_DIR);
79
+
80
+ function loadPageDsl(id: string): PageDsl {
81
+ const file = pageFiles.get(id);
82
+ if (!file) throw new Error(`页面 DSL 缺失:${id}.yaml`);
83
+ return loadYaml<PageDsl>(file);
84
+ }
85
+
86
+ /** 页面 DSL:按 id 约定从 yaml/<域>/<id>.yaml 加载;lazy 页不在此内联 */
87
+ const pageDsls = new Map(
88
+ site.pages.filter((p) => !p.lazy).map((p) => [p.id, loadPageDsl(p.id)]),
89
+ );
90
+ const lazyPageDsls = new Map(
91
+ site.pages.filter((p) => p.lazy).map((p) => [p.id, loadPageDsl(p.id)]),
92
+ );
93
+
94
+ /* ---------------- 会话裁剪 ---------------- */
95
+
96
+ type Visibility = { public?: boolean; permission?: PermissionConfig };
97
+
98
+ /** PermissionConfig 归一化为权限码数组(string / string[] / { code } 三种形态) */
99
+ function permissionCodes(permission: PermissionConfig): string[] {
100
+ const code =
101
+ typeof permission === "object" && !Array.isArray(permission)
102
+ ? permission.code
103
+ : permission;
104
+ return Array.isArray(code) ? code : [code];
105
+ }
106
+
107
+ /** 可见性:public 匿名可见;permission 命中任一权限码;无标记需登录 */
108
+ function canSee(entry: Visibility, user: DemoUser | null): boolean {
109
+ if (entry.public) return true;
110
+ if (!user) return false;
111
+ if (entry.permission) {
112
+ return permissionCodes(entry.permission).some((c) =>
113
+ user.permissions.includes(c),
114
+ );
115
+ }
116
+ return true;
117
+ }
118
+
119
+ /** 菜单按会话过滤;group 无可见子项时整组隐藏 */
120
+ function filterMenus(
121
+ menus: SitemapResource["menus"],
122
+ user: DemoUser | null,
123
+ ): SitemapResource["menus"] {
124
+ const out: SitemapResource["menus"] = [];
125
+ for (const item of menus) {
126
+ if (!canSee(item, user)) continue;
127
+ if (item.type === "group") {
128
+ const children = filterMenus(item.children ?? [], user);
129
+ if (children.length === 0) continue;
130
+ out.push({ ...item, children });
131
+ } else {
132
+ out.push(item);
133
+ }
134
+ }
135
+ return out;
136
+ }
137
+
138
+ /**
139
+ * 按会话构建 sitemap:同一 URL,不同用户拿到不同内容。
140
+ * 匿名 → 仅 public 页;guest → + 首页;admin → + 用户列表 / 报表组。
141
+ */
142
+ export function buildSitemap(user: DemoUser | null): SitemapResource {
143
+ const pages: SitemapResource["pages"] = site.pages
144
+ .filter((p) => canSee(p, user))
145
+ .map((p) => ({
146
+ id: p.id,
147
+ path: p.path,
148
+ title: p.title,
149
+ version: p.version,
150
+ // 懒加载页不下发 pageDsl,仅 id / path / version,经 /api/public/pages/{id} 按需获取
151
+ ...(p.lazy ? {} : { pageDsl: pageDsls.get(p.id) }),
152
+ }));
153
+
154
+ // sitemap.yaml 原样透传,仅覆盖会话相关部分:pages / menus 按可见性过滤,
155
+ // updatedAt / user / permissions 按会话注入
156
+ return {
157
+ ...site,
158
+ updatedAt: new Date().toISOString(),
159
+ ...(user ? { user: { ...user }, permissions: user.permissions } : {}),
160
+ pages,
161
+ menus: filterMenus(site.menus, user),
162
+ };
163
+ }
164
+
165
+ /** 懒加载页:校验会话与可见性(前端权限只控制 UI,接口必须鉴权) */
166
+ export function getLazyPage(
167
+ pageId: string,
168
+ user: DemoUser | null,
169
+ ): { version: number; pageDsl: PageDsl } | null {
170
+ const entry = site.pages.find((p) => p.id === pageId);
171
+ const pageDsl = lazyPageDsls.get(pageId);
172
+ if (!entry?.lazy || !pageDsl || !canSee(entry, user)) {
173
+ return null;
174
+ }
175
+ return { version: entry.version, pageDsl };
176
+ }