node-resource-utils 0.0.0-stage → 1.1.7

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 (229) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -2
  3. package/lib/sync.js +121 -0
  4. package/package.json +14 -4
  5. package/resources/.cache/ref.b64 +1 -0
  6. package/resources/README.md +97 -0
  7. package/resources/_.env +6 -0
  8. package/resources/_.env.example +25 -0
  9. package/resources/_.gitignore +18 -0
  10. package/resources/_.lintstagedrc.json +9 -0
  11. package/resources/_.npmrc +5 -0
  12. package/resources/_.prettierrc +7 -0
  13. package/resources/commitlint.config.mjs +1 -0
  14. package/resources/content/docs/README.md +63 -0
  15. package/resources/content/docs/faq.md +107 -0
  16. package/resources/content/docs/features/_meta.json +5 -0
  17. package/resources/content/docs/features/ai.md +71 -0
  18. package/resources/content/docs/features/appearance.md +47 -0
  19. package/resources/content/docs/features/feedback.md +79 -0
  20. package/resources/content/docs/features/files.md +74 -0
  21. package/resources/content/docs/features/graph.md +66 -0
  22. package/resources/content/docs/features/inbox.md +61 -0
  23. package/resources/content/docs/guide/_meta.json +5 -0
  24. package/resources/content/docs/guide/account.md +76 -0
  25. package/resources/content/docs/guide/data.md +87 -0
  26. package/resources/content/docs/guide/navigation.md +81 -0
  27. package/resources/content/docs/guide/permissions.md +67 -0
  28. package/resources/content/docs/guide/system.md +92 -0
  29. package/resources/content/docs/quick-start.md +70 -0
  30. package/resources/content/docs/shortcuts.md +69 -0
  31. package/resources/eslint.config.mjs +17 -0
  32. package/resources/next-env.d.ts +6 -0
  33. package/resources/next.config.ts +13 -0
  34. package/resources/postcss.config.mjs +7 -0
  35. package/resources/prisma/org.config.ts +98 -0
  36. package/resources/prisma/schema.prisma +336 -0
  37. package/resources/prisma/seed.ts +721 -0
  38. package/resources/prisma/seedSafe.ts +347 -0
  39. package/resources/prisma.config.ts +14 -0
  40. package/resources/scripts/check-config.mjs +773 -0
  41. package/resources/scripts/lib/schema-graph.mjs +410 -0
  42. package/resources/scripts/patch-next-trace.mjs +86 -0
  43. package/resources/scripts/suggest-relations.mjs +105 -0
  44. package/resources/src/app/(admin)/ai/page.tsx +469 -0
  45. package/resources/src/app/(admin)/dashboard/page.tsx +260 -0
  46. package/resources/src/app/(admin)/demo/employees/page.tsx +508 -0
  47. package/resources/src/app/(admin)/demo/files/page.tsx +451 -0
  48. package/resources/src/app/(admin)/demo/loading/page.tsx +513 -0
  49. package/resources/src/app/(admin)/error.tsx +96 -0
  50. package/resources/src/app/(admin)/feedback/page.tsx +573 -0
  51. package/resources/src/app/(admin)/graph/page.tsx +1224 -0
  52. package/resources/src/app/(admin)/inbox/page.tsx +31 -0
  53. package/resources/src/app/(admin)/layout.tsx +58 -0
  54. package/resources/src/app/(admin)/loading.tsx +31 -0
  55. package/resources/src/app/(admin)/system/depts/page.tsx +415 -0
  56. package/resources/src/app/(admin)/system/logs/page.tsx +215 -0
  57. package/resources/src/app/(admin)/system/menus/page.tsx +403 -0
  58. package/resources/src/app/(admin)/system/roles/page.tsx +433 -0
  59. package/resources/src/app/(admin)/system/users/page.tsx +620 -0
  60. package/resources/src/app/api/account-recovery/route.ts +132 -0
  61. package/resources/src/app/api/ai/act/route.ts +70 -0
  62. package/resources/src/app/api/ai/ask/route.ts +82 -0
  63. package/resources/src/app/api/ai/chat/route.ts +404 -0
  64. package/resources/src/app/api/ai/report/route.ts +164 -0
  65. package/resources/src/app/api/auth/[...nextauth]/route.ts +3 -0
  66. package/resources/src/app/api/auth/change-password/route.ts +57 -0
  67. package/resources/src/app/api/auth/forgot/route.ts +98 -0
  68. package/resources/src/app/api/auth/register/route.ts +95 -0
  69. package/resources/src/app/api/auth/register-config/route.ts +51 -0
  70. package/resources/src/app/api/badges/route.ts +39 -0
  71. package/resources/src/app/api/depts/[id]/route.ts +145 -0
  72. package/resources/src/app/api/depts/route.ts +126 -0
  73. package/resources/src/app/api/docs/export/route.ts +87 -0
  74. package/resources/src/app/api/docs/parse/route.ts +57 -0
  75. package/resources/src/app/api/docs/search/route.ts +22 -0
  76. package/resources/src/app/api/employees/[id]/route.ts +147 -0
  77. package/resources/src/app/api/employees/export/route.ts +115 -0
  78. package/resources/src/app/api/employees/import/route.ts +205 -0
  79. package/resources/src/app/api/employees/route.ts +149 -0
  80. package/resources/src/app/api/export/route.ts +254 -0
  81. package/resources/src/app/api/favorites/route.ts +56 -0
  82. package/resources/src/app/api/feedback/ai/route.ts +31 -0
  83. package/resources/src/app/api/feedback/approve/route.ts +67 -0
  84. package/resources/src/app/api/feedback/report/route.ts +213 -0
  85. package/resources/src/app/api/feedback/route.ts +246 -0
  86. package/resources/src/app/api/files/[name]/route.ts +82 -0
  87. package/resources/src/app/api/graph/focus/route.ts +75 -0
  88. package/resources/src/app/api/graph/node/move/route.ts +48 -0
  89. package/resources/src/app/api/graph/node/route.ts +274 -0
  90. package/resources/src/app/api/graph/route.ts +272 -0
  91. package/resources/src/app/api/health/route.ts +11 -0
  92. package/resources/src/app/api/inbox/read/route.ts +36 -0
  93. package/resources/src/app/api/inbox/summary/route.ts +15 -0
  94. package/resources/src/app/api/logs/route.ts +82 -0
  95. package/resources/src/app/api/menus/[id]/route.ts +132 -0
  96. package/resources/src/app/api/menus/mine/route.ts +23 -0
  97. package/resources/src/app/api/menus/route.ts +89 -0
  98. package/resources/src/app/api/roles/[id]/menus/route.ts +73 -0
  99. package/resources/src/app/api/roles/[id]/route.ts +96 -0
  100. package/resources/src/app/api/roles/route.ts +114 -0
  101. package/resources/src/app/api/search/route.ts +22 -0
  102. package/resources/src/app/api/stats/route.ts +92 -0
  103. package/resources/src/app/api/upload/route.ts +38 -0
  104. package/resources/src/app/api/users/[id]/password/route.ts +55 -0
  105. package/resources/src/app/api/users/[id]/route.ts +145 -0
  106. package/resources/src/app/api/users/route.ts +153 -0
  107. package/resources/src/app/api/visits/route.ts +48 -0
  108. package/resources/src/app/change-password/ChangePasswordForm.tsx +174 -0
  109. package/resources/src/app/change-password/page.tsx +19 -0
  110. package/resources/src/app/docs/[[...slug]]/page.tsx +56 -0
  111. package/resources/src/app/docs/layout.tsx +34 -0
  112. package/resources/src/app/docs/loading.tsx +19 -0
  113. package/resources/src/app/docs/not-found.tsx +25 -0
  114. package/resources/src/app/forgot/page.tsx +392 -0
  115. package/resources/src/app/global-error.tsx +75 -0
  116. package/resources/src/app/globals.css +235 -0
  117. package/resources/src/app/layout.tsx +44 -0
  118. package/resources/src/app/login/page.tsx +267 -0
  119. package/resources/src/app/page.tsx +14 -0
  120. package/resources/src/app/register/page.tsx +237 -0
  121. package/resources/src/auth.ts +215 -0
  122. package/resources/src/components/AiPanel.tsx +970 -0
  123. package/resources/src/components/AuthContext.tsx +82 -0
  124. package/resources/src/components/DataTable.tsx +459 -0
  125. package/resources/src/components/EChart.tsx +140 -0
  126. package/resources/src/components/FeedbackAiModal.tsx +476 -0
  127. package/resources/src/components/FeedbackReportModal.tsx +479 -0
  128. package/resources/src/components/Markdown.tsx +532 -0
  129. package/resources/src/components/OntologyGraph.tsx +1196 -0
  130. package/resources/src/components/Providers.tsx +99 -0
  131. package/resources/src/components/VisitTracker.tsx +89 -0
  132. package/resources/src/components/admin/AdminShell.tsx +749 -0
  133. package/resources/src/components/admin/CountBadge.tsx +67 -0
  134. package/resources/src/components/admin/GlobalSearch.tsx +509 -0
  135. package/resources/src/components/admin/InboxBell.tsx +78 -0
  136. package/resources/src/components/admin/VisitStats.tsx +145 -0
  137. package/resources/src/components/admin/menu-icons.tsx +56 -0
  138. package/resources/src/components/ai/AiInputForm.tsx +351 -0
  139. package/resources/src/components/appearance/AppearanceDrawer.tsx +268 -0
  140. package/resources/src/components/appearance/AppearanceProvider.tsx +160 -0
  141. package/resources/src/components/docs/DocArticle.tsx +106 -0
  142. package/resources/src/components/docs/DocToc.tsx +100 -0
  143. package/resources/src/components/docs/DocsChrome.tsx +259 -0
  144. package/resources/src/components/docs/DocsHome.tsx +209 -0
  145. package/resources/src/components/docs/DocsSearch.tsx +365 -0
  146. package/resources/src/components/docs/docs.css +1458 -0
  147. package/resources/src/components/feedback/AsyncButton.tsx +108 -0
  148. package/resources/src/components/feedback/LoadingOverlay.tsx +125 -0
  149. package/resources/src/components/feedback/PressableCard.tsx +92 -0
  150. package/resources/src/components/feedback/README.md +97 -0
  151. package/resources/src/components/feedback/RouteProgress.tsx +108 -0
  152. package/resources/src/components/feedback/Skeleton.tsx +249 -0
  153. package/resources/src/components/feedback/StatCard.tsx +70 -0
  154. package/resources/src/components/feedback/TaskHint.tsx +153 -0
  155. package/resources/src/components/feedback/index.ts +93 -0
  156. package/resources/src/components/feedback/motion.css +204 -0
  157. package/resources/src/components/feedback/motion.ts +63 -0
  158. package/resources/src/components/feedback/useCountUp.ts +110 -0
  159. package/resources/src/components/feedback/usePending.ts +209 -0
  160. package/resources/src/components/feedback/useRowFlash.ts +40 -0
  161. package/resources/src/components/inbox/InboxView.tsx +409 -0
  162. package/resources/src/components/ui/button.tsx +35 -0
  163. package/resources/src/components/ui/card.tsx +37 -0
  164. package/resources/src/components/ui/input.tsx +19 -0
  165. package/resources/src/lib/ai.ts +310 -0
  166. package/resources/src/lib/aiContext.ts +357 -0
  167. package/resources/src/lib/aiForm.test.ts +191 -0
  168. package/resources/src/lib/aiForm.ts +532 -0
  169. package/resources/src/lib/aiFormTypes.ts +83 -0
  170. package/resources/src/lib/aiToken.ts +72 -0
  171. package/resources/src/lib/aiTools.ts +823 -0
  172. package/resources/src/lib/api.ts +160 -0
  173. package/resources/src/lib/appInfo.ts +128 -0
  174. package/resources/src/lib/appearance.ts +643 -0
  175. package/resources/src/lib/badge-count.ts +146 -0
  176. package/resources/src/lib/badges.test.ts +58 -0
  177. package/resources/src/lib/badges.ts +79 -0
  178. package/resources/src/lib/brand-mark.test.ts +87 -0
  179. package/resources/src/lib/brand-mark.ts +45 -0
  180. package/resources/src/lib/docs-shared.test.ts +148 -0
  181. package/resources/src/lib/docs-shared.ts +422 -0
  182. package/resources/src/lib/docs.ts +381 -0
  183. package/resources/src/lib/err-text.ts +21 -0
  184. package/resources/src/lib/excel.ts +152 -0
  185. package/resources/src/lib/favorite-path.ts +60 -0
  186. package/resources/src/lib/favorites.ts +59 -0
  187. package/resources/src/lib/features.ts +69 -0
  188. package/resources/src/lib/feedback-access.ts +17 -0
  189. package/resources/src/lib/feedbackAi.ts +1621 -0
  190. package/resources/src/lib/formRules.ts +76 -0
  191. package/resources/src/lib/format.ts +52 -0
  192. package/resources/src/lib/graph-cascade.ts +304 -0
  193. package/resources/src/lib/graph-focus.ts +301 -0
  194. package/resources/src/lib/graph-link.ts +17 -0
  195. package/resources/src/lib/graph-node-write.ts +150 -0
  196. package/resources/src/lib/graph-schema.ts +475 -0
  197. package/resources/src/lib/graph-scope.test.ts +46 -0
  198. package/resources/src/lib/graph-scope.ts +28 -0
  199. package/resources/src/lib/http.ts +157 -0
  200. package/resources/src/lib/inbox-types.ts +57 -0
  201. package/resources/src/lib/inbox.ts +55 -0
  202. package/resources/src/lib/menu-service.ts +204 -0
  203. package/resources/src/lib/notifications.ts +113 -0
  204. package/resources/src/lib/office.ts +531 -0
  205. package/resources/src/lib/ontology.ts +277 -0
  206. package/resources/src/lib/openaiCompat.ts +257 -0
  207. package/resources/src/lib/pending.ts +98 -0
  208. package/resources/src/lib/prisma.ts +30 -0
  209. package/resources/src/lib/rbac.test.ts +106 -0
  210. package/resources/src/lib/rbac.ts +185 -0
  211. package/resources/src/lib/search-data.ts +158 -0
  212. package/resources/src/lib/security-questions.ts +20 -0
  213. package/resources/src/lib/skin-theme.ts +155 -0
  214. package/resources/src/lib/system-config.ts +55 -0
  215. package/resources/src/lib/theme.ts +96 -0
  216. package/resources/src/lib/todos/feedback.ts +86 -0
  217. package/resources/src/lib/todos/index.ts +73 -0
  218. package/resources/src/lib/todos/types.ts +33 -0
  219. package/resources/src/lib/upload.ts +106 -0
  220. package/resources/src/lib/utils.test.ts +9 -0
  221. package/resources/src/lib/utils.ts +6 -0
  222. package/resources/src/lib/validation.test.ts +297 -0
  223. package/resources/src/lib/validation.ts +658 -0
  224. package/resources/src/lib/visit-stats.ts +109 -0
  225. package/resources/src/lib/visits.ts +111 -0
  226. package/resources/src/lib/zodField.ts +44 -0
  227. package/resources/tsconfig.json +41 -0
  228. package/resources/vitest.config.ts +16 -0
  229. package/resources/vitest.setup.ts +1 -0
@@ -0,0 +1,410 @@
1
+ /**
2
+ * schema / ontology 关系分析(供 check-config 与 suggest-relations 共用)。
3
+ *
4
+ * 为什么要有它:
5
+ * 知识图谱的关系全靠人(或 AI)在 ontology.ts 里手写,
6
+ * 而人只会写"自己想得到"的关系 —— 典型漏配是
7
+ * "考试"和"人员"都在视图里,但谁都忘了它们通过成绩单相连。
8
+ * 这里把 Prisma schema 里的外键和桥接表扒出来,和 ontology 对账,
9
+ * 让"漏关系"从"靠自觉"变成"能被机器发现"。
10
+ */
11
+
12
+ /**
13
+ * 去掉 // 与 /* *\/ 注释。
14
+ * 不剥的话,文件顶部的 `interface GraphView { entities: string[] }`
15
+ * 和文档里的示例会被误当成真实配置(第一次跑就踩了:本体数 0)。
16
+ */
17
+ function stripComments(src) {
18
+ return src
19
+ .replace(/\/\*[\s\S]*?\*\//g, '')
20
+ .split('\n')
21
+ .map((line) => line.replace(/\/\/.*$/, ''))
22
+ .join('\n');
23
+ }
24
+
25
+ /** 这些字段是审计/元数据,不算业务载荷 */
26
+ const META_FIELDS = new Set([
27
+ 'id',
28
+ 'createdAt',
29
+ 'updatedAt',
30
+ 'deletedAt',
31
+ 'sort',
32
+ 'status',
33
+ 'remark',
34
+ 'hidden',
35
+ ]);
36
+
37
+ /**
38
+ * 解析 prisma/schema.prisma。
39
+ * 只关心三样:模型名、标量字段、带 `fields: [...]` 的 @relation(真实外键)。
40
+ */
41
+ export function parseSchema(src) {
42
+ const models = [];
43
+ const lines = src.split('\n');
44
+ let cur = null;
45
+
46
+ for (const raw of lines) {
47
+ const line = raw.replace(/\/\/.*$/, '').trim();
48
+ if (!line) continue;
49
+
50
+ const m = line.match(/^model\s+(\w+)\s*\{/);
51
+ if (m) {
52
+ cur = { name: m[1], fields: [] };
53
+ continue;
54
+ }
55
+ if (!cur) continue;
56
+ if (line.startsWith('}')) {
57
+ models.push(cur);
58
+ cur = null;
59
+ continue;
60
+ }
61
+
62
+ // 字段行:name Type[?|[]] @attrs...
63
+ const f = line.match(/^(\w+)\s+([A-Za-z_][\w]*)(\[\])?(\?)?\s*(.*)$/);
64
+ if (!f) continue;
65
+ const [, name, type, isList, isOptional, attrs] = f;
66
+
67
+ const rel = attrs.match(/@relation\(([^)]*)\)/);
68
+ if (!rel) {
69
+ cur.fields.push({ name, type, isList: !!isList, optional: !!isOptional, isFk: false });
70
+ continue;
71
+ }
72
+ const fieldsMatch = rel[1].match(/fields:\s*\[([^\]]+)\]/);
73
+ if (!fieldsMatch) {
74
+ // 反向虚拟字段(如 users User[]),不是外键
75
+ cur.fields.push({ name, type, isList: !!isList, optional: !!isOptional, isFk: false });
76
+ continue;
77
+ }
78
+ cur.fields.push({
79
+ name,
80
+ type,
81
+ isList: !!isList,
82
+ optional: !!isOptional,
83
+ isFk: true,
84
+ fkField: fieldsMatch[1].trim().split(/\s*,\s*/)[0],
85
+ refModel: type,
86
+ });
87
+ }
88
+
89
+ const fks = [];
90
+ for (const model of models) {
91
+ for (const f of model.fields) {
92
+ if (f.isFk) fks.push({ model: model.name, field: f.fkField, refModel: f.refModel });
93
+ }
94
+ }
95
+
96
+ /**
97
+ * 桥接表:一个模型带 ≥2 个指向**不同模型**的外键。
98
+ * 不要求"纯中间表"——成绩单带 score 字段也算,
99
+ * 因为它照样能表达"考试 ↔ 人员"的关系(这恰恰是最常被漏的一种)。
100
+ */
101
+ const bridges = [];
102
+ for (const model of models) {
103
+ const own = fks.filter((f) => f.model === model.name && f.refModel !== model.name);
104
+ const seen = new Set();
105
+ const uniq = [];
106
+ for (const f of own) {
107
+ const key = f.refModel + '|' + f.field;
108
+ if (seen.has(key)) continue;
109
+ seen.add(key);
110
+ uniq.push(f);
111
+ }
112
+ if (uniq.length < 2) continue;
113
+ // 注意:Prisma 的 @relation 写在**虚拟关系字段**上(user User @relation(fields:[userId])),
114
+ // userId 本身是普通标量 —— 计算载荷时要把这些外键列名排掉,
115
+ // 否则 UserRole 会被误判成"带载荷的业务表"。
116
+ const fkNames = new Set(own.map((f) => f.field));
117
+ const payload = model.fields
118
+ .filter((f) => !f.isFk && !META_FIELDS.has(f.name) && !f.isList && !fkNames.has(f.name))
119
+ .map((f) => f.name);
120
+ bridges.push({
121
+ model: model.name,
122
+ fks: uniq,
123
+ // 有载荷字段 = 业务表(成绩/明细),空 = 纯中间表(UserRole)
124
+ pure: payload.length === 0,
125
+ payload,
126
+ });
127
+ }
128
+
129
+ return { models, fks, bridges };
130
+ }
131
+
132
+ /** 从源码里截出 `xxx: [ ... ]` 的数组内容(按括号配对,够用且不怕多行) */
133
+ function sliceArray(src, key) {
134
+ const i = src.indexOf(key);
135
+ if (i < 0) return null;
136
+ const open = src.indexOf('[', i);
137
+ if (open < 0) return null;
138
+ let depth = 0;
139
+ for (let j = open; j < src.length; j += 1) {
140
+ const ch = src[j];
141
+ if (ch === '[') depth += 1;
142
+ else if (ch === ']') {
143
+ depth -= 1;
144
+ if (depth === 0) return src.slice(open + 1, j);
145
+ }
146
+ }
147
+ return null;
148
+ }
149
+
150
+ /** 把数组内容按顶层 `{...}` 切成对象字符串 */
151
+ function splitObjects(block) {
152
+ const out = [];
153
+ let depth = 0;
154
+ let start = -1;
155
+ for (let i = 0; i < block.length; i += 1) {
156
+ const ch = block[i];
157
+ if (ch === '{') {
158
+ depth += 1;
159
+ if (depth === 1) start = i;
160
+ } else if (ch === '}') {
161
+ depth -= 1;
162
+ if (depth === 0 && start >= 0) {
163
+ out.push(block.slice(start, i + 1));
164
+ start = -1;
165
+ }
166
+ }
167
+ }
168
+ return out;
169
+ }
170
+
171
+ function str(block, key) {
172
+ const m = block.match(new RegExp(`${key}\\s*:\\s*'([^']*)'`));
173
+ return m ? m[1] : null;
174
+ }
175
+
176
+ function bool(block, key) {
177
+ return new RegExp(`${key}\\s*:\\s*true`).test(block);
178
+ }
179
+
180
+ /**
181
+ * 解析 ontology.ts 的 entities / relations / views。
182
+ * 只提取关系分析需要的字段,不试图做完整的 TS 解析。
183
+ */
184
+ export function parseOntology(rawSrc) {
185
+ const src = stripComments(rawSrc);
186
+ const start = src.indexOf('export const ONTOLOGY');
187
+ const body = start >= 0 ? src.slice(start) : src;
188
+
189
+ const entities = splitObjects(sliceArray(body, 'entities:') ?? '').map((o) => ({
190
+ id: str(o, 'id'),
191
+ label: str(o, 'label'),
192
+ model: str(o, 'model'),
193
+ }));
194
+
195
+ const relations = splitObjects(sliceArray(body, 'relations:') ?? '').map((o) => {
196
+ const viaBlock = o.match(/via\s*:\s*\{([\s\S]*?)\}/);
197
+ const via = viaBlock ? viaBlock[1] : '';
198
+ return {
199
+ id: str(o, 'id'),
200
+ label: str(o, 'label'),
201
+ from: str(o, 'from'),
202
+ to: str(o, 'to'),
203
+ field: str(via, 'field'),
204
+ reverse: bool(via, 'reverse'),
205
+ model: str(via, 'model'),
206
+ fromField: str(via, 'fromField'),
207
+ toField: str(via, 'toField'),
208
+ };
209
+ });
210
+
211
+ const views = splitObjects(sliceArray(body, 'views:') ?? '').map((o) => {
212
+ const list = (key) => {
213
+ const m = o.match(new RegExp(`${key}\\s*:\\s*\\[([^\\]]*)\\]`));
214
+ return m ? m[1].split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean) : [];
215
+ };
216
+ return {
217
+ id: str(o, 'id'),
218
+ label: str(o, 'label'),
219
+ entities: list('entities'),
220
+ relations: list('relations'),
221
+ root: str(o, 'root'),
222
+ };
223
+ });
224
+
225
+ return { entities, relations, views };
226
+ }
227
+
228
+ /** 模型名 ↔ 本体 id */
229
+ function makeModelMap(entities) {
230
+ const byModel = new Map();
231
+ const byEntity = new Map();
232
+ for (const e of entities) {
233
+ if (!e.model) continue;
234
+ byModel.set(e.model, e);
235
+ byEntity.set(e.id, e);
236
+ }
237
+ return { byModel, byEntity };
238
+ }
239
+
240
+ /**
241
+ * schema 里的"连接":直接外键,或经桥接表。
242
+ * 只保留两端都是已声明本体的连接 —— 没建本体就没法画,报了也没用。
243
+ */
244
+ export function schemaLinks(schema, entities, relations) {
245
+ const { byModel } = makeModelMap(entities);
246
+ const links = [];
247
+ const declared = (aModel, bModel, via) =>
248
+ relations.some((r) => {
249
+ const ra = byModel.get(aModel);
250
+ const rb = byModel.get(bModel);
251
+ if (!ra || !rb) return false;
252
+ const pairOk =
253
+ (r.from === ra.id && r.to === rb.id) || (r.from === rb.id && r.to === ra.id);
254
+ if (!pairOk) return false;
255
+ if (via.kind === 'fk') {
256
+ return r.field === via.field || (r.model === via.model && via.model);
257
+ }
258
+ return r.model === via.model;
259
+ });
260
+
261
+ for (const fk of schema.fks) {
262
+ if (fk.model === fk.refModel) continue; // 自关联(部门树/菜单树)不强制
263
+ const a = byModel.get(fk.model);
264
+ const b = byModel.get(fk.refModel);
265
+ if (!a || !b) continue;
266
+ const via = { kind: 'fk', field: fk.field };
267
+ links.push({
268
+ kind: 'fk',
269
+ aModel: fk.model,
270
+ bModel: fk.refModel,
271
+ aEntity: a.id,
272
+ bEntity: b.id,
273
+ field: fk.field,
274
+ declared: declared(fk.model, fk.refModel, via),
275
+ });
276
+ }
277
+
278
+ for (const br of schema.bridges) {
279
+ for (let i = 0; i < br.fks.length; i += 1) {
280
+ for (let j = 0; j < br.fks.length; j += 1) {
281
+ if (i === j) continue;
282
+ const left = br.fks[i];
283
+ const right = br.fks[j];
284
+ if (left.refModel === right.refModel) continue;
285
+ const a = byModel.get(left.refModel);
286
+ const b = byModel.get(right.refModel);
287
+ if (!a || !b) continue;
288
+ const via = { kind: 'bridge', model: br.model };
289
+ links.push({
290
+ kind: 'bridge',
291
+ aModel: left.refModel,
292
+ bModel: right.refModel,
293
+ aEntity: a.id,
294
+ bEntity: b.id,
295
+ bridgeModel: br.model,
296
+ fromField: left.field,
297
+ toField: right.field,
298
+ pure: br.pure,
299
+ payload: br.payload,
300
+ declared: declared(left.refModel, right.refModel, via),
301
+ });
302
+ }
303
+ }
304
+ }
305
+
306
+ // 去重(A→B 与 B→A 视作同一条连接;桥接表只留一条)
307
+ const seen = new Set();
308
+ const out = [];
309
+ for (const l of links) {
310
+ const pair = [l.aEntity, l.bEntity].sort().join('|');
311
+ const key = `${l.kind}|${pair}|${l.kind === 'fk' ? '' : l.bridgeModel}`;
312
+ if (seen.has(key)) continue;
313
+ seen.add(key);
314
+ out.push(l);
315
+ }
316
+ return out;
317
+ }
318
+
319
+ /** 视图内的关系图:entity → 相邻 entity 集合(只算视图内声明且列出的关系) */
320
+ export function viewAdjacency(view, relations, entities) {
321
+ const { byEntity } = makeModelMap(entities);
322
+ const adj = new Map();
323
+ for (const id of view.entities) adj.set(id, new Set());
324
+ for (const rid of view.relations) {
325
+ const r = relations.find((x) => x.id === rid);
326
+ if (!r) continue;
327
+ if (!byEntity.has(r.from) || !byEntity.has(r.to)) continue;
328
+ if (!adj.has(r.from)) adj.set(r.from, new Set());
329
+ if (!adj.has(r.to)) adj.set(r.to, new Set());
330
+ adj.get(r.from).add(r.to);
331
+ adj.get(r.to).add(r.from);
332
+ }
333
+ return adj;
334
+ }
335
+
336
+ /** 两跳内可达(含直达) */
337
+ export function reachableWithin(adj, from, to, hops = 2) {
338
+ if (from === to) return 0;
339
+ let frontier = [from];
340
+ const seen = new Set([from]);
341
+ for (let d = 1; d <= hops; d += 1) {
342
+ const next = [];
343
+ for (const cur of frontier) {
344
+ for (const nb of adj.get(cur) ?? []) {
345
+ if (nb === to) return d;
346
+ if (!seen.has(nb)) {
347
+ seen.add(nb);
348
+ next.push(nb);
349
+ }
350
+ }
351
+ }
352
+ frontier = next;
353
+ }
354
+ return -1;
355
+ }
356
+
357
+ /**
358
+ * 总分析:
359
+ * missing —— schema 有连接、ontology 里没声明(全局)
360
+ * viewIssues —— 视图里两端都在、schema 有连接,但图上既没有直连也没有两跳(**硬错误**)
361
+ * indirectNote —— 能两跳到达,但没有直连(提示,不报错)
362
+ */
363
+ export function analyze(schemaSrc, ontologySrc) {
364
+ const schema = parseSchema(schemaSrc);
365
+ const { entities, relations, views } = parseOntology(ontologySrc);
366
+ const links = schemaLinks(schema, entities, relations);
367
+ const { byEntity } = makeModelMap(entities);
368
+
369
+ const missing = links.filter((l) => !l.declared);
370
+
371
+ const viewIssues = [];
372
+ const indirectNote = [];
373
+ for (const view of views) {
374
+ const adj = viewAdjacency(view, relations, entities);
375
+ const inView = new Set(view.entities);
376
+ for (const link of links) {
377
+ if (!inView.has(link.aEntity) || !inView.has(link.bEntity)) continue;
378
+ const direct = (adj.get(link.aEntity) ?? new Set()).has(link.bEntity);
379
+ if (direct) continue;
380
+ const d = reachableWithin(adj, link.aEntity, link.bEntity, 2);
381
+ const a = byEntity.get(link.aEntity);
382
+ const b = byEntity.get(link.bEntity);
383
+ const desc = {
384
+ view: view.id,
385
+ a: a?.label ?? link.aEntity,
386
+ b: b?.label ?? link.bEntity,
387
+ aId: link.aEntity,
388
+ bId: link.bEntity,
389
+ link,
390
+ hops: d,
391
+ };
392
+ if (d > 0) indirectNote.push(desc);
393
+ else viewIssues.push(desc);
394
+ }
395
+ }
396
+
397
+ return { schema, entities, relations, views, links, missing, viewIssues, indirectNote };
398
+ }
399
+
400
+ /** 生成可直接粘贴的 relations 片段 */
401
+ export function suggestSnippet(link, entities) {
402
+ const { byEntity } = makeModelMap(entities);
403
+ const a = byEntity.get(link.aEntity);
404
+ const b = byEntity.get(link.bEntity);
405
+ if (!a || !b) return '';
406
+ if (link.kind === 'fk') {
407
+ return `{ id: '${a.id}_${b.id}', label: '关联${b.label}', from: '${a.id}', to: '${b.id}', via: { field: '${link.field}' } },`;
408
+ }
409
+ return `{ id: '${a.id}_${b.id}', label: '关联${b.label}', from: '${a.id}', to: '${b.id}', via: { model: '${link.bridgeModel}', fromField: '${link.fromField}', toField: '${link.toField}' } },`;
410
+ }
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 给 Next 打 trace 并发建流补丁(幂等,可反复跑)
4
+ *
5
+ * node scripts\patch-next-trace.mjs # 打补丁
6
+ * node scripts\patch-next-trace.mjs --check # 只检查,不写(退出码 1 = 补丁缺失)
7
+ * node scripts\patch-next-trace.mjs --file <路径> # 指定文件(自测用)
8
+ *
9
+ * 为什么需要它:`npm ci` / 重装 node_modules 会覆盖 node_modules/next/dist/trace/report/to-json.js,
10
+ * 补丁就丢了。这个脚本把补丁变成可重复执行的动作 —— 装完依赖跑一次即可。
11
+ *
12
+ * 补的是什么(详见 README 第八节):
13
+ * Next 的 trace 上报在 `if (!writeStream)` 与 `writeStream = new RotatingWriteStream(...)`
14
+ * 之间夹了一个 `await mkdir(...)`。同一个 tick 内两次 flush(trace 批次满 101 条就会触发)
15
+ * 会双双通过判空、各建一个写流;两个流同时"创建"同一个还不存在的 .next/trace,
16
+ * Windows 上第二次 open 返回 EPERM,而没有 error 监听器 → uncaughtException → build 崩。
17
+ */
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const here = path.dirname(fileURLToPath(import.meta.url));
23
+ const defaultFile = path.resolve(here, "../node_modules/next/dist/trace/report/to-json.js");
24
+
25
+ const args = process.argv.slice(2);
26
+ const checkOnly = args.includes("--check");
27
+ const fileArgIndex = args.indexOf("--file");
28
+ const target = fileArgIndex >= 0 ? path.resolve(args[fileArgIndex + 1]) : defaultFile;
29
+
30
+ const MARK = "[patch-next-trace]";
31
+
32
+ /** 上游原样(15.5.26)——必须逐字匹配,改了说明 Next 版本变了,要重新看代码 */
33
+ const UPSTREAM_INIT = ` if (!writeStream) {
34
+ await _fs.default.promises.mkdir(distDir, {
35
+ recursive: true
36
+ });
37
+ const file = _path.default.join(distDir, 'trace');
38
+ writeStream = new RotatingWriteStream(file, // Development is limited to 50MB, production is unlimited
39
+ phase === _constants.PHASE_DEVELOPMENT_SERVER ? 52428800 : Infinity);
40
+ }`;
41
+
42
+ const PATCHED_INIT = ` if (!writeStream) {
43
+ // ${MARK} 判空与赋值之间不能有 await:同一 tick 两次 flush 会各建一个写流,
44
+ // 两个流并发创建同一个不存在的 .next/trace → Windows 第二次 open 得到 EPERM。
45
+ _fs.default.mkdirSync(distDir, {
46
+ recursive: true
47
+ });
48
+ const file = _path.default.join(distDir, 'trace');
49
+ writeStream = new RotatingWriteStream(file, // Development is limited to 50MB, production is unlimited
50
+ phase === _constants.PHASE_DEVELOPMENT_SERVER ? 52428800 : Infinity);
51
+ }`;
52
+
53
+ const UPSTREAM_STREAM = ` this.writeStream = _fs.default.createWriteStream(this.file, writeStreamOptions);`;
54
+ const PATCHED_STREAM = ` this.writeStream = _fs.default.createWriteStream(this.file, writeStreamOptions);
55
+ // ${MARK} trace 是可丢的诊断数据,绝不让它的 error 变成 uncaughtException 打死构建
56
+ this.writeStream.on('error', ()=>{});`;
57
+
58
+ if (!fs.existsSync(target)) {
59
+ console.error(`✗ 找不到文件:${target}\n 先在 biz-full 里装好依赖(node_modules/next 必须存在)`);
60
+ process.exit(1);
61
+ }
62
+
63
+ const src = fs.readFileSync(target, "utf8");
64
+
65
+ if (src.includes(MARK)) {
66
+ console.log(`✓ 补丁已在位:${path.relative(process.cwd(), target)}`);
67
+ process.exit(0);
68
+ }
69
+
70
+ if (checkOnly) {
71
+ console.error(`✗ 补丁缺失:${path.relative(process.cwd(), target)}`);
72
+ process.exit(1);
73
+ }
74
+
75
+ if (!src.includes(UPSTREAM_INIT)) {
76
+ console.error("✗ 没匹配到上游的建流代码 —— Next 版本可能变了,别硬套。\n 请打开该文件确认 writeStream 的初始化写法,再更新本脚本的 UPSTREAM_INIT。");
77
+ process.exit(1);
78
+ }
79
+ if (!src.includes(UPSTREAM_STREAM)) {
80
+ console.error("✗ 没匹配到 createWriteStream 那一行 —— Next 版本可能变了。");
81
+ process.exit(1);
82
+ }
83
+
84
+ const out = src.replace(UPSTREAM_INIT, PATCHED_INIT).replace(UPSTREAM_STREAM, PATCHED_STREAM);
85
+ fs.writeFileSync(target, out);
86
+ console.log(`✓ 打补丁完成:${path.relative(process.cwd(), target)}`);
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 关系挖掘器 —— 把 schema 里"物理存在但 ontology 没写"的关系找出来。
4
+ *
5
+ * 用法:
6
+ * npm run relations 列出候选关系、漏配项、桥接表,并给出可粘贴的 TS 片段
7
+ * npm run relations -- --strict 只要有"视图内该连未连"就退出码 1(CI 用)
8
+ *
9
+ * 为什么需要它:
10
+ * 图谱关系是手写的,人只会写"想得到的"。最典型的漏配是
11
+ * 考试与人员:两者都在视图里,靠成绩单(examId + personId)相连,
12
+ * 但写 ontology 时只想着"考试属于部门",就把它漏了。
13
+ * 这个脚本把每条外键、每张桥接表都摊开对账,漏没漏一目了然。
14
+ */
15
+ import { readFileSync } from 'node:fs';
16
+ import { analyze, suggestSnippet } from './lib/schema-graph.mjs';
17
+
18
+ const SCHEMA = 'prisma/schema.prisma';
19
+ const ONTOLOGY = 'src/lib/ontology.ts';
20
+ const strict = process.argv.includes('--strict');
21
+
22
+ function read(path) {
23
+ try {
24
+ return readFileSync(path, 'utf8');
25
+ } catch {
26
+ console.error(`\n[relations] 找不到 ${path},请在 biz-full 目录下运行\n`);
27
+ process.exit(1);
28
+ }
29
+ }
30
+
31
+ const result = analyze(read(SCHEMA), read(ONTOLOGY));
32
+ const { entities, relations, views, links, missing, viewIssues, indirectNote, schema } = result;
33
+
34
+ const declared = links.filter((l) => l.declared);
35
+ const byEntity = new Map(entities.map((e) => [e.id, e]));
36
+
37
+ console.log('\n══════════ 关系挖掘报告 ══════════\n');
38
+ console.log(`本体 ${entities.length} 个 · 已声明关系 ${relations.length} 条 · 视图 ${views.length} 个`);
39
+ console.log(`schema 外键 ${schema.fks.length} 条 · 桥接表 ${schema.bridges.length} 张\n`);
40
+
41
+ if (schema.bridges.length) {
42
+ console.log('── 桥接表(最容易漏的关系来源)──');
43
+ for (const br of schema.bridges) {
44
+ const refs = br.fks.map((f) => `${f.refModel}.${f.field}`).join(' + ');
45
+ const extra = br.pure ? '纯中间表' : `带载荷:${br.payload.join(', ')}`;
46
+ console.log(` ${br.model} → ${refs} (${extra})`);
47
+ if (br.fks.length >= 2) {
48
+ const a = byEntity.get(br.fks[0].refModel) ?? { label: br.fks[0].refModel };
49
+ const b = byEntity.get(br.fks[1].refModel) ?? { label: br.fks[1].refModel };
50
+ console.log(` 可表达:${a.label} ↔ ${b.label}(用 via.model: '${br.model}')`);
51
+ }
52
+ }
53
+ console.log('');
54
+ }
55
+
56
+ if (declared.length) {
57
+ console.log('── 已声明 ──');
58
+ for (const l of declared) {
59
+ const a = byEntity.get(l.aEntity)?.label ?? l.aEntity;
60
+ const b = byEntity.get(l.bEntity)?.label ?? l.bEntity;
61
+ console.log(` ✓ ${a} ↔ ${b}${l.kind === 'bridge' ? `(经 ${l.bridgeModel})` : `(${l.field})`}`);
62
+ }
63
+ console.log('');
64
+ }
65
+
66
+ if (missing.length) {
67
+ console.log('── 未声明(可直接粘贴到 ontology.ts 的 relations)──');
68
+ for (const l of missing) {
69
+ const a = byEntity.get(l.aEntity)?.label ?? l.aEntity;
70
+ const b = byEntity.get(l.bEntity)?.label ?? l.bEntity;
71
+ const how = l.kind === 'bridge'
72
+ ? `经桥接表 ${l.bridgeModel}(${l.fromField} / ${l.toField}${l.pure ? ',纯中间表' : `,含 ${l.payload.join('/')}`})`
73
+ : `外键 ${l.aModel}.${l.field}`;
74
+ console.log(` ✗ ${a} ↔ ${b} ${how}`);
75
+ const snippet = suggestSnippet(l, entities);
76
+ if (snippet) console.log(` ${snippet}`);
77
+ }
78
+ console.log('');
79
+ }
80
+
81
+ if (indirectNote.length) {
82
+ console.log('── 只能间接到达(两跳内可连通,不报错)──');
83
+ for (const n of indirectNote) {
84
+ console.log(` · 视图「${n.view}」:${n.a} ↔ ${n.b} 隔着 1 个节点,${n.hops} 跳可达`);
85
+ console.log(` 如果评委要想看它们的关系,建议补一条 via.model 直连`);
86
+ }
87
+ console.log('');
88
+ }
89
+
90
+ if (viewIssues.length) {
91
+ console.log('── ✗ 视图内该连未连(check:config 会报错)──');
92
+ for (const n of viewIssues) {
93
+ console.log(` ✗ 视图「${n.view}」:${n.a} 和 ${n.b} 都在图里,schema 也有连接,但图上没有任何路径`);
94
+ console.log(` ${suggestSnippet(n.link, entities)}`);
95
+ }
96
+ console.log('');
97
+ }
98
+
99
+ if (!links.length) {
100
+ console.log('(schema 里没有落到"已声明本体"上的连接 —— 先建本体,再谈关系)\n');
101
+ }
102
+
103
+ const ok = viewIssues.length === 0;
104
+ console.log(ok ? '✓ 视图内的关系没有明显漏配\n' : `✗ 有 ${viewIssues.length} 处关系漏配\n`);
105
+ process.exit(strict && !ok ? 1 : 0);