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,1224 @@
1
+ 'use client';
2
+
3
+ import { Suspense, useCallback, useEffect, useRef, useState } from 'react';
4
+ import { useRouter, useSearchParams } from 'next/navigation';
5
+ import {
6
+ Alert, App, Button, Card, Col, Collapse, DatePicker, Divider, Empty, Form,
7
+ Input, InputNumber, Row, Select, Space, Spin, Switch, Tag, Typography,
8
+ } from 'antd';
9
+ import {
10
+ ApartmentOutlined, DeleteOutlined, DisconnectOutlined, PlusOutlined, ReloadOutlined,
11
+ SaveOutlined, UndoOutlined,
12
+ } from '@ant-design/icons';
13
+ import dayjs, { type Dayjs } from 'dayjs';
14
+ import OntologyGraph from '@/components/OntologyGraph';
15
+ import { fs, MILITARY } from '@/lib/theme';
16
+
17
+ /**
18
+ * 知识图谱页 —— 带编辑能力(图谱操作直接落库)。
19
+ *
20
+ * ★ 差异化亮点:不只"画出来",还能"改进去"。
21
+ *
22
+ * 三个设计要点(都是踩过的坑):
23
+ * 1. 分层布局而不是力导:力导会把节点撒成一盘沙,看不出结构。
24
+ * 2. 浅色底 + 深色字:深色底会让节点文字完全读不出来。
25
+ * 3. 边标签只在悬停/选中时显示:几十条边都挂标签会糊成一片。
26
+ *
27
+ * ★ 视图与字段都不在这里写死(曾经写死过,踩过坑):
28
+ * 以前这里有一行 const VIEW = 'org',页面永远请求 view=org。
29
+ * 换成请销假本体(views[0].id = 'leave')后,页面立刻报
30
+ * 「图谱视图 org 不存在。可用视图:leave」,整页只剩红色错误横幅。
31
+ * 现在改为:首次请求不传 view,由服务端取 ontology.views[0];
32
+ * 返回把 views 渲染成下拉;节点标题字段也用服务端下发的 titleField。
33
+ *
34
+ * ★ 详情面板 = schema 驱动的表单(2026-10 改造):
35
+ * 以前只有「名称」一个输入框,类型 / 附加 / 状态 / 职位 / 备注 全是只读文本。
36
+ * 现在字段列表由 /api/graph/node?detail=1 下发(服务端从 schema.prisma 反推):
37
+ * text → Input,长文本 → TextArea,数字 → InputNumber,
38
+ * 布尔 → Switch,日期 → DatePicker,枚举 → Select,外键 → 可搜索 Select。
39
+ * 换句话说:**图上/提示里给人看的字段,都能在这里改**,保存即写库。
40
+ * 保存成功后重新拉图,并让刚改的节点在图里闪几秒(见 OntologyGraph 的 flash)。
41
+ */
42
+
43
+ interface GNode {
44
+ /** 图内唯一键 `entity:dbId`(边靠它连节点) */
45
+ id: string;
46
+ /** 数据库主键,写操作用它;老接口可能没返回,兜底从 id 里截 */
47
+ dbId?: string;
48
+ label: string;
49
+ entity: string;
50
+ color: string;
51
+ borderColor?: string;
52
+ subtitle: string;
53
+ link?: string;
54
+ }
55
+ interface GEdge {
56
+ source: string;
57
+ target: string;
58
+ label: string;
59
+ relation: string;
60
+ }
61
+ interface DeleteImpact {
62
+ entity: string;
63
+ model: string;
64
+ title: string;
65
+ soft: boolean;
66
+ actions: { kind: 'delete' | 'nullify'; model: string; label: string; field: string; count: number }[];
67
+ deleteCount: number;
68
+ nullifyCount: number;
69
+ }
70
+
71
+ type FieldKind = 'text' | 'longtext' | 'number' | 'boolean' | 'datetime' | 'enum' | 'fk';
72
+
73
+ interface FieldOption {
74
+ label: string;
75
+ value: string | number;
76
+ }
77
+
78
+ interface FieldMeta {
79
+ name: string;
80
+ label: string;
81
+ kind: FieldKind;
82
+ prismaType: string;
83
+ required: boolean;
84
+ nullable: boolean;
85
+ unique: boolean;
86
+ primary?: boolean;
87
+ options?: FieldOption[];
88
+ refModel?: string;
89
+ hint?: string;
90
+ }
91
+
92
+ interface NodeDetail {
93
+ entity: { id: string; label: string; model: string; titleField: string; subtitleFields: string[] };
94
+ /** 「类型」下拉的候选(ontology 里的全部本体) */
95
+ entities: { id: string; label: string; model: string }[];
96
+ fields: FieldMeta[];
97
+ values: Record<string, unknown>;
98
+ refs: Record<string, FieldOption[]>;
99
+ title: string;
100
+ }
101
+
102
+ /** 二次确认里展示"会影响什么" */
103
+ function ImpactList({ impact }: { impact: DeleteImpact }) {
104
+ if (impact.soft) {
105
+ return (
106
+ <div style={{ fontSize: fs(13), color: MILITARY.textSecondary }}>
107
+ 该模型采用软删除:记录保留用于审计,图谱中不再显示,不会改动其他数据。
108
+ </div>
109
+ );
110
+ }
111
+ if (!impact.actions.length) {
112
+ return (
113
+ <div style={{ fontSize: fs(13), color: MILITARY.textSecondary }}>
114
+ 没有其他数据引用它,可以直接删除。
115
+ </div>
116
+ );
117
+ }
118
+ return (
119
+ <div style={{ fontSize: fs(13) }}>
120
+ <div style={{ color: MILITARY.error, marginBottom: 8 }}>以下数据会被一并处理,且不可撤销:</div>
121
+ <ul style={{ margin: 0, paddingLeft: 18, lineHeight: 1.9 }}>
122
+ {impact.actions.map((a) => (
123
+ <li key={`${a.kind}-${a.model}-${a.field}`}>
124
+ {a.kind === 'delete' ? (
125
+ <>
126
+ 删除 <b>{a.label}</b> {a.count} 条(必填外键,无法保留)
127
+ </>
128
+ ) : (
129
+ <>
130
+ 把 <b>{a.label}.{a.field}</b> 置空 {a.count} 条(解除关联,数据保留)
131
+ </>
132
+ )}
133
+ </li>
134
+ ))}
135
+ </ul>
136
+ </div>
137
+ );
138
+ }
139
+
140
+ interface GraphData {
141
+ view: { id: string; label: string };
142
+ /** 服务端下发的可选视图(ontology.views),前端不硬编码 */
143
+ views: { label: string; value: string }[];
144
+ nodes: GNode[];
145
+ edges: GEdge[];
146
+ truncated: boolean;
147
+ entities: { id: string; label: string; color: string; layer?: number; titleField: string }[];
148
+ }
149
+
150
+ /**
151
+ * 业务记录 → 图谱关联(/api/graph/focus 的返回)。
152
+ *
153
+ * /graph?src=FitnessRecord:clx123 就是靠它定位的:
154
+ * mode=single → 只有一个节点,直接打开右侧详情面板;
155
+ * mode=multi → 一簇节点,跳转后高亮簇内连线。
156
+ */
157
+ interface FocusInfo {
158
+ source: { model: string; dbId: string };
159
+ entity: { id: string; label: string };
160
+ title: string;
161
+ view: { id: string; label: string };
162
+ mode: 'single' | 'multi';
163
+ center: string;
164
+ nodes: {
165
+ id: string;
166
+ dbId: string;
167
+ entity: string;
168
+ entityLabel: string;
169
+ label: string;
170
+ subtitle: string;
171
+ role: string;
172
+ }[];
173
+ edges: { source: string; target: string; label: string; relation: string }[];
174
+ summary: string;
175
+ backLink: string | null;
176
+ url: string;
177
+ }
178
+
179
+ /* ------------------------- 表单值 ↔ 接口值 的转换 ------------------------- */
180
+
181
+ /** 表单里的值(日期是 dayjs)→ 可以放进 JSON 的值 */
182
+ function serialize(field: FieldMeta, v: unknown): unknown {
183
+ if (v === undefined || v === null || v === '') {
184
+ return field.kind === 'text' || field.kind === 'longtext' ? (v ?? null) : null;
185
+ }
186
+ switch (field.kind) {
187
+ case 'datetime': {
188
+ const d = dayjs.isDayjs(v) ? (v as Dayjs) : dayjs(String(v));
189
+ return d.isValid() ? d.toISOString() : null;
190
+ }
191
+ case 'number':
192
+ return typeof v === 'number' ? v : Number(v);
193
+ case 'boolean':
194
+ return Boolean(v);
195
+ default:
196
+ return v;
197
+ }
198
+ }
199
+
200
+ function sameValue(a: unknown, b: unknown) {
201
+ const na = a === undefined ? null : a;
202
+ const nb = b === undefined ? null : b;
203
+ if (na === null || nb === null) return na === nb;
204
+ return String(na) === String(nb);
205
+ }
206
+
207
+ /** 详情 → 表单初值 */
208
+ function toFormValues(detail: NodeDetail): Record<string, unknown> {
209
+ const out: Record<string, unknown> = { __entity: detail.entity.id };
210
+ for (const f of detail.fields) {
211
+ const v = detail.values[f.name];
212
+ out[f.name] =
213
+ f.kind === 'datetime'
214
+ ? v
215
+ ? dayjs(String(v))
216
+ : null
217
+ : f.kind === 'boolean'
218
+ ? Boolean(v)
219
+ : (v ?? undefined);
220
+ }
221
+ return out;
222
+ }
223
+
224
+ /** 只取"有值"的字段:迁移本体时不要用空值覆盖目标表的继承值 */
225
+ function nonEmptyPayload(detail: NodeDetail, raw: Record<string, unknown>) {
226
+ const out: Record<string, unknown> = {};
227
+ for (const f of detail.fields) {
228
+ const v = raw[f.name];
229
+ if (v === undefined || v === null) continue;
230
+ if (typeof v === 'string' && v.trim() === '') continue;
231
+ out[f.name] = serialize(f, v);
232
+ }
233
+ return out;
234
+ }
235
+
236
+ /** 和加载时的值比对,只提交真正改过的字段(审计日志才不会一片噪声) */
237
+ function diffPayload(detail: NodeDetail, raw: Record<string, unknown>) {
238
+ const out: Record<string, unknown> = {};
239
+ for (const f of detail.fields) {
240
+ const now = serialize(f, raw[f.name]);
241
+ const before = serialize(f, detail.values[f.name]);
242
+ if (!sameValue(now, before)) out[f.name] = now;
243
+ }
244
+ return out;
245
+ }
246
+
247
+ function GraphPageInner() {
248
+ const { message, modal } = App.useApp();
249
+ const router = useRouter();
250
+ const searchParams = useSearchParams();
251
+ /** /graph?src=<模型名>:<记录id> —— 业务列表「关联」按钮带过来的定位参数 */
252
+ const srcParam = searchParams.get('src') ?? '';
253
+ const [data, setData] = useState<GraphData | null>(null);
254
+ const [loading, setLoading] = useState(false);
255
+ const [err, setErr] = useState<string | null>(null);
256
+ const [viewId, setViewId] = useState<string | null>(null);
257
+ const [active, setActive] = useState<GNode | null>(null);
258
+ const [saving, setSaving] = useState(false);
259
+ const [form] = Form.useForm();
260
+ const [adding, setAdding] = useState(false);
261
+ /** 新增节点后定位到它(服务端 center 参数,格式 entity:dbId) */
262
+ const [centered, setCentered] = useState<string | null>(null);
263
+ /** 已折叠的部门节点:点击节点是"选中",折叠由右侧面板的按钮控制 */
264
+ const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
265
+
266
+ /** 关联聚焦:从业务列表跳进来时高亮的那一簇实体 */
267
+ const [focusInfo, setFocusInfo] = useState<FocusInfo | null>(null);
268
+ const [focusLoading, setFocusLoading] = useState(false);
269
+ const [focusErr, setFocusErr] = useState<string | null>(null);
270
+ const appliedSrc = useRef('');
271
+
272
+ /** 节点详情(字段元数据 + 当前值) */
273
+ const [detail, setDetail] = useState<NodeDetail | null>(null);
274
+ const [detailLoading, setDetailLoading] = useState(false);
275
+ const [detailErr, setDetailErr] = useState<string | null>(null);
276
+ /** 保存后自增,强制重新拉一次详情(节点还在,只是值变了) */
277
+ const [detailNonce, setDetailNonce] = useState(0);
278
+ /** 保存成功 → 图上闪几秒 */
279
+ const [flash, setFlash] = useState<{ id: string; nonce: number } | null>(null);
280
+ const [savedAt, setSavedAt] = useState<string | null>(null);
281
+ /** 「更多字段」是否展开(受控:换节点时收起,切类型时自动展开) */
282
+ const [moreOpen, setMoreOpen] = useState(false);
283
+
284
+ const load = useCallback(async (view?: string, center?: string, depth = 2) => {
285
+ setLoading(true);
286
+ setErr(null);
287
+ try {
288
+ // 不传 view 时服务端自动用 ontology.views[0] —— 换业务本体后页面仍然能开
289
+ const qs = new URLSearchParams({ depth: String(depth) });
290
+ if (view) qs.set('view', view);
291
+ if (center) qs.set('center', center);
292
+ const r = await fetch(`/api/graph?${qs.toString()}`);
293
+ const j = (await r.json()) as { code: number; data?: GraphData; message?: string };
294
+ // 注意:脚手架 ok() 的成功码是 0,不是 200
295
+ if (j.code === 0 && j.data) {
296
+ setData(j.data);
297
+ setViewId(j.data.view.id);
298
+ setCentered(center ?? null);
299
+ return j.data;
300
+ }
301
+ setErr(j.message || '加载失败');
302
+ return null;
303
+ } catch (e) {
304
+ setErr(String(e));
305
+ return null;
306
+ } finally {
307
+ setLoading(false);
308
+ }
309
+ }, []);
310
+
311
+ useEffect(() => {
312
+ // 带 src 参数时交给下面的「关联聚焦」effect 拉图 —— 否则会先画一遍全图再跳到局部
313
+ if (srcParam) return;
314
+ void load();
315
+ }, [load, srcParam]);
316
+
317
+ /**
318
+ * 关联聚焦:把 `模型名:记录id` 交给服务端推导成图谱里的一簇实体。
319
+ *
320
+ * 位置逻辑全在服务端(src/lib/graph-focus.ts):
321
+ * 单一实体 → 只给 center,页面接着打开节点详情;
322
+ * 多个实体 → 给 center + 节点/边集合,图里高亮这一簇、压暗其余。
323
+ */
324
+ useEffect(() => {
325
+ if (!srcParam) {
326
+ if (appliedSrc.current) {
327
+ appliedSrc.current = '';
328
+ setFocusInfo(null);
329
+ setFocusErr(null);
330
+ }
331
+ return;
332
+ }
333
+ if (appliedSrc.current === srcParam) return;
334
+ appliedSrc.current = srcParam;
335
+
336
+ let dead = false;
337
+ setFocusLoading(true);
338
+ setFocusErr(null);
339
+ void (async () => {
340
+ try {
341
+ const r = await fetch(`/api/graph/focus?src=${encodeURIComponent(srcParam)}`);
342
+ const j = (await r.json()) as { code: number; message?: string; data?: FocusInfo };
343
+ if (dead) return;
344
+ if (j.code !== 0 || !j.data) {
345
+ setFocusErr(j.message || '关联解析失败');
346
+ return;
347
+ }
348
+ const info = j.data;
349
+ setFocusInfo(info);
350
+ // 单实体:只画这一个节点(展开反向关系会得到几十条记录,扇成一团反而看不清);
351
+ // 多实体:center + depth=2 带上上下文,再由 focus 高亮这一簇。
352
+ const fresh = await load(info.view.id, info.center, info.mode === 'single' ? 0 : 2);
353
+ if (dead) return;
354
+ if (info.mode === 'single') {
355
+ const n = fresh?.nodes.find((x) => x.id === info.center);
356
+ if (n) setActive(n);
357
+ } else {
358
+ setActive(null);
359
+ }
360
+ } catch (e) {
361
+ if (!dead) setFocusErr(String(e));
362
+ } finally {
363
+ if (!dead) setFocusLoading(false);
364
+ }
365
+ })();
366
+ return () => {
367
+ dead = true;
368
+ };
369
+ }, [srcParam, load]);
370
+
371
+ /** 退出聚焦:回到整张视图 */
372
+ const clearFocus = useCallback(() => {
373
+ appliedSrc.current = '';
374
+ setFocusInfo(null);
375
+ setFocusErr(null);
376
+ setActive(null);
377
+ router.replace('/graph');
378
+ void load(viewId ?? undefined);
379
+ }, [router, load, viewId]);
380
+
381
+ /* --------------------------- 详情加载 --------------------------- */
382
+
383
+ const fetchDetail = useCallback(
384
+ async (entity: string, id?: string): Promise<NodeDetail | null> => {
385
+ const qs = new URLSearchParams({ entity });
386
+ if (id) {
387
+ qs.set('id', id);
388
+ qs.set('detail', '1');
389
+ } else {
390
+ qs.set('meta', '1');
391
+ }
392
+ const r = await fetch(`/api/graph/node?${qs.toString()}`);
393
+ const j = (await r.json()) as { code: number; data?: NodeDetail; message?: string };
394
+ if (j.code === 0 && j.data) return j.data;
395
+ throw new Error(j.message || '加载节点详情失败');
396
+ },
397
+ []
398
+ );
399
+
400
+ const activeKey = active?.id ?? null;
401
+
402
+ useEffect(() => {
403
+ if (!active) {
404
+ setDetail(null);
405
+ setDetailErr(null);
406
+ return;
407
+ }
408
+ let dead = false;
409
+ setDetailLoading(true);
410
+ setDetailErr(null);
411
+ const nid = active.dbId ?? (active.id.includes(':') ? active.id.slice(active.id.indexOf(':') + 1) : active.id);
412
+ fetchDetail(active.entity, nid)
413
+ .then((d) => {
414
+ if (!dead) setDetail(d);
415
+ })
416
+ .catch((e: unknown) => {
417
+ if (!dead) {
418
+ setDetail(null);
419
+ setDetailErr(e instanceof Error ? e.message : String(e));
420
+ }
421
+ })
422
+ .finally(() => {
423
+ if (!dead) setDetailLoading(false);
424
+ });
425
+ return () => {
426
+ dead = true;
427
+ };
428
+ // activeKey 变化(换节点)或 detailNonce 变化(保存后刷新)都要重拉
429
+ // eslint-disable-next-line react-hooks/exhaustive-deps
430
+ }, [activeKey, detailNonce, fetchDetail]);
431
+
432
+ /** 详情变化 → 重建表单(setFieldsValue 要和 Form.Item 的挂载同步,交给 effect 最稳) */
433
+ useEffect(() => {
434
+ // ⚠️ detail 为空时**不要**碰 form:那时面板没渲染,Form 实例处于"未连接"状态,
435
+ // resetFields 会在控制台刷 "Instance created by useForm is not connected to any Form element"。
436
+ if (!detail) return;
437
+ form.resetFields();
438
+ form.setFieldsValue(toFormValues(detail));
439
+ }, [detail, form]);
440
+
441
+ /** 本体 → 标题字段由服务端下发(曾经在这里维护 TITLE_FIELD 映射,换本体就漏) */
442
+ const titleFieldOf = (entity: string) =>
443
+ data?.entities.find((e) => e.id === entity)?.titleField;
444
+
445
+ /**
446
+ * 数据库主键。
447
+ * ⚠️ 节点的 id 是 `entity:dbId`(图内唯一键,ECharts 的边靠它连),
448
+ * 直接拿它去调 PATCH/DELETE 会 findUnique 不到 → 全部 404
449
+ * ("保存失败 / 删除失败"就是这么来的)。写操作必须用 dbId。
450
+ */
451
+ const dbIdOf = (n: GNode) =>
452
+ n.dbId ?? (n.id.includes(':') ? n.id.slice(n.id.indexOf(':') + 1) : n.id);
453
+
454
+ /** 保存后统一收尾:重新拉图 → 关掉/更新选中 → 触发高亮 */
455
+ const refreshAfterSave = async (entityId: string, nodeId: string, center?: string) => {
456
+ const key = `${entityId}:${nodeId}`;
457
+ const fresh = await load(viewId ?? undefined, center ?? centered ?? undefined);
458
+ const node = fresh?.nodes.find((n) => n.id === key) ?? null;
459
+ if (node) setActive(node);
460
+ setFlash({ id: key, nonce: Date.now() });
461
+ setSavedAt(dayjs().format('HH:mm:ss'));
462
+ setDetailNonce((n) => n + 1);
463
+ };
464
+
465
+ /* --------------------------- 保存 --------------------------- */
466
+
467
+ const save = async () => {
468
+ if (!active || !detail) return;
469
+ const raw = form.getFieldsValue(true) as Record<string, unknown>;
470
+ const targetEntity = detail.entity.id;
471
+
472
+ // 类型变了 = 换表,这不是一次 update,必须先让用户看清代价
473
+ if (targetEntity !== active.entity) {
474
+ await confirmMove(raw);
475
+ return;
476
+ }
477
+
478
+ const payload = diffPayload(detail, raw);
479
+ if (!Object.keys(payload).length) {
480
+ message.info('没有检测到改动');
481
+ return;
482
+ }
483
+
484
+ setSaving(true);
485
+ try {
486
+ const r = await fetch('/api/graph/node', {
487
+ method: 'PATCH',
488
+ headers: { 'Content-Type': 'application/json' },
489
+ body: JSON.stringify({ entity: active.entity, id: dbIdOf(active), data: payload }),
490
+ });
491
+ const j = (await r.json()) as { code: number; message?: string; data?: { changed?: string[] } };
492
+ if (j.code === 0) {
493
+ const n = j.data?.changed?.length ?? 0;
494
+ message.success(`已保存到数据库(${n} 个字段)`);
495
+ await refreshAfterSave(active.entity, dbIdOf(active));
496
+ } else {
497
+ message.error(j.message || '保存失败');
498
+ }
499
+ } catch (e) {
500
+ message.error(`保存失败:${String(e)}`);
501
+ } finally {
502
+ setSaving(false);
503
+ }
504
+ };
505
+
506
+ /**
507
+ * 变更节点类型。
508
+ * 服务端会:目标本体建一条新记录(同名字段自动继承)→ 清掉旧记录。
509
+ * 清旧记录时可选外键会被置空、必填外键会连带删除依赖行,
510
+ * 所以这里复用"删除影响预览"接口,先把代价摆给用户看。
511
+ */
512
+ const confirmMove = async (raw: Record<string, unknown>) => {
513
+ if (!active || !detail) return;
514
+ const entity = active.entity;
515
+ const id = dbIdOf(active);
516
+ const target = detail.entity;
517
+ const payload = nonEmptyPayload(detail, raw);
518
+
519
+ let impact: DeleteImpact | null = null;
520
+ try {
521
+ const r = await fetch(
522
+ `/api/graph/node?entity=${encodeURIComponent(entity)}&id=${encodeURIComponent(id)}`
523
+ );
524
+ const j = (await r.json()) as { code: number; data?: DeleteImpact };
525
+ if (j.code === 0 && j.data) impact = j.data;
526
+ } catch {
527
+ // 拿不到影响就退化成纯文字确认,不阻塞流程
528
+ }
529
+
530
+ modal.confirm({
531
+ title: `把「${active.label}」变更为「${target.label}」?`,
532
+ width: 520,
533
+ okText: '迁移并保存',
534
+ cancelText: '取消',
535
+ content: (
536
+ <div>
537
+ <div style={{ fontSize: fs(13), marginBottom: 10, color: MILITARY.textSecondary }}>
538
+ 节点类型对应的是数据库里的一张表,所以这不是改字段,而是
539
+ <b>在「{target.label}」里新建一条记录、再删掉原来那条</b>。
540
+ 同名字段会自动带过去,目标本体的必填字段先用占位值补齐,保存后可继续在这里完善。
541
+ </div>
542
+ {impact && <ImpactList impact={impact} />}
543
+ </div>
544
+ ),
545
+ onOk: async () => {
546
+ setSaving(true);
547
+ try {
548
+ const r = await fetch('/api/graph/node/move', {
549
+ method: 'POST',
550
+ headers: { 'Content-Type': 'application/json' },
551
+ body: JSON.stringify({ fromEntity: entity, id, toEntity: target.id, data: payload }),
552
+ });
553
+ const j = (await r.json()) as {
554
+ code: number;
555
+ message?: string;
556
+ data?: { id: string; entity: string };
557
+ };
558
+ if (j.code === 0 && j.data) {
559
+ message.success(`已变更为「${target.label}」`);
560
+ // 新节点可能落在当前视图之外,用 center 定位过去,避免"改完找不到了"
561
+ await refreshAfterSave(j.data.entity, j.data.id, `${j.data.entity}:${j.data.id}`);
562
+ } else {
563
+ message.error(j.message || '变更类型失败');
564
+ }
565
+ } catch (e) {
566
+ message.error(`变更类型失败:${String(e)}`);
567
+ } finally {
568
+ setSaving(false);
569
+ }
570
+ },
571
+ });
572
+ };
573
+
574
+ /** 切换"类型"下拉 → 换成目标本体的字段表,并把同名且有值的输入带过去 */
575
+ const onEntityChange = async (next: string) => {
576
+ if (!detail || !active) return;
577
+ const raw = form.getFieldsValue(true) as Record<string, unknown>;
578
+ try {
579
+ // 切回原类型时不能再拿"空表单"当字段表 —— 直接把真实值重新拉一遍,
580
+ // 否则用户会看到一堆本该有值的字段变成空白。
581
+ const backToSelf = next === active.entity;
582
+ const meta = await fetchDetail(next, backToSelf ? dbIdOf(active) : undefined);
583
+ if (!meta) return;
584
+ if (backToSelf) {
585
+ setDetail(meta);
586
+ return;
587
+ }
588
+ const carry: Record<string, unknown> = {};
589
+ for (const f of meta.fields) {
590
+ // 唯一列(编码/账号)不继承:两个表各自唯一,复制过去十有八九撞约束
591
+ if (f.unique) continue;
592
+ const v = raw[f.name];
593
+ if (v === undefined || v === null) continue;
594
+ if (typeof v === 'string' && v.trim() === '') continue;
595
+ carry[f.name] = v;
596
+ }
597
+ setDetail({ ...meta, values: carry });
598
+ } catch (e) {
599
+ message.error(e instanceof Error ? e.message : String(e));
600
+ }
601
+ };
602
+
603
+ const doRemove = async () => {
604
+ if (!active) return;
605
+ setSaving(true);
606
+ try {
607
+ const r = await fetch('/api/graph/node', {
608
+ method: 'DELETE',
609
+ headers: { 'Content-Type': 'application/json' },
610
+ body: JSON.stringify({ entity: active.entity, id: dbIdOf(active) }),
611
+ });
612
+ const j = (await r.json()) as {
613
+ code: number;
614
+ message?: string;
615
+ data?: { deleted?: number; nullified?: number; soft?: boolean };
616
+ };
617
+ if (j.code === 0) {
618
+ const d = j.data ?? {};
619
+ message.success(
620
+ d.soft
621
+ ? '已删除(软删,记录保留用于审计)'
622
+ : `已删除,连带清理 ${d.deleted ?? 1} 条、置空 ${d.nullified ?? 0} 处引用`
623
+ );
624
+ setActive(null);
625
+ setFlash(null);
626
+ setSavedAt(null);
627
+ await load(viewId ?? undefined);
628
+ } else {
629
+ message.error(j.message || '删除失败');
630
+ }
631
+ } catch (e) {
632
+ message.error(`删除失败:${String(e)}`);
633
+ } finally {
634
+ setSaving(false);
635
+ }
636
+ };
637
+
638
+ /**
639
+ * 删除前先问服务端"会影响什么",再用 Modal 二次确认。
640
+ * 删除会连带改数据(可选外键置空、必填外键删除),这一步是给用户看清代价。
641
+ */
642
+ const confirmRemove = async () => {
643
+ if (!active) return;
644
+ const entity = active.entity;
645
+ const id = dbIdOf(active);
646
+ try {
647
+ const r = await fetch(`/api/graph/node?entity=${encodeURIComponent(entity)}&id=${encodeURIComponent(id)}`);
648
+ const j = (await r.json()) as { code: number; message?: string; data?: DeleteImpact };
649
+ if (j.code !== 0 || !j.data) {
650
+ message.error(j.message || '无法获取删除影响');
651
+ return;
652
+ }
653
+ const impact = j.data;
654
+ modal.confirm({
655
+ title: `确认删除「${impact.title}」?`,
656
+ width: 480,
657
+ okText: '确认删除',
658
+ okButtonProps: { danger: true },
659
+ cancelText: '取消',
660
+ content: <ImpactList impact={impact} />,
661
+ onOk: () => doRemove(),
662
+ });
663
+ } catch (e) {
664
+ message.error(`无法获取删除影响:${String(e)}`);
665
+ }
666
+ };
667
+
668
+ const addNode = async (values: Record<string, string>) => {
669
+ const entity = values.entity;
670
+ const titleField = titleFieldOf(entity);
671
+ const payload: Record<string, unknown> = {};
672
+ if (titleField && values.label) payload[titleField] = values.label;
673
+
674
+ setSaving(true);
675
+ try {
676
+ const r = await fetch('/api/graph/node', {
677
+ method: 'POST',
678
+ headers: { 'Content-Type': 'application/json' },
679
+ body: JSON.stringify({ entity, data: payload }),
680
+ });
681
+ const j = (await r.json()) as { code: number; message?: string; data?: { id?: string } };
682
+ if (j.code === 0) {
683
+ const newId = j.data?.id;
684
+ message.success('已新增节点,可在右侧补全其余字段');
685
+ setAdding(false);
686
+ // 新节点通常没有关联,默认视图可能取不到它 —— 用 center 定位过去
687
+ const key = newId ? `${entity}:${newId}` : undefined;
688
+ const fresh = await load(viewId ?? undefined, key);
689
+ const node = key ? fresh?.nodes.find((n) => n.id === key) : undefined;
690
+ if (node) {
691
+ setActive(node);
692
+ setFlash({ id: node.id, nonce: Date.now() });
693
+ }
694
+ } else {
695
+ message.error(j.message || '新增失败');
696
+ }
697
+ } catch (e) {
698
+ message.error(`新增失败:${String(e)}`);
699
+ } finally {
700
+ setSaving(false);
701
+ }
702
+ };
703
+
704
+ /* --------------------------- 字段控件 --------------------------- */
705
+
706
+ const renderField = (f: FieldMeta) => {
707
+ let control: React.ReactNode;
708
+ switch (f.kind) {
709
+ case 'longtext':
710
+ control = (
711
+ <Input.TextArea
712
+ rows={3}
713
+ maxLength={500}
714
+ placeholder={f.hint || `请输入${f.label}`}
715
+ style={{ fontSize: fs(13) }}
716
+ />
717
+ );
718
+ break;
719
+ case 'number':
720
+ control = <InputNumber style={{ width: '100%' }} placeholder={f.hint || '数字'} />;
721
+ break;
722
+ case 'boolean':
723
+ control = <Switch checkedChildren="是" unCheckedChildren="否" />;
724
+ break;
725
+ case 'datetime':
726
+ control = (
727
+ <DatePicker
728
+ style={{ width: '100%' }}
729
+ showTime={{ format: 'HH:mm' }}
730
+ format="YYYY-MM-DD HH:mm"
731
+ placeholder={f.hint || '选择时间'}
732
+ />
733
+ );
734
+ break;
735
+ case 'enum':
736
+ control = (
737
+ <Select
738
+ allowClear={f.nullable}
739
+ placeholder={f.hint || `选择${f.label}`}
740
+ options={(f.options ?? []).map((o) => ({ label: String(o.label), value: o.value }))}
741
+ />
742
+ );
743
+ break;
744
+ case 'fk':
745
+ control = (
746
+ <Select
747
+ allowClear
748
+ showSearch
749
+ optionFilterProp="label"
750
+ placeholder={`选择${f.label}`}
751
+ options={(detail?.refs[f.name] ?? []).map((o) => ({ label: o.label, value: o.value }))}
752
+ />
753
+ );
754
+ break;
755
+ default:
756
+ control = <Input maxLength={200} allowClear placeholder={f.hint || `请输入${f.label}`} />;
757
+ }
758
+
759
+ return (
760
+ <Form.Item
761
+ key={f.name}
762
+ name={f.name}
763
+ label={
764
+ <span style={{ fontSize: fs(12.5) }}>
765
+ {f.label}
766
+ <span style={{ marginLeft: 6, fontSize: fs(11), color: MILITARY.textMuted }}>
767
+ {f.name}
768
+ {f.required ? ' *' : ''}
769
+ </span>
770
+ </span>
771
+ }
772
+ valuePropName={f.kind === 'boolean' ? 'checked' : undefined}
773
+ rules={
774
+ f.required && f.kind !== 'boolean'
775
+ ? [{ required: true, message: `请填写${f.label}` }]
776
+ : undefined
777
+ }
778
+ style={{ marginBottom: 12 }}
779
+ >
780
+ {control}
781
+ </Form.Item>
782
+ );
783
+ };
784
+
785
+ const primaryFields = detail?.fields.filter((f) => f.primary) ?? [];
786
+ const otherFields = detail?.fields.filter((f) => !f.primary) ?? [];
787
+ const moved = !!detail && !!active && detail.entity.id !== active.entity;
788
+
789
+ // 换节点 → 收起"更多字段";切类型 → 展开(目标本体的必填项多半在里面,藏着会让人找不到)
790
+ useEffect(() => {
791
+ setMoreOpen(false);
792
+ }, [activeKey]);
793
+
794
+ useEffect(() => {
795
+ if (moved) setMoreOpen(true);
796
+ }, [moved]);
797
+
798
+ return (
799
+ <div style={{ padding: 16 }}>
800
+ {/* 页头:和 AI 中心、工作台同一套「小标题 + 主标题 + 说明」节奏 */}
801
+ <div style={{ marginBottom: 12 }}>
802
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
803
+ <span style={{ width: 6, height: 6, background: MILITARY.accent, display: 'inline-block' }} />
804
+ <span style={{ fontSize: fs(10.5), letterSpacing: 3, color: MILITARY.textMuted, fontWeight: 600 }}>
805
+ KNOWLEDGE GRAPH
806
+ </span>
807
+ </div>
808
+ <div style={{ fontSize: fs(20), fontWeight: 700, color: MILITARY.text, marginTop: 4 }}>
809
+ <ApartmentOutlined style={{ color: MILITARY.primary, marginRight: 8, fontSize: fs(18) }} />
810
+ 知识图谱
811
+ </div>
812
+ <div style={{ fontSize: fs(12.5), color: MILITARY.textMuted, marginTop: 4 }}>
813
+ 按本体分层展示业务对象之间的关系。点击节点可修改它的全部字段(含类型与附加信息),修改直接写入数据库。
814
+ </div>
815
+ </div>
816
+
817
+ {err && (
818
+ <Alert
819
+ type="error"
820
+ showIcon
821
+ style={{ marginBottom: 12 }}
822
+ title="图谱加载失败"
823
+ description={err}
824
+ />
825
+ )}
826
+
827
+ {focusLoading && !focusInfo && (
828
+ <div
829
+ style={{
830
+ marginBottom: 12,
831
+ padding: '10px 14px',
832
+ border: `1px solid ${MILITARY.border}`,
833
+ borderRadius: 10,
834
+ background: MILITARY.paper,
835
+ fontSize: fs(12.5),
836
+ color: MILITARY.textSecondary,
837
+ }}
838
+ >
839
+ <Spin size="small" /> <span style={{ marginLeft: 8 }}>正在解析这条业务记录与图谱的关联…</span>
840
+ </div>
841
+ )}
842
+
843
+ {focusErr && (
844
+ <Alert
845
+ type="warning"
846
+ showIcon
847
+ style={{ marginBottom: 12 }}
848
+ title="无法定位到图谱节点"
849
+ description={focusErr}
850
+ />
851
+ )}
852
+
853
+ {/* 关联横幅:从业务列表点「关联」跳过来时,先说清"这一跳连着什么" */}
854
+ {focusInfo && (
855
+ <div
856
+ style={{
857
+ marginBottom: 12,
858
+ padding: '12px 14px',
859
+ border: `1px solid ${MILITARY.border}`,
860
+ borderRadius: 10,
861
+ background: MILITARY.paper,
862
+ }}
863
+ >
864
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
865
+ <span style={{ width: 3, height: 14, background: MILITARY.accent, display: 'inline-block' }} />
866
+ <Tag
867
+ style={{
868
+ background: MILITARY.soft,
869
+ color: MILITARY.primaryDark,
870
+ borderColor: MILITARY.softStrong,
871
+ marginInlineEnd: 0,
872
+ }}
873
+ >
874
+ {focusInfo.entity.label}
875
+ </Tag>
876
+ <span style={{ fontSize: fs(11), letterSpacing: 2, color: MILITARY.textMuted, fontWeight: 600 }}>
877
+ LINKED FROM BUSINESS DATA
878
+ </span>
879
+ <span style={{ flex: 1 }} />
880
+ {focusInfo.backLink && (
881
+ <Button size="small" href={focusInfo.backLink}>
882
+ 去业务记录
883
+ </Button>
884
+ )}
885
+ {focusInfo.mode === 'single' && (
886
+ <Button
887
+ size="small"
888
+ onClick={() => void load(focusInfo.view.id, focusInfo.center, 2)}
889
+ >
890
+ 展开关联关系
891
+ </Button>
892
+ )}
893
+ <Button size="small" icon={<DisconnectOutlined />} onClick={clearFocus}>
894
+ 退出聚焦
895
+ </Button>
896
+ </div>
897
+
898
+ <div style={{ fontSize: fs(13), color: MILITARY.text, marginTop: 8 }}>{focusInfo.summary}</div>
899
+
900
+ <Space wrap size={[6, 6]} style={{ marginTop: 8 }}>
901
+ {focusInfo.nodes.map((n) => (
902
+ <Tag
903
+ key={n.id}
904
+ style={
905
+ n.id === focusInfo.center
906
+ ? {
907
+ background: MILITARY.softStrong,
908
+ color: MILITARY.primaryDark,
909
+ borderColor: MILITARY.primary,
910
+ }
911
+ : {
912
+ background: MILITARY.card,
913
+ color: MILITARY.textSecondary,
914
+ borderColor: MILITARY.border,
915
+ }
916
+ }
917
+ >
918
+ {n.label}
919
+ <span style={{ color: MILITARY.textMuted, marginLeft: 6 }}>{n.role}</span>
920
+ </Tag>
921
+ ))}
922
+ </Space>
923
+
924
+ <div style={{ fontSize: fs(12), color: MILITARY.textMuted, marginTop: 8 }}>
925
+ {focusInfo.mode === 'multi'
926
+ ? `已高亮这 ${focusInfo.nodes.length} 个实体之间的 ${focusInfo.edges.length} 条连接线,其余节点压暗保留上下文`
927
+ : '该记录在图谱中只对应 1 个节点,已直接打开右侧节点详情'}
928
+ </div>
929
+ </div>
930
+ )}
931
+
932
+ <Row gutter={16}>
933
+ <Col xs={24} xl={active || adding ? 15 : 24}>
934
+ <Card
935
+ title={data?.view.label ?? '关系图'}
936
+ extra={
937
+ <Space>
938
+ {(data?.views.length ?? 0) > 1 && (
939
+ <Select
940
+ size="small"
941
+ style={{ minWidth: 150 }}
942
+ value={viewId ?? undefined}
943
+ options={data?.views}
944
+ onChange={(v) => void load(v)}
945
+ />
946
+ )}
947
+ <Button size="small" icon={<PlusOutlined />} onClick={() => setAdding((v) => !v)}>
948
+ 新增节点
949
+ </Button>
950
+ {centered && !focusInfo && (
951
+ <Space size={4}>
952
+ <Tag color="gold">已定位到新节点</Tag>
953
+ <Button size="small" onClick={() => void load(viewId ?? undefined)}>
954
+ 返回全图
955
+ </Button>
956
+ </Space>
957
+ )}
958
+ <Button
959
+ size="small"
960
+ icon={<ReloadOutlined />}
961
+ loading={loading}
962
+ onClick={() => void load(viewId ?? undefined)}
963
+ >
964
+ 刷新
965
+ </Button>
966
+ </Space>
967
+ }
968
+ >
969
+ {data ? (
970
+ <>
971
+ <OntologyGraph
972
+ nodes={data.nodes}
973
+ edges={data.edges}
974
+ entities={data.entities}
975
+ loading={loading}
976
+ activeId={active?.id ?? null}
977
+ onNodeClick={(n) => setActive(n)}
978
+ collapsed={collapsed}
979
+ height={560}
980
+ flash={flash}
981
+ focus={
982
+ focusInfo && focusInfo.mode === 'multi'
983
+ ? {
984
+ nodes: focusInfo.nodes.map((n) => n.id),
985
+ edges: focusInfo.edges.map((e) => ({ source: e.source, target: e.target })),
986
+ }
987
+ : null
988
+ }
989
+ />
990
+ {data.truncated && (
991
+ <Alert
992
+ type="warning"
993
+ showIcon
994
+ style={{ marginTop: 10 }}
995
+ title="节点过多已截断"
996
+ description="为保证渲染性能,最多展示 120 个节点。"
997
+ />
998
+ )}
999
+ </>
1000
+ ) : (
1001
+ <OntologyGraph nodes={[]} edges={[]} entities={[]} loading={loading} />
1002
+ )}
1003
+ </Card>
1004
+ </Col>
1005
+
1006
+ {(active || adding) && (
1007
+ <Col xs={24} xl={9}>
1008
+ {adding && (
1009
+ <Card title="新增节点" style={{ marginBottom: 16 }}>
1010
+ <Form layout="vertical" onFinish={addNode}>
1011
+ <Form.Item name="entity" label="本体类型" rules={[{ required: true }]}>
1012
+ <Select
1013
+ placeholder="选择本体"
1014
+ options={(data?.entities ?? []).map((e) => ({ label: e.label, value: e.id }))}
1015
+ />
1016
+ </Form.Item>
1017
+ <Form.Item name="label" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
1018
+ <Input placeholder="例如:技术部 / 张三" maxLength={30} />
1019
+ </Form.Item>
1020
+ <div style={{ fontSize: fs(12), color: MILITARY.textMuted, marginBottom: 10 }}>
1021
+ 其余必填字段会先用占位值补齐,创建后在详情面板里逐项改掉即可。
1022
+ </div>
1023
+ <Space>
1024
+ <Button type="primary" htmlType="submit" loading={saving} icon={<SaveOutlined />}>
1025
+ 新增
1026
+ </Button>
1027
+ <Button onClick={() => setAdding(false)}>取消</Button>
1028
+ </Space>
1029
+ </Form>
1030
+ </Card>
1031
+ )}
1032
+
1033
+ {active && (
1034
+ <Card
1035
+ title="节点详情"
1036
+ extra={
1037
+ <Space size={6}>
1038
+ {savedAt && <Tag color="success">已保存 {savedAt}</Tag>}
1039
+ <Tag
1040
+ style={{
1041
+ background: MILITARY.soft,
1042
+ color: MILITARY.primaryDark,
1043
+ borderColor: MILITARY.softStrong,
1044
+ }}
1045
+ >
1046
+ {detail?.entity.label ??
1047
+ data?.entities.find((e) => e.id === active.entity)?.label ??
1048
+ active.entity}
1049
+ </Tag>
1050
+ </Space>
1051
+ }
1052
+ >
1053
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
1054
+ <span style={{ fontSize: fs(12), color: MILITARY.textMuted }}>节点 ID</span>
1055
+ <Typography.Text code copyable style={{ fontSize: fs(11) }}>
1056
+ {dbIdOf(active)}
1057
+ </Typography.Text>
1058
+ </div>
1059
+
1060
+ <Divider style={{ margin: '4px 0 12px' }}>可编辑内容(保存后写入数据库)</Divider>
1061
+
1062
+ {detailErr && (
1063
+ <Alert type="error" showIcon style={{ marginBottom: 10 }} title={detailErr} />
1064
+ )}
1065
+
1066
+ {detailLoading && !detail ? (
1067
+ <div style={{ padding: '24px 0', textAlign: 'center' }}>
1068
+ <Spin size="small" />
1069
+ <div style={{ fontSize: fs(12), color: MILITARY.textMuted, marginTop: 8 }}>
1070
+ 正在读取字段…
1071
+ </div>
1072
+ </div>
1073
+ ) : detail ? (
1074
+ <>
1075
+ <Form form={form} layout="vertical" size="small" disabled={detailLoading}>
1076
+ <Form.Item
1077
+ name="__entity"
1078
+ label={
1079
+ <span style={{ fontSize: fs(12.5) }}>
1080
+ 类型
1081
+ <span style={{ marginLeft: 6, fontSize: fs(11), color: MILITARY.textMuted }}>
1082
+ 节点所属本体
1083
+ </span>
1084
+ </span>
1085
+ }
1086
+ style={{ marginBottom: 12 }}
1087
+ >
1088
+ <Select
1089
+ options={detail.entities.map((e) => ({
1090
+ label: e.label,
1091
+ value: e.id,
1092
+ }))}
1093
+ onChange={(v: string) => void onEntityChange(v)}
1094
+ />
1095
+ </Form.Item>
1096
+
1097
+ {moved && (
1098
+ <Alert
1099
+ type="warning"
1100
+ showIcon
1101
+ style={{ marginBottom: 12 }}
1102
+ title="保存时会迁移到另一个本体"
1103
+ description="类型对应数据库表,保存后会在目标本体新建记录并清掉原记录。同名字段自动带过去,但唯一字段(编码/工号等)需要你重新填一个不重复的值。"
1104
+ />
1105
+ )}
1106
+
1107
+ {primaryFields.map(renderField)}
1108
+
1109
+ {otherFields.length > 0 && (
1110
+ <Collapse
1111
+ ghost
1112
+ size="small"
1113
+ style={{ marginBottom: 4 }}
1114
+ activeKey={moreOpen ? ['more'] : []}
1115
+ onChange={(k) => {
1116
+ const keys = Array.isArray(k) ? k : [k];
1117
+ setMoreOpen(keys.includes('more'));
1118
+ }}
1119
+ items={[
1120
+ {
1121
+ key: 'more',
1122
+ label: (
1123
+ <span style={{ fontSize: fs(12.5), color: MILITARY.textSecondary }}>
1124
+ 更多字段({otherFields.length})
1125
+ </span>
1126
+ ),
1127
+ children: <div>{otherFields.map(renderField)}</div>,
1128
+ },
1129
+ ]}
1130
+ />
1131
+ )}
1132
+
1133
+ {!detail.fields.length && (
1134
+ <Empty
1135
+ image={Empty.PRESENTED_IMAGE_SIMPLE}
1136
+ description={
1137
+ <span style={{ fontSize: fs(12.5), color: MILITARY.textMuted }}>
1138
+ 该模型没有可编辑字段(可能全是主键/审计列)
1139
+ </span>
1140
+ }
1141
+ />
1142
+ )}
1143
+ </Form>
1144
+
1145
+ <Space wrap style={{ marginTop: 12 }}>
1146
+ <Button
1147
+ type="primary"
1148
+ icon={<SaveOutlined />}
1149
+ loading={saving}
1150
+ onClick={() => void save()}
1151
+ disabled={detailLoading}
1152
+ >
1153
+ {moved ? '迁移并保存' : '保存'}
1154
+ </Button>
1155
+ <Button
1156
+ icon={<UndoOutlined />}
1157
+ disabled={detailLoading || saving}
1158
+ onClick={() => {
1159
+ form.resetFields();
1160
+ form.setFieldsValue(toFormValues(detail));
1161
+ }}
1162
+ >
1163
+ 撤销改动
1164
+ </Button>
1165
+ {active.entity === 'department' && (
1166
+ <Button
1167
+ onClick={() =>
1168
+ setCollapsed((prev) => {
1169
+ const next = new Set(prev);
1170
+ if (next.has(active.id)) next.delete(active.id);
1171
+ else next.add(active.id);
1172
+ return next;
1173
+ })
1174
+ }
1175
+ >
1176
+ {collapsed.has(active.id) ? '展开下属' : '折叠下属'}
1177
+ </Button>
1178
+ )}
1179
+ <Button
1180
+ danger
1181
+ icon={<DeleteOutlined />}
1182
+ loading={saving}
1183
+ onClick={() => void confirmRemove()}
1184
+ >
1185
+ 删除
1186
+ </Button>
1187
+ <Button onClick={() => setActive(null)}>关闭</Button>
1188
+ </Space>
1189
+
1190
+ {active.link && (
1191
+ <div style={{ marginTop: 12 }}>
1192
+ <Typography.Link href={active.link} target="_blank">
1193
+ 去业务页面查看 →
1194
+ </Typography.Link>
1195
+ </div>
1196
+ )}
1197
+ </>
1198
+ ) : null}
1199
+ </Card>
1200
+ )}
1201
+ </Col>
1202
+ )}
1203
+ </Row>
1204
+ </div>
1205
+ );
1206
+ }
1207
+
1208
+ /**
1209
+ * useSearchParams 需要 Suspense 边界(否则构建期静态预渲染会直接报错)。
1210
+ * 页面主体是 GraphPageInner,这一层只负责边界,不持有任何状态。
1211
+ */
1212
+ export default function GraphPage() {
1213
+ return (
1214
+ <Suspense
1215
+ fallback={
1216
+ <div style={{ padding: 24, fontSize: fs(12.5), color: MILITARY.textMuted }}>
1217
+ <Spin size="small" /> 正在加载知识图谱…
1218
+ </div>
1219
+ }
1220
+ >
1221
+ <GraphPageInner />
1222
+ </Suspense>
1223
+ );
1224
+ }