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,475 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { prisma } from '@/lib/prisma';
4
+ import ONTOLOGY from '@/lib/ontology';
5
+
6
+ /**
7
+ * 图谱节点「字段元数据」—— 让右侧详情面板能按 schema 自动长出表单。
8
+ *
9
+ * ★ 为什么要这一层:
10
+ * 以前详情面板只会拼一个「名称」输入框(名字写死在页面上),
11
+ * 类型 / 附加 / 状态 / 职位 / 备注这些"人一眼能看到的字段"全是只读文本。
12
+ * 但图谱的本体是配置驱动的(换业务只改 ontology.ts),
13
+ * 所以表单**不能**在页面里写死字段,只能从 prisma/schema.prisma 反推:
14
+ * 字段名 / 中文标签 / 数据类型 → 渲染成 Input / Select / Switch / DatePicker。
15
+ *
16
+ * ★ 中文标签的来源(按优先级):
17
+ * 1. ontology.ts 里显式声明的 fieldLabels
18
+ * 2. schema 里字段后面的行注释(`status Int @default(1) // 1 在职 0 离职`)
19
+ * 3. 内置常用字段词典(name/code/status/remark…)
20
+ * 4. 字段名原样
21
+ *
22
+ * ★ 选项(枚举)的来源:
23
+ * 1. ontology.ts 的 fieldOptions
24
+ * 2. prisma enum 声明
25
+ * 3. 注释里的 `a | b | c` 或 `1 在职 0 离职`(SQLite 没有 enum,这是唯一线索)
26
+ */
27
+
28
+ export type FieldKind = 'text' | 'longtext' | 'number' | 'boolean' | 'datetime' | 'enum' | 'fk';
29
+
30
+ export interface FieldOption {
31
+ label: string;
32
+ value: string | number;
33
+ }
34
+
35
+ export interface FieldMeta {
36
+ name: string;
37
+ label: string;
38
+ kind: FieldKind;
39
+ prismaType: string;
40
+ /** 必填且无默认值 —— 清空会被拒绝 */
41
+ required: boolean;
42
+ nullable: boolean;
43
+ /** 唯一约束(code / 账号这类,迁移本体时不能直接继承) */
44
+ unique: boolean;
45
+ /** 标题字段 / 附加字段,面板里排在最前面 */
46
+ primary?: boolean;
47
+ options?: FieldOption[];
48
+ /** 外键指向的 Prisma 模型 */
49
+ refModel?: string;
50
+ /** 注释里的说明(当 placeholder / 提示用) */
51
+ hint?: string;
52
+ }
53
+
54
+ const SCALARS = new Set([
55
+ 'String', 'Int', 'BigInt', 'Float', 'Decimal', 'Boolean', 'DateTime', 'Json', 'Bytes',
56
+ ]);
57
+
58
+ /** 永远不给前端看的字段:主键 / 审计列 */
59
+ const READONLY = new Set(['id', 'createdAt', 'updatedAt', 'deletedAt']);
60
+
61
+ /** 敏感字段:哪怕是本体里声明的模型,也不允许把这个接口当后门改密码 */
62
+ const SENSITIVE = /(password|passwd|pwd|salt|hash|secret|token|apikey|credential)/i;
63
+
64
+ /** 长文本字段:用 TextArea,别让备注挤在一个单行框里 */
65
+ const LONGTEXT = /(remark|desc|detail|note|content|reason|address|summary|intro|comment|reply|body|message|memo)/i;
66
+
67
+ /** 常用字段的中文名(schema 没写注释时兜底) */
68
+ const LABELS: Record<string, string> = {
69
+ name: '名称',
70
+ title: '标题',
71
+ code: '编码',
72
+ no: '编号',
73
+ jobNo: '工号',
74
+ status: '状态',
75
+ type: '类型',
76
+ sort: '排序',
77
+ remark: '备注',
78
+ description: '描述',
79
+ detail: '明细',
80
+ phone: '手机号',
81
+ mobile: '手机号',
82
+ email: '邮箱',
83
+ gender: '性别',
84
+ position: '职位',
85
+ post: '岗位',
86
+ hireDate: '入职日期',
87
+ salary: '月薪',
88
+ amount: '金额',
89
+ quantity: '数量',
90
+ price: '价格',
91
+ deptId: '所属部门',
92
+ parentId: '上级',
93
+ userId: '关联账号',
94
+ roleId: '关联角色',
95
+ date: '日期',
96
+ startTime: '开始时间',
97
+ endTime: '结束时间',
98
+ startDate: '开始日期',
99
+ endDate: '结束日期',
100
+ address: '地址',
101
+ contact: '联系方式',
102
+ reply: '回复',
103
+ score: '得分',
104
+ level: '等级',
105
+ batchId: '所属批次',
106
+ hidden: '是否隐藏',
107
+ };
108
+
109
+ /* ------------------------------ schema 解析 ------------------------------ */
110
+
111
+ let schemaCache: string | null | undefined;
112
+
113
+ function schemaText(): string | null {
114
+ if (schemaCache !== undefined) return schemaCache;
115
+ try {
116
+ schemaCache = readFileSync(join(process.cwd(), 'prisma', 'schema.prisma'), 'utf8');
117
+ } catch {
118
+ schemaCache = null;
119
+ }
120
+ return schemaCache;
121
+ }
122
+
123
+ function parseEnums(src: string): Map<string, string[]> {
124
+ const out = new Map<string, string[]>();
125
+ for (const m of src.matchAll(/enum\s+(\w+)\s*\{([\s\S]*?)\n\}/g)) {
126
+ const vals = m[2]
127
+ .split('\n')
128
+ .map((s) => s.trim())
129
+ .filter((s) => s && !s.startsWith('//') && !s.startsWith('@@'))
130
+ .map((s) => s.split(/\s+/)[0]);
131
+ if (vals.length) out.set(m[1], vals);
132
+ }
133
+ return out;
134
+ }
135
+
136
+ /** 把 schema 里的行注释拆出来(`///` 文档注释同样处理) */
137
+ function splitComment(line: string): { decl: string; comment: string } {
138
+ const i = line.indexOf('//');
139
+ if (i < 0) return { decl: line, comment: '' };
140
+ return {
141
+ decl: line.slice(0, i),
142
+ comment: line.slice(i).replace(/^\/+\s*/, '').trim(),
143
+ };
144
+ }
145
+
146
+ /** 注释 → 中文标签(注释常用于写取值说明,这种就不要当标签) */
147
+ function labelFromComment(comment: string, type: string): string | null {
148
+ if (!comment) return null;
149
+ if (comment.includes('|')) return null;
150
+ if (/^\d+\s/.test(comment)) return null; // "1 启用 0 停用"
151
+ const cut = comment.split(/[((::,,。;;]/)[0].trim();
152
+ if (!cut || cut.length > 12) return null;
153
+ if (type === 'Int' && /\d/.test(cut)) return null;
154
+ return cut;
155
+ }
156
+
157
+ /** 注释 → 选项(SQLite 没有 enum,只能从注释里认) */
158
+ function optionsFromComment(comment: string, type: string): FieldOption[] | undefined {
159
+ if (!comment) return undefined;
160
+ if (type === 'String' && comment.includes('|')) {
161
+ const vals = comment.split('|').map((s) => s.trim()).filter(Boolean);
162
+ if (vals.length >= 2) return vals.map((v) => ({ label: v, value: v }));
163
+ }
164
+ if (type === 'Int') {
165
+ const hits = [...comment.matchAll(/(\d+)\s*([\u4e00-\u9fa5A-Za-z][\u4e00-\u9fa5A-Za-z0-9]*)/g)];
166
+ if (hits.length >= 2 && hits.length <= 6) {
167
+ return hits.map((h) => ({ label: h[2], value: Number(h[1]) }));
168
+ }
169
+ }
170
+ return undefined;
171
+ }
172
+
173
+ interface ModelMeta {
174
+ /** 可编辑字段(已剔除主键 / 审计列 / 敏感列) */
175
+ fields: FieldMeta[];
176
+ byName: Map<string, FieldMeta>;
177
+ fk: Map<string, string>;
178
+ soft: boolean;
179
+ }
180
+
181
+ let modelCache: Map<string, ModelMeta> | undefined;
182
+
183
+ function parseModels(): Map<string, ModelMeta> {
184
+ const out = new Map<string, ModelMeta>();
185
+ const src = schemaText();
186
+ if (!src) return out;
187
+ const enums = parseEnums(src);
188
+
189
+ for (const block of src.matchAll(/model\s+(\w+)\s*\{([\s\S]*?)\n\}/g)) {
190
+ const model = block[1];
191
+ const body = block[2];
192
+
193
+ // 第一遍:外键(@relation(fields: [deptId], references: [id]) → deptId → Department)
194
+ const fk = new Map<string, string>();
195
+ for (const line of body.split('\n')) {
196
+ const m = line.match(
197
+ /^\s*(\w+)\s+([A-Za-z_]\w*)(\[\])?(\?)?\s+@relation\(([^)]*)\)/
198
+ );
199
+ if (!m) continue;
200
+ const fields = m[5].match(/fields:\s*\[(\w+)\]/);
201
+ if (fields) fk.set(fields[1], m[2]);
202
+ }
203
+
204
+ const fields: FieldMeta[] = [];
205
+ const byName = new Map<string, FieldMeta>();
206
+ let soft = false;
207
+
208
+ for (const raw of body.split('\n')) {
209
+ const trimmed = raw.trim();
210
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('@@')) continue;
211
+ const { decl, comment } = splitComment(trimmed);
212
+
213
+ const m = decl.match(/^(\w+)\s+([A-Za-z_]\w*)(\[\])?(\?)?\s*(.*)$/);
214
+ if (!m) continue;
215
+ const [, name, type, list, opt, attrs] = m;
216
+ if (name === 'deletedAt') soft = true;
217
+ if (list) continue; // 关系数组(users / children / logs)不是字段
218
+ if (!SCALARS.has(type) && !enums.has(type)) continue; // 关系对象字段,跳过
219
+ if (READONLY.has(name) || SENSITIVE.test(name)) continue;
220
+ if (attrs.includes('@id')) continue;
221
+
222
+ const nullable = opt === '?';
223
+ const hasDefault = /@default|@updatedAt/.test(attrs);
224
+ const refModel = fk.get(name);
225
+ const kind = kindOf(name, type, comment, enums, refModel);
226
+ const options =
227
+ kind === 'enum'
228
+ ? enumOptions(type, enums) ?? optionsFromComment(comment, type)
229
+ : undefined;
230
+ const label =
231
+ labelFromComment(comment, type) ?? LABELS[name] ?? name;
232
+
233
+ const meta: FieldMeta = {
234
+ name,
235
+ label,
236
+ kind,
237
+ prismaType: type,
238
+ required: !nullable && !hasDefault,
239
+ nullable,
240
+ unique: /@unique/.test(attrs),
241
+ options,
242
+ refModel,
243
+ hint: comment || undefined,
244
+ };
245
+ fields.push(meta);
246
+ byName.set(name, meta);
247
+ }
248
+
249
+ out.set(model, { fields, byName, fk, soft });
250
+ }
251
+ return out;
252
+ }
253
+
254
+ function kindOf(
255
+ name: string,
256
+ type: string,
257
+ comment: string,
258
+ enums: Map<string, string[]>,
259
+ refModel?: string
260
+ ): FieldKind {
261
+ if (refModel) return 'fk';
262
+ if (enums.has(type)) return 'enum';
263
+ if (type === 'DateTime') return 'datetime';
264
+ if (type === 'Boolean') return 'boolean';
265
+ if (['Int', 'BigInt', 'Float', 'Decimal'].includes(type)) {
266
+ return optionsFromComment(comment, type) ? 'enum' : 'number';
267
+ }
268
+ if (type === 'Json') return 'longtext';
269
+ // String 也可能是枚举(SQLite 没有 enum,注释里写了 `male | female` 就认)
270
+ if (type === 'String' && (optionsFromComment(comment, type)?.length ?? 0) >= 2) return 'enum';
271
+ return LONGTEXT.test(name) ? 'longtext' : 'text';
272
+ }
273
+
274
+ function enumOptions(type: string, enums: Map<string, string[]>): FieldOption[] | undefined {
275
+ const vals = enums.get(type);
276
+ if (!vals?.length) return undefined;
277
+ return vals.map((v) => ({ label: v, value: v }));
278
+ }
279
+
280
+ function models(): Map<string, ModelMeta> {
281
+ if (!modelCache) modelCache = parseModels();
282
+ return modelCache;
283
+ }
284
+
285
+ function modelMeta(model: string): ModelMeta {
286
+ return models().get(model) ?? { fields: [], byName: new Map(), fk: new Map(), soft: false };
287
+ }
288
+
289
+ /* ------------------------------ 对外接口 ------------------------------ */
290
+
291
+ export function isSensitive(name: string) {
292
+ return SENSITIVE.test(name);
293
+ }
294
+
295
+ /** 该模型所有可编辑字段(schema 顺序) */
296
+ export function fieldsOfModel(model: string): FieldMeta[] {
297
+ return modelMeta(model).fields;
298
+ }
299
+
300
+ export function fieldOf(model: string, name: string): FieldMeta | undefined {
301
+ return modelMeta(model).byName.get(name);
302
+ }
303
+
304
+ export function fkMapOf(model: string): Map<string, string> {
305
+ return modelMeta(model).fk;
306
+ }
307
+
308
+ export function hasSoftDelete(model: string): boolean {
309
+ return modelMeta(model).soft;
310
+ }
311
+
312
+ /**
313
+ * 某个本体在详情面板里要展示的字段:
314
+ * 标题字段 → 附加字段 → 其余字段。标签 / 选项可被 ontology.ts 覆盖。
315
+ */
316
+ export function fieldsForEntity(entityId: string): FieldMeta[] {
317
+ const ent = ONTOLOGY.entities.find((e) => e.id === entityId);
318
+ if (!ent) return [];
319
+ const all = fieldsOfModel(ent.model);
320
+ const labels = ONTOLOGY.fieldLabels?.[entityId] ?? ONTOLOGY.fieldLabels?.[ent.model] ?? {};
321
+ const opts = ONTOLOGY.fieldOptions?.[entityId] ?? ONTOLOGY.fieldOptions?.[ent.model] ?? {};
322
+
323
+ const decorated = all.map((f) => ({
324
+ ...f,
325
+ label: labels[f.name] ?? f.label,
326
+ options: opts[f.name]?.length ? opts[f.name] : f.options,
327
+ primary: f.name === ent.titleField || (ent.subtitleFields ?? []).includes(f.name),
328
+ }));
329
+
330
+ const order = [ent.titleField, ...(ent.subtitleFields ?? [])];
331
+ const rank = (n: string) => {
332
+ const i = order.indexOf(n);
333
+ return i < 0 ? order.length + 1 : i;
334
+ };
335
+ // 稳定排序:主字段按 titleField → subtitleFields 的顺序排,其余保持 schema 顺序
336
+ return decorated
337
+ .map((f, i) => ({ f, i }))
338
+ .sort((a, b) => rank(a.f.name) - rank(b.f.name) || a.i - b.i)
339
+ .map((x) => x.f);
340
+ }
341
+
342
+ /** 外键下拉选项(取标题字段做 label) */
343
+ export async function optionsOf(refModel: string): Promise<FieldOption[]> {
344
+ const delegate = (prisma as unknown as Record<string, unknown>)[refModel] as
345
+ | { findMany?: (a: unknown) => Promise<Record<string, unknown>[]> }
346
+ | undefined;
347
+ if (!delegate?.findMany) return [];
348
+
349
+ const ent = ONTOLOGY.entities.find((e) => e.model === refModel);
350
+ const titleField =
351
+ ent?.titleField ??
352
+ fieldsOfModel(refModel).find((f) => f.kind === 'text' && f.required)?.name ??
353
+ fieldsOfModel(refModel).find((f) => f.kind === 'text')?.name ??
354
+ 'id';
355
+ const secondary = fieldsOfModel(refModel).find(
356
+ (f) => f.kind === 'text' && f.name !== titleField
357
+ )?.name;
358
+
359
+ try {
360
+ const rows = await delegate.findMany({
361
+ where: hasSoftDelete(refModel) ? { deletedAt: null } : {},
362
+ select: { id: true, [titleField]: true, ...(secondary ? { [secondary]: true } : {}) },
363
+ take: 200,
364
+ orderBy: { [titleField]: 'asc' },
365
+ });
366
+ return rows.map((r) => {
367
+ const main = String(r[titleField] ?? r.id);
368
+ const extra = secondary ? String(r[secondary] ?? '') : '';
369
+ return {
370
+ label: extra ? `${main}(${extra})` : main,
371
+ value: String(r.id),
372
+ };
373
+ });
374
+ } catch {
375
+ // 标题字段取错时不要让整个面板挂掉,退化成"显示 id"
376
+ return [];
377
+ }
378
+ }
379
+
380
+ /** 任意值 → 该字段可写库的值;undefined 表示"这次别动这个字段" */
381
+ export function coerceValue(field: FieldMeta, v: unknown): unknown {
382
+ if (v === null) return field.nullable ? null : undefined;
383
+ if (v === undefined) return undefined;
384
+ if (typeof v === 'string' && v.trim() === '') return field.nullable ? null : '';
385
+
386
+ switch (field.kind) {
387
+ case 'number': {
388
+ const n = Number(v);
389
+ if (!Number.isFinite(n)) return undefined;
390
+ return field.prismaType === 'Float' || field.prismaType === 'Decimal' ? n : Math.trunc(n);
391
+ }
392
+ case 'boolean':
393
+ if (typeof v === 'string') return v === 'true' || v === '1';
394
+ return Boolean(v);
395
+ case 'datetime': {
396
+ const d = v instanceof Date ? v : new Date(String(v));
397
+ return Number.isNaN(d.getTime()) ? undefined : d;
398
+ }
399
+ case 'enum': {
400
+ const hit = field.options?.find((o) => String(o.value) === String(v));
401
+ return hit ? hit.value : String(v);
402
+ }
403
+ case 'fk':
404
+ return String(v);
405
+ default:
406
+ return typeof v === 'string' ? v : String(v);
407
+ }
408
+ }
409
+
410
+ /** 清洗 + 类型转换:只留真实存在、可编辑、非敏感的字段 */
411
+ export function coercePayload(model: string, data: Record<string, unknown>): Record<string, unknown> {
412
+ const meta = modelMeta(model);
413
+ const out: Record<string, unknown> = {};
414
+ for (const [k, v] of Object.entries(data ?? {})) {
415
+ const f = meta.byName.get(k);
416
+ if (!f) continue;
417
+ const cv = coerceValue(f, v);
418
+ if (cv === undefined) continue;
419
+ out[k] = cv;
420
+ }
421
+ return out;
422
+ }
423
+
424
+ /** 必填字段占位值(仅用于图谱里"快速新增") */
425
+ function placeholderOf(f: FieldMeta): unknown {
426
+ const n = f.name.toLowerCase();
427
+ const uniq = Date.now().toString(36).toUpperCase();
428
+ if (f.kind === 'number') return 0;
429
+ if (f.kind === 'boolean') return false;
430
+ if (f.kind === 'datetime') return new Date();
431
+ if (f.kind === 'enum') return f.options?.[0]?.value ?? '1';
432
+ if (/code|no$|number|sn$/.test(n)) return `A${uniq}`;
433
+ if (n.includes('email')) return `demo${Date.now()}@example.com`;
434
+ if (n.includes('phone') || n.includes('mobile') || n.includes('tel')) return '13800000000';
435
+ if (n.includes('username') || n.includes('account') || n.includes('login')) return `demo_${uniq}`;
436
+ return `演示-${uniq}`;
437
+ }
438
+
439
+ /**
440
+ * 补必填字段:新增节点 / 迁移本体时用。
441
+ * 必填外键挂到目标表已存在的第一条记录上,否则一定撞外键约束。
442
+ */
443
+ export async function fillRequired(model: string, data: Record<string, unknown>) {
444
+ const meta = modelMeta(model);
445
+ for (const f of meta.fields) {
446
+ if (!f.required || data[f.name] !== undefined) continue;
447
+
448
+ if (f.refModel) {
449
+ const ref = (prisma as unknown as Record<string, { findFirst?: (a: unknown) => Promise<{ id: string } | null> }>)[
450
+ f.refModel
451
+ ];
452
+ const row = ref?.findFirst
453
+ ? await ref.findFirst({ where: hasSoftDelete(f.refModel) ? { deletedAt: null } : {} })
454
+ : null;
455
+ if (row) data[f.name] = row.id;
456
+ continue; // 找不到就留空,让 Prisma 明确报出缺哪个字段
457
+ }
458
+
459
+ const v = placeholderOf(f);
460
+ if (v !== undefined) data[f.name] = v;
461
+ }
462
+ return data;
463
+ }
464
+
465
+ /** 两个模型之间同名字段的交集(迁移本体时"能带过去的字段") */
466
+ export function sharedFields(fromModel: string, toModel: string): string[] {
467
+ const to = modelMeta(toModel).byName;
468
+ return modelMeta(fromModel).fields
469
+ .filter((f) => {
470
+ const t = to.get(f.name);
471
+ // 唯一列不继承:两个表各有 @unique 的 code,复制过去一定撞约束
472
+ return !!t && t.kind === f.kind && !t.unique && !f.unique;
473
+ })
474
+ .map((f) => f.name);
475
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('@/lib/api', () => ({
4
+ BizError: class BizError extends Error {},
5
+ CODE: { FORBIDDEN: 40300 },
6
+ }));
7
+
8
+ import { graphWhereOf } from './graph-scope';
9
+
10
+ const user = (patch: Record<string, unknown> = {}) =>
11
+ ({
12
+ id: 'u1',
13
+ username: 'zhangsan',
14
+ name: '张三',
15
+ isSuper: false,
16
+ permCodes: ['demo:employee:list'],
17
+ roleCodes: ['staff'],
18
+ deptId: 'd1',
19
+ deptIds: ['d1', 'd2'],
20
+ dataScope: 2,
21
+ ...patch,
22
+ }) as never;
23
+
24
+ describe('graphWhereOf', () => {
25
+ it('超管只做软删过滤,不加任何范围条件', () => {
26
+ expect(graphWhereOf('Employee', user({ isSuper: true, dataScope: 1 }))).toEqual({
27
+ deletedAt: null,
28
+ });
29
+ });
30
+
31
+ it('有 deptId 的表套 deptScope', () => {
32
+ expect(graphWhereOf('Employee', user())).toEqual({
33
+ deletedAt: null,
34
+ deptId: { in: ['d1', 'd2'] },
35
+ });
36
+ });
37
+
38
+ it('Department 自己按 id 收口,否则整棵组织树都看得见', () => {
39
+ expect(graphWhereOf('Department', user())).toEqual({ id: { in: ['d1', 'd2'] } });
40
+ });
41
+
42
+ it('没有 deptId 的字典表(OperationLog / Menu / Role)只做软删', () => {
43
+ expect(graphWhereOf('OperationLog', user())).toEqual({});
44
+ expect(graphWhereOf('Menu', user())).toEqual({});
45
+ });
46
+ });
@@ -0,0 +1,28 @@
1
+ import { DATA_SCOPE, deptScope, effectiveDataScope, type ScopeUser } from '@/lib/rbac';
2
+ import { fieldsOfModel, hasSoftDelete } from '@/lib/graph-schema';
3
+
4
+ /**
5
+ * 图谱相关接口共用的 where 构造:软删过滤 + 数据范围。
6
+ *
7
+ * ★ 为什么单独一个文件:图谱有四个入口都在读业务行
8
+ * (/api/graph 列表与展开、/api/graph/focus 取关联簇、
9
+ * /api/graph/node 的详情面板与删除影响预览),
10
+ * 漏一个就等于给行级权限开了后门。放在这里,四个入口共用一份实现。
11
+ *
12
+ * 规则:
13
+ * - 有 deptId 的表 → 套 deptScope(部门子树收口)
14
+ * - Department 自己 → 按 id 收口,否则整棵组织树连人带姓全都看得见
15
+ * - 其余字典 / 配置 / 关联表 → 不涉个人数据,只做软删过滤
16
+ */
17
+ export function graphWhereOf(model: string, user?: ScopeUser | null): Record<string, unknown> {
18
+ const base: Record<string, unknown> = hasSoftDelete(model) ? { deletedAt: null } : {};
19
+ if (!user) return base;
20
+ if (effectiveDataScope(user) === DATA_SCOPE.ALL) return base;
21
+ if (fieldsOfModel(model).some((f) => f.name === 'deptId')) {
22
+ return { ...base, ...deptScope(user) };
23
+ }
24
+ if (model === 'Department' && user.deptIds?.length) {
25
+ return { ...base, id: { in: user.deptIds } };
26
+ }
27
+ return base;
28
+ }