node-resource-utils 0.0.0-stage → 1.1.8

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,48 @@
1
+ import { ok, withRoute, audit, BizError, CODE } from '@/lib/api';
2
+ import { currentUser } from '@/auth';
3
+ import { moveNode, requireGraphWriter } from '@/lib/graph-node-write';
4
+
5
+ /**
6
+ * POST /api/graph/node/move —— 变更节点类型(把节点迁移到另一个本体)。
7
+ *
8
+ * ★ 为什么单独一个接口,而不是 PATCH 里加个 toEntity:
9
+ * 节点"类型"不是某个字段,而是**它存在哪张表**。
10
+ * 换类型 = 目标本体新建一条记录 + 清掉旧记录(含级联置空/连带删除),
11
+ * 这和普通改字段完全不是一个量级的操作,必须走单独确认流程。
12
+ *
13
+ * 请求:{ fromEntity, id, toEntity, data? }
14
+ * data 里只带用户在面板里改过的值;同名字段由服务端自动继承。
15
+ */
16
+ export const POST = withRoute(async (req) => {
17
+ const user = await currentUser();
18
+ if (!user) throw new BizError('请先登录', CODE.UNAUTHORIZED, 401);
19
+ // 变更类型 = 建新记录 + 删旧记录,是破坏性最大的图谱写操作,同样只给超管
20
+ requireGraphWriter(user);
21
+
22
+ const body = (await req.json().catch(() => ({}))) as {
23
+ fromEntity?: string;
24
+ toEntity?: string;
25
+ id?: string;
26
+ data?: Record<string, unknown>;
27
+ };
28
+ const fromEntity = String(body.fromEntity ?? '');
29
+ const toEntity = String(body.toEntity ?? '');
30
+ const id = String(body.id ?? '');
31
+ if (!fromEntity || !toEntity || !id) {
32
+ throw new BizError('缺少 fromEntity / toEntity / id', CODE.BAD_REQUEST);
33
+ }
34
+
35
+ const res = await moveNode({ fromEntity, id, toEntity, data: body.data });
36
+
37
+ void audit({
38
+ module: '知识图谱',
39
+ action: '变更节点类型',
40
+ method: 'POST',
41
+ path: req.url,
42
+ detail: `${fromEntity}(${id}) → ${toEntity}(${res.id}) 继承字段=${res.inherited.join(',') || '无'}`,
43
+ userId: user.id,
44
+ username: user.username,
45
+ });
46
+
47
+ return ok(res);
48
+ });
@@ -0,0 +1,274 @@
1
+ import { ok, withRoute, audit, BizError, CODE } from '@/lib/api';
2
+ import { currentUser, type SessionUser } from '@/auth';
3
+ import ONTOLOGY from '@/lib/ontology';
4
+ import { deleteWithCleanup, impactOf } from '@/lib/graph-cascade';
5
+ import { delegateFor, requireGraphWriter, translatePrismaError } from '@/lib/graph-node-write';
6
+ import { graphWhereOf } from '@/lib/graph-scope';
7
+ import {
8
+ coercePayload,
9
+ fieldsForEntity,
10
+ fillRequired,
11
+ optionsOf,
12
+ type FieldMeta,
13
+ } from '@/lib/graph-schema';
14
+
15
+ /**
16
+ * 知识图谱节点增删改 —— 让"在图谱里操作 = 真的改数据库"。
17
+ *
18
+ * 这是图谱从"看着好看"变成"真能用"的关键:
19
+ * 用户在右侧面板里改名 / 改状态 / 改附加信息 / 删除节点,落库后重新拉图就能看到变化。
20
+ *
21
+ * 所有写操作都在**本体白名单**内进行:
22
+ * 只有 ONTOLOGY.entities 里声明的模型才允许写,
23
+ * 不允许借这个接口去改 User / Role 这类敏感表(除非本体里显式声明)。
24
+ * 字段白名单则由 graph-schema 从 prisma schema 反推,password / token 一类的列永远不许写。
25
+ *
26
+ * GET /api/graph/node?entity&id 删除影响预览
27
+ * GET /api/graph/node?entity&id&detail=1 节点全字段 + 字段元数据(详情面板用)
28
+ * GET /api/graph/node?entity&meta=1 字段元数据(切换本体类型时预取)
29
+ * POST /api/graph/node { entity, data } 新增
30
+ * PATCH /api/graph/node { entity, id, data } 修改
31
+ * DELETE /api/graph/node { entity, id } 删除(软删优先)
32
+ */
33
+
34
+ /**
35
+ * 图谱直接写库只对超管开放(判断在 lib/graph-node-write.ts,三个写接口共用)。
36
+ *
37
+ * 业务数据的增删改必须走各自模块的接口 —— 那里才有业务权限码和数据范围校验。
38
+ * 否则任何登录用户都能绕过模块权限,用这个接口改/删别人部门的单据,
39
+ * 前面在列表和导出上做的行级过滤就白做了。
40
+ */
41
+ function entityOf(id: string) {
42
+ const e = ONTOLOGY.entities.find((x) => x.id === id);
43
+ if (!e) {
44
+ throw new BizError(
45
+ `本体 ${id} 未在 ontology.ts 中声明。可用:${ONTOLOGY.entities.map((x) => x.id).join(', ')}`,
46
+ CODE.BAD_REQUEST
47
+ );
48
+ }
49
+ return e;
50
+ }
51
+
52
+ /** 字段值 → 可 JSON 化的展示值(Date 统一 ISO 字符串,前端好接 dayjs) */
53
+ function displayValues(row: Record<string, unknown>, fields: FieldMeta[]) {
54
+ const out: Record<string, unknown> = {};
55
+ for (const f of fields) {
56
+ const v = row[f.name];
57
+ out[f.name] = v instanceof Date ? v.toISOString() : (v ?? null);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /**
63
+ * 详情面板要的数据:
64
+ * fields —— 有哪些字段、什么类型、什么选项(表单据此渲染)
65
+ * values —— 当前值
66
+ * refs —— 外键下拉选项
67
+ * entities—— 可选本体("类型"下拉)
68
+ */
69
+ async function detailPayload(entityId: string, id?: string) {
70
+ const ent = entityOf(entityId);
71
+ const fields = fieldsForEntity(ent.id);
72
+ let values: Record<string, unknown> = {};
73
+ let title = ent.label;
74
+
75
+ if (id) {
76
+ const row = await delegateFor(ent.model).findUnique({ where: { id } });
77
+ if (!row) throw new BizError('节点不存在或已被删除', CODE.NOT_FOUND, 404);
78
+ values = displayValues(row, fields);
79
+ title = String(row[ent.titleField] ?? id);
80
+ }
81
+
82
+ const refs: Record<string, { label: string; value: string | number }[]> = {};
83
+ await Promise.all(
84
+ fields
85
+ .filter((f) => f.kind === 'fk' && f.refModel)
86
+ .map(async (f) => {
87
+ refs[f.name] = await optionsOf(f.refModel!);
88
+ })
89
+ );
90
+
91
+ return {
92
+ entity: {
93
+ id: ent.id,
94
+ label: ent.label,
95
+ model: ent.model,
96
+ titleField: ent.titleField,
97
+ subtitleFields: ent.subtitleFields ?? [],
98
+ },
99
+ /** "类型"下拉的候选项(含不在当前视图里的本体,切过去会重新取字段) */
100
+ entities: ONTOLOGY.entities.map((e) => ({ id: e.id, label: e.label, model: e.model })),
101
+ fields,
102
+ values,
103
+ refs,
104
+ title,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * 详情面板 / 删除影响预览 的范围闸。
110
+ *
111
+ * 这两个入口都会返回**全字段**(薪资、电话这类敏感列都在里面),
112
+ * 且是按 id 直取的 —— 不过这一关,知道一个 id 就能把范围外的人看穿。
113
+ * 范围外、已软删的一律按「不存在」处理,不泄露"存在但你没权限"。
114
+ */
115
+ async function assertNodeInScope(entityId: string, id: string | undefined, user: SessionUser) {
116
+ if (!id) return;
117
+ const ent = entityOf(entityId);
118
+ const row = await delegateFor(ent.model).findFirst?.({
119
+ where: { ...graphWhereOf(ent.model, user), id },
120
+ });
121
+ if (!row) throw new BizError('节点不存在或已被删除', CODE.NOT_FOUND, 404);
122
+ }
123
+
124
+ export const GET = withRoute(async (req) => {
125
+ const user = await currentUser();
126
+ if (!user) throw new BizError('请先登录', CODE.UNAUTHORIZED, 401);
127
+
128
+ const url = new URL(req.url);
129
+ const entity = String(url.searchParams.get('entity') ?? '');
130
+ const id = String(url.searchParams.get('id') ?? '');
131
+ if (!entity) throw new BizError('缺少 entity', CODE.BAD_REQUEST);
132
+
133
+ // 详情面板:有 id 取全字段,没 id(切类型时)只取字段定义
134
+ const detail = url.searchParams.get('detail');
135
+ const meta = url.searchParams.get('meta');
136
+ if (detail || meta) {
137
+ if (detail && !id) throw new BizError('缺少 id', CODE.BAD_REQUEST);
138
+ // ★ 详情面板是"全字段"出口(含薪资这类敏感列),范围外的节点按不存在处理
139
+ await assertNodeInScope(entity, id, user);
140
+ return ok(await detailPayload(entity, detail ? id : undefined));
141
+ }
142
+
143
+ if (!id) throw new BizError('缺少 entity / id', CODE.BAD_REQUEST);
144
+
145
+ try {
146
+ await assertNodeInScope(entity, id, user);
147
+ const impact = await impactOf(entity, id);
148
+ return ok(impact);
149
+ } catch (e) {
150
+ // 范围闸抛的 404 要原样透出,别被这层 catch 改成 400(前端靠状态码区分"没有"和"参数错")
151
+ if (e instanceof BizError) throw e;
152
+ throw new BizError(e instanceof Error ? e.message : String(e), CODE.BAD_REQUEST);
153
+ }
154
+ });
155
+
156
+ export const POST = withRoute(async (req) => {
157
+ const user = await currentUser();
158
+ if (!user) throw new BizError('请先登录', CODE.UNAUTHORIZED, 401);
159
+ requireGraphWriter(user);
160
+
161
+ const body = (await req.json().catch(() => ({}))) as {
162
+ entity?: string;
163
+ data?: Record<string, unknown>;
164
+ };
165
+ const ent = entityOf(String(body.entity ?? ''));
166
+ // ★ 先清洗(只留真实字段 + 类型转换),再给必填字段补占位值
167
+ const payload = await fillRequired(ent.model, coercePayload(ent.model, body.data ?? {}));
168
+ if (!Object.keys(payload).length) {
169
+ throw new BizError('没有可写入的字段(检查字段名是否与 Prisma 模型一致)', CODE.BAD_REQUEST);
170
+ }
171
+
172
+ let row: Record<string, unknown>;
173
+ try {
174
+ row = await delegateFor(ent.model).create({ data: payload });
175
+ } catch (e) {
176
+ translatePrismaError(e);
177
+ }
178
+
179
+ void audit({
180
+ module: '知识图谱',
181
+ action: '新增节点',
182
+ method: 'POST',
183
+ path: req.url,
184
+ detail: `${ent.model} id=${String(row.id)}`,
185
+ userId: user.id,
186
+ username: user.username,
187
+ });
188
+ return ok({ id: row.id });
189
+ });
190
+
191
+ export const PATCH = withRoute(async (req) => {
192
+ const user = await currentUser();
193
+ if (!user) throw new BizError('请先登录', CODE.UNAUTHORIZED, 401);
194
+ requireGraphWriter(user);
195
+
196
+ const body = (await req.json().catch(() => ({}))) as {
197
+ entity?: string;
198
+ id?: string;
199
+ data?: Record<string, unknown>;
200
+ };
201
+ const ent = entityOf(String(body.entity ?? ''));
202
+ const id = String(body.id ?? '');
203
+ if (!id) throw new BizError('缺少 id', CODE.BAD_REQUEST);
204
+
205
+ const payload = coercePayload(ent.model, body.data ?? {});
206
+ if (!Object.keys(payload).length) {
207
+ throw new BizError(
208
+ '没有可更新的字段(可能提交的是只读字段,或字段名与该模型不一致)',
209
+ CODE.BAD_REQUEST
210
+ );
211
+ }
212
+
213
+ const d = delegateFor(ent.model);
214
+ const exists = await d.findUnique({ where: { id } });
215
+ if (!exists) throw new BizError('节点不存在或已被删除', CODE.NOT_FOUND, 404);
216
+
217
+ let row: Record<string, unknown>;
218
+ try {
219
+ row = await d.update({ where: { id }, data: payload });
220
+ } catch (e) {
221
+ translatePrismaError(e);
222
+ }
223
+
224
+ void audit({
225
+ module: '知识图谱',
226
+ action: '修改节点',
227
+ method: 'PATCH',
228
+ path: req.url,
229
+ detail: `${ent.model} id=${id} 字段=${Object.keys(payload).join(',')}`,
230
+ userId: user.id,
231
+ username: user.username,
232
+ });
233
+ return ok({ id: row.id, entity: ent.id, changed: Object.keys(payload) });
234
+ });
235
+
236
+ export const DELETE = withRoute(async (req) => {
237
+ const user = await currentUser();
238
+ if (!user) throw new BizError('请先登录', CODE.UNAUTHORIZED, 401);
239
+ requireGraphWriter(user);
240
+
241
+ const body = (await req.json().catch(() => ({}))) as { entity?: string; id?: string };
242
+ const ent = entityOf(String(body.entity ?? ''));
243
+ const id = String(body.id ?? '');
244
+ if (!id) throw new BizError('缺少 id', CODE.BAD_REQUEST);
245
+
246
+ const d = delegateFor(ent.model);
247
+ const row = await d.findUnique({ where: { id } });
248
+ if (!row) throw new BizError('节点不存在', CODE.NOT_FOUND, 404);
249
+
250
+ /**
251
+ * 删除 + 级联清理放在一个事务里(见 lib/graph-cascade.ts):
252
+ * - 可选外键 → 置空(解除关联,保留业务行)
253
+ * - 必填外键 → 连带删除依赖行(递归)
254
+ * - 有 deletedAt 的模型 → 只软删,不动引用
255
+ * 提交前还会复核所有反向引用是否归零,清理不彻底就整体回滚。
256
+ */
257
+ let res;
258
+ try {
259
+ res = await deleteWithCleanup(ent.id, id);
260
+ } catch (e) {
261
+ translatePrismaError(e);
262
+ }
263
+
264
+ void audit({
265
+ module: '知识图谱',
266
+ action: '删除节点',
267
+ method: 'DELETE',
268
+ path: req.url,
269
+ detail: `${ent.model} id=${id} 连带删除=${res.deleted} 置空=${res.nullified}${res.soft ? '(软删)' : ''}`,
270
+ userId: user.id,
271
+ username: user.username,
272
+ });
273
+ return ok({ id, ...res });
274
+ });
@@ -0,0 +1,272 @@
1
+ import { ok, withRoute, BizError, CODE } from '@/lib/api';
2
+ import { currentUser, type SessionUser } from '@/auth';
3
+ import { prisma } from '@/lib/prisma';
4
+ import { graphWhereOf } from '@/lib/graph-scope';
5
+ import ONTOLOGY from '@/lib/ontology';
6
+
7
+ /**
8
+ * 知识图谱数据接口(通用引擎)。
9
+ *
10
+ * 前端只要传 view id,就能拿到一张完整的图。
11
+ * 画什么由 src/lib/ontology.ts 的声明式配置决定,这个接口本身不认识任何业务。
12
+ *
13
+ * GET /api/graph?view=dispatch&center=<节点id>&depth=2
14
+ * view 视图 id,对应 ontology.ts 里的 views[].id
15
+ * center 可选,指定中心节点(只保留与它相距 depth 跳以内的节点)
16
+ * depth 默认 2
17
+ */
18
+
19
+ type Node = {
20
+ /** 图内唯一键:`${entity}:${dbId}`,ECharts 的边靠它连节点 */
21
+ id: string;
22
+ /** 数据库主键。增删改必须用它 —— 直接拿 id 去 findUnique 会 404(踩过) */
23
+ dbId: string;
24
+ label: string;
25
+ entity: string;
26
+ color: string;
27
+ borderColor?: string;
28
+ subtitle: string;
29
+ link?: string;
30
+ };
31
+ type Edge = { source: string; target: string; label: string; relation: string };
32
+
33
+ const MAX_NODES = 120; // 节点太多会糊成一团,也拖慢渲染
34
+
35
+ export const GET = withRoute(async (req) => {
36
+ const user = await currentUser();
37
+ if (!user) throw new BizError('请先登录', CODE.UNAUTHORIZED, 401);
38
+
39
+ const url = new URL(req.url);
40
+ const viewId = url.searchParams.get('view') || ONTOLOGY.views[0]?.id;
41
+ const center = url.searchParams.get('center');
42
+ /**
43
+ * depth 允许 0 —— 只要中心节点本身,不展开任何关系。
44
+ * 业务表「关联」跳到"单实体"时用 depth=0:一个字典项在图谱里就一个节点,
45
+ * 展开它的反向关系只会得到几十条成绩记录,把画布铺成一团扇形,反而看不清。
46
+ */
47
+ const depthRaw = url.searchParams.get('depth');
48
+ const depth = depthRaw === null ? 2 : Math.min(Math.max(Number(depthRaw) || 0, 0), 4);
49
+
50
+ const view = ONTOLOGY.views.find((v) => v.id === viewId);
51
+ if (!view) {
52
+ throw new BizError(
53
+ ONTOLOGY.views.length
54
+ ? `图谱视图 ${viewId} 不存在。可用视图:${ONTOLOGY.views.map((v) => v.id).join(', ')}`
55
+ : 'ontology.ts 没有配置 views —— 知识图谱无法渲染。请在 ontology.ts 里加一个视图(entities / relations / root)',
56
+ CODE.BAD_REQUEST
57
+ );
58
+ }
59
+
60
+ const entMap = new Map(ONTOLOGY.entities.map((e) => [e.id, e]));
61
+ const relMap = new Map(ONTOLOGY.relations.map((r) => [r.id, r]));
62
+
63
+ const nodes: Node[] = [];
64
+ const nodeKeys = new Set<string>();
65
+ const edges: Edge[] = [];
66
+ const edgeKeys = new Set<string>();
67
+
68
+ const pushNode = (entity: string, id: string, label: string, subtitle: string) => {
69
+ const key = `${entity}:${id}`;
70
+ if (nodeKeys.has(key)) return;
71
+ const def = entMap.get(entity)!;
72
+ nodeKeys.add(key);
73
+ nodes.push({
74
+ id: key,
75
+ dbId: id,
76
+ label,
77
+ entity,
78
+ color: def.color,
79
+ borderColor: def.borderColor,
80
+ subtitle,
81
+ link: def.link?.replace(':id', id),
82
+ });
83
+ };
84
+
85
+ /** 按 depth 做 BFS 展开,避免一上来把整库拉进来 */
86
+ let frontier: { entity: string; id: string }[] = [];
87
+
88
+ // ---- 根节点:center 指定了就用它,否则取视图里每个本体的前若干条 ----
89
+ if (center) {
90
+ const [entity, id] = center.split(':');
91
+ if (!entMap.has(entity)) throw new BizError(`未知本体 ${entity}`, CODE.BAD_REQUEST);
92
+ const def = entMap.get(entity)!;
93
+ const row = await rowOf(def.model, id, user);
94
+ if (!row) throw new BizError('中心节点不存在', CODE.BAD_REQUEST);
95
+ pushNode(entity, id, String(row[def.titleField] ?? id), subOf(def, row));
96
+ frontier = [{ entity, id }];
97
+ } else {
98
+ for (const eid of view.entities) {
99
+ const def = entMap.get(eid);
100
+ if (!def) continue;
101
+ const rows = await rowsOf(def.model, 12, user);
102
+ for (const r of rows) {
103
+ pushNode(eid, String(r.id), String(r[def.titleField] ?? r.id), subOf(def, r));
104
+ frontier.push({ entity: eid, id: String(r.id) });
105
+ }
106
+ if (nodes.length >= MAX_NODES) break;
107
+ }
108
+ }
109
+
110
+ // ---- 逐跳展开关系 ----
111
+ for (let hop = 0; hop < depth && frontier.length; hop++) {
112
+ const next: { entity: string; id: string }[] = [];
113
+ for (const cur of frontier) {
114
+ for (const rid of view.relations) {
115
+ const rel = relMap.get(rid);
116
+ if (!rel || (rel.from !== cur.entity && rel.to !== cur.entity)) continue;
117
+ // 从 cur 出发能拿到哪些邻居
118
+ const neighbors = await neighborsOf(cur, rel, user);
119
+ for (const n of neighbors) {
120
+ pushNode(n.entity, n.id, n.label, n.subtitle);
121
+ const a = `${cur.entity}:${cur.id}`;
122
+ const b = `${n.entity}:${n.id}`;
123
+ if (a === b) continue;
124
+ // ⚠️ 同一条关系会被 BFS 从两侧各展开一次(cur 是 from 时一次、是 to 时一次),
125
+ // 以前用带方向的 key 去重,结果一条关系出现两条方向相反的边,
126
+ // 页面按边数统计"下属"直接翻倍(部门 (16) 实际是 8)。
127
+ // 现在:方向固定为 ontology 声明的 from → to,去重键按无向排序。
128
+ const src = n.entity === rel.from ? b : a;
129
+ const dst = n.entity === rel.from ? a : b;
130
+ const [lo, hi] = src < dst ? [src, dst] : [dst, src];
131
+ const ek = `${rel.id}|${lo}|${hi}`;
132
+ if (!edgeKeys.has(ek)) {
133
+ edgeKeys.add(ek);
134
+ edges.push({ source: src, target: dst, label: rel.label, relation: rel.id });
135
+ }
136
+ next.push(n);
137
+ }
138
+ }
139
+ if (nodes.length >= MAX_NODES) break;
140
+ }
141
+ frontier = next;
142
+ if (nodes.length >= MAX_NODES) break;
143
+ }
144
+
145
+ return ok({
146
+ view: { id: view.id, label: view.label },
147
+ // 视图列表随数据返回,页面据此渲染下拉,不在前端硬编码——避免两边漂移
148
+ views: ONTOLOGY.views.map((v) => ({ label: v.label, value: v.id })),
149
+ nodes: nodes.slice(0, MAX_NODES),
150
+ edges,
151
+ truncated: nodes.length >= MAX_NODES,
152
+ entities: ONTOLOGY.entities
153
+ .filter((e) => view.entities.includes(e.id))
154
+ .map((e) => ({
155
+ id: e.id,
156
+ label: e.label,
157
+ color: e.color,
158
+ layer: e.layer ?? 0,
159
+ // ★ 标题字段随数据下发:前端不再维护 entity → 字段名的硬编码映射,
160
+ // 换业务本体时只改 ontology.ts 就够了(曾经页面里有一份 TITLE_FIELD,
161
+ // 换成请销假本体后编辑节点会发空 payload,报"没有可更新的字段")。
162
+ titleField: e.titleField,
163
+ })),
164
+ });
165
+ });
166
+
167
+ /* ------------------------------ 内部工具 ------------------------------ */
168
+
169
+ function subOf(def: (typeof ONTOLOGY.entities)[number], row: Record<string, unknown>): string {
170
+ return (def.subtitleFields ?? [])
171
+ .map((f) => String(row[f] ?? ''))
172
+ .filter(Boolean)
173
+ .slice(0, 2)
174
+ .join(' · ');
175
+ }
176
+
177
+ function delegateFor(model: string) {
178
+ const d = (prisma as unknown as Record<string, unknown>)[model];
179
+ if (!d || typeof (d as { findMany?: unknown }).findMany !== 'function') {
180
+ throw new BizError(`ontology.ts 里的模型 ${model} 不存在,请检查 Prisma 模型名`, CODE.BAD_REQUEST);
181
+ }
182
+ return d as {
183
+ findMany: (a: unknown) => Promise<Record<string, unknown>[]>;
184
+ findUnique: (a: unknown) => Promise<Record<string, unknown> | null>;
185
+ findFirst: (a: unknown) => Promise<Record<string, unknown> | null>;
186
+ };
187
+ }
188
+
189
+ async function rowsOf(model: string, take: number, user: SessionUser) {
190
+ return delegateFor(model).findMany({
191
+ where: graphWhereOf(model, user),
192
+ take,
193
+ orderBy: { id: 'asc' },
194
+ });
195
+ }
196
+
197
+ async function rowOf(model: string, id: string, user: SessionUser) {
198
+ // findFirst 而不是 findUnique:where 里要带范围;范围外 / 已软删的节点就是「不存在」
199
+ return delegateFor(model).findFirst({
200
+ where: { ...graphWhereOf(model, user), id },
201
+ });
202
+ }
203
+
204
+ /** 找出与 cur 相邻的所有节点(含关系方向处理) */
205
+ async function neighborsOf(
206
+ cur: { entity: string; id: string },
207
+ rel: (typeof ONTOLOGY.relations)[number],
208
+ user: SessionUser
209
+ ): Promise<{ entity: string; id: string; label: string; subtitle: string }[]> {
210
+ const out: { entity: string; id: string; label: string; subtitle: string }[] = [];
211
+
212
+ if ('field' in rel.via) {
213
+ // 外键关系:查起点的行,拿外键值,再去终点表取那一行
214
+ const fromDef = ONTOLOGY.entities.find((e) => e.id === rel.from)!;
215
+ const toDef = ONTOLOGY.entities.find((e) => e.id === rel.to)!;
216
+
217
+ if (cur.entity === rel.from) {
218
+ const row = await rowOf(fromDef.model, cur.id, user);
219
+ const fk = row?.[rel.via.field];
220
+ if (fk) {
221
+ const target = await rowOf(toDef.model, String(fk), user);
222
+ if (target) {
223
+ out.push({
224
+ entity: rel.to,
225
+ id: String(target.id),
226
+ label: String(target[toDef.titleField] ?? target.id),
227
+ subtitle: subOf(toDef, target),
228
+ });
229
+ }
230
+ }
231
+ } else if (cur.entity === rel.to) {
232
+ // 反向:谁的外键指向我
233
+ const rows = await delegateFor(fromDef.model).findMany({
234
+ where: { [rel.via.field]: cur.id, ...graphWhereOf(fromDef.model, user) },
235
+ take: 30,
236
+ });
237
+ for (const r of rows) {
238
+ out.push({
239
+ entity: rel.from,
240
+ id: String(r.id),
241
+ label: String(r[fromDef.titleField] ?? r.id),
242
+ subtitle: subOf(fromDef, r),
243
+ });
244
+ }
245
+ }
246
+ } else {
247
+ // 中间表:model.fromField -> cur.id,取 toField
248
+ const toDef = ONTOLOGY.entities.find((e) => e.id === rel.to)!;
249
+ const fromDef = ONTOLOGY.entities.find((e) => e.id === rel.from)!;
250
+ const key = cur.entity === rel.from ? rel.via.fromField : rel.via.toField;
251
+ const otherEntity = cur.entity === rel.from ? rel.to : rel.from;
252
+ const otherDef = cur.entity === rel.from ? toDef : fromDef;
253
+ const otherField = cur.entity === rel.from ? rel.via.toField : rel.via.fromField;
254
+
255
+ const mids = await delegateFor(rel.via.model).findMany({
256
+ where: { [key]: cur.id, ...graphWhereOf(rel.via.model, user) },
257
+ take: 50,
258
+ });
259
+ for (const m of mids) {
260
+ const other = await rowOf(otherDef.model, String(m[otherField]), user);
261
+ if (other) {
262
+ out.push({
263
+ entity: otherEntity,
264
+ id: String(other.id),
265
+ label: String(other[otherDef.titleField] ?? other.id),
266
+ subtitle: subOf(otherDef, other),
267
+ });
268
+ }
269
+ }
270
+ }
271
+ return out;
272
+ }
@@ -0,0 +1,11 @@
1
+ import { NextResponse } from 'next/server';
2
+
3
+ export const dynamic = 'force-dynamic';
4
+
5
+ export function GET() {
6
+ return NextResponse.json({
7
+ ok: true,
8
+ runtime: process.version,
9
+ time: new Date().toISOString(),
10
+ });
11
+ }
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod';
2
+ import { ok, withRoute } from '@/lib/api';
3
+ import { requireUser } from '@/auth';
4
+ import { markAllNotificationsRead, markNotificationRead } from '@/lib/notifications';
5
+
6
+ export const dynamic = 'force-dynamic';
7
+
8
+ const schema = z
9
+ .object({
10
+ /** 单条消息 id */
11
+ id: z.string().trim().min(1).optional(),
12
+ /** 全部标为已读 */
13
+ all: z.boolean().optional(),
14
+ })
15
+ .refine((body) => body.all === true || Boolean(body.id), {
16
+ message: '缺少 id 或 all',
17
+ });
18
+
19
+ /**
20
+ * POST /api/inbox/read { id } | { all: true }
21
+ *
22
+ * 只能操作当前登录用户自己的消息:where 里同时带 userId 和 id,
23
+ * 传别人的消息 id 不会生效,也不会泄露该消息是否存在。
24
+ */
25
+ export const POST = withRoute(async (req) => {
26
+ const user = await requireUser();
27
+ const body = schema.parse(await req.json().catch(() => ({})));
28
+
29
+ if (body.all) {
30
+ await markAllNotificationsRead(user.id);
31
+ return ok({ unread: 0, all: true });
32
+ }
33
+
34
+ const unread = await markNotificationRead(user.id, body.id as string);
35
+ return ok({ id: body.id, unread });
36
+ });
@@ -0,0 +1,15 @@
1
+ import { ok, withRoute } from '@/lib/api';
2
+ import { requireUser } from '@/auth';
3
+ import { getInboxSummary } from '@/lib/inbox';
4
+
5
+ export const dynamic = 'force-dynamic';
6
+
7
+ /**
8
+ * GET /api/inbox/summary
9
+ *
10
+ * 顶栏铃铛的轻量摘要。只返回三个数字,不拉待办和消息明细。
11
+ */
12
+ export const GET = withRoute(async () => {
13
+ const user = await requireUser();
14
+ return ok(await getInboxSummary(user));
15
+ });