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.
- package/LICENSE +21 -0
- package/README.md +35 -2
- package/lib/sync.js +121 -0
- package/package.json +14 -4
- package/resources/.cache/ref.b64 +1 -0
- package/resources/README.md +97 -0
- package/resources/_.env +6 -0
- package/resources/_.env.example +25 -0
- package/resources/_.gitignore +18 -0
- package/resources/_.lintstagedrc.json +9 -0
- package/resources/_.npmrc +5 -0
- package/resources/_.prettierrc +7 -0
- package/resources/commitlint.config.mjs +1 -0
- package/resources/content/docs/README.md +63 -0
- package/resources/content/docs/faq.md +107 -0
- package/resources/content/docs/features/_meta.json +5 -0
- package/resources/content/docs/features/ai.md +71 -0
- package/resources/content/docs/features/appearance.md +47 -0
- package/resources/content/docs/features/feedback.md +79 -0
- package/resources/content/docs/features/files.md +74 -0
- package/resources/content/docs/features/graph.md +66 -0
- package/resources/content/docs/features/inbox.md +61 -0
- package/resources/content/docs/guide/_meta.json +5 -0
- package/resources/content/docs/guide/account.md +76 -0
- package/resources/content/docs/guide/data.md +87 -0
- package/resources/content/docs/guide/navigation.md +81 -0
- package/resources/content/docs/guide/permissions.md +67 -0
- package/resources/content/docs/guide/system.md +92 -0
- package/resources/content/docs/quick-start.md +70 -0
- package/resources/content/docs/shortcuts.md +69 -0
- package/resources/eslint.config.mjs +17 -0
- package/resources/next-env.d.ts +6 -0
- package/resources/next.config.ts +13 -0
- package/resources/postcss.config.mjs +7 -0
- package/resources/prisma/org.config.ts +98 -0
- package/resources/prisma/schema.prisma +336 -0
- package/resources/prisma/seed.ts +721 -0
- package/resources/prisma/seedSafe.ts +347 -0
- package/resources/prisma.config.ts +14 -0
- package/resources/scripts/check-config.mjs +773 -0
- package/resources/scripts/lib/schema-graph.mjs +410 -0
- package/resources/scripts/patch-next-trace.mjs +86 -0
- package/resources/scripts/suggest-relations.mjs +105 -0
- package/resources/src/app/(admin)/ai/page.tsx +469 -0
- package/resources/src/app/(admin)/dashboard/page.tsx +260 -0
- package/resources/src/app/(admin)/demo/employees/page.tsx +508 -0
- package/resources/src/app/(admin)/demo/files/page.tsx +451 -0
- package/resources/src/app/(admin)/demo/loading/page.tsx +513 -0
- package/resources/src/app/(admin)/error.tsx +96 -0
- package/resources/src/app/(admin)/feedback/page.tsx +573 -0
- package/resources/src/app/(admin)/graph/page.tsx +1224 -0
- package/resources/src/app/(admin)/inbox/page.tsx +31 -0
- package/resources/src/app/(admin)/layout.tsx +58 -0
- package/resources/src/app/(admin)/loading.tsx +31 -0
- package/resources/src/app/(admin)/system/depts/page.tsx +415 -0
- package/resources/src/app/(admin)/system/logs/page.tsx +215 -0
- package/resources/src/app/(admin)/system/menus/page.tsx +403 -0
- package/resources/src/app/(admin)/system/roles/page.tsx +433 -0
- package/resources/src/app/(admin)/system/users/page.tsx +620 -0
- package/resources/src/app/api/account-recovery/route.ts +132 -0
- package/resources/src/app/api/ai/act/route.ts +70 -0
- package/resources/src/app/api/ai/ask/route.ts +82 -0
- package/resources/src/app/api/ai/chat/route.ts +404 -0
- package/resources/src/app/api/ai/report/route.ts +164 -0
- package/resources/src/app/api/auth/[...nextauth]/route.ts +3 -0
- package/resources/src/app/api/auth/change-password/route.ts +57 -0
- package/resources/src/app/api/auth/forgot/route.ts +98 -0
- package/resources/src/app/api/auth/register/route.ts +95 -0
- package/resources/src/app/api/auth/register-config/route.ts +51 -0
- package/resources/src/app/api/badges/route.ts +39 -0
- package/resources/src/app/api/depts/[id]/route.ts +145 -0
- package/resources/src/app/api/depts/route.ts +126 -0
- package/resources/src/app/api/docs/export/route.ts +87 -0
- package/resources/src/app/api/docs/parse/route.ts +57 -0
- package/resources/src/app/api/docs/search/route.ts +22 -0
- package/resources/src/app/api/employees/[id]/route.ts +147 -0
- package/resources/src/app/api/employees/export/route.ts +115 -0
- package/resources/src/app/api/employees/import/route.ts +205 -0
- package/resources/src/app/api/employees/route.ts +149 -0
- package/resources/src/app/api/export/route.ts +254 -0
- package/resources/src/app/api/favorites/route.ts +56 -0
- package/resources/src/app/api/feedback/ai/route.ts +31 -0
- package/resources/src/app/api/feedback/approve/route.ts +67 -0
- package/resources/src/app/api/feedback/report/route.ts +213 -0
- package/resources/src/app/api/feedback/route.ts +246 -0
- package/resources/src/app/api/files/[name]/route.ts +82 -0
- package/resources/src/app/api/graph/focus/route.ts +75 -0
- package/resources/src/app/api/graph/node/move/route.ts +48 -0
- package/resources/src/app/api/graph/node/route.ts +274 -0
- package/resources/src/app/api/graph/route.ts +272 -0
- package/resources/src/app/api/health/route.ts +11 -0
- package/resources/src/app/api/inbox/read/route.ts +36 -0
- package/resources/src/app/api/inbox/summary/route.ts +15 -0
- package/resources/src/app/api/logs/route.ts +82 -0
- package/resources/src/app/api/menus/[id]/route.ts +132 -0
- package/resources/src/app/api/menus/mine/route.ts +23 -0
- package/resources/src/app/api/menus/route.ts +89 -0
- package/resources/src/app/api/roles/[id]/menus/route.ts +73 -0
- package/resources/src/app/api/roles/[id]/route.ts +96 -0
- package/resources/src/app/api/roles/route.ts +114 -0
- package/resources/src/app/api/search/route.ts +22 -0
- package/resources/src/app/api/stats/route.ts +92 -0
- package/resources/src/app/api/upload/route.ts +38 -0
- package/resources/src/app/api/users/[id]/password/route.ts +55 -0
- package/resources/src/app/api/users/[id]/route.ts +145 -0
- package/resources/src/app/api/users/route.ts +153 -0
- package/resources/src/app/api/visits/route.ts +48 -0
- package/resources/src/app/change-password/ChangePasswordForm.tsx +174 -0
- package/resources/src/app/change-password/page.tsx +19 -0
- package/resources/src/app/docs/[[...slug]]/page.tsx +56 -0
- package/resources/src/app/docs/layout.tsx +34 -0
- package/resources/src/app/docs/loading.tsx +19 -0
- package/resources/src/app/docs/not-found.tsx +25 -0
- package/resources/src/app/forgot/page.tsx +392 -0
- package/resources/src/app/global-error.tsx +75 -0
- package/resources/src/app/globals.css +235 -0
- package/resources/src/app/layout.tsx +44 -0
- package/resources/src/app/login/page.tsx +267 -0
- package/resources/src/app/page.tsx +14 -0
- package/resources/src/app/register/page.tsx +237 -0
- package/resources/src/auth.ts +215 -0
- package/resources/src/components/AiPanel.tsx +970 -0
- package/resources/src/components/AuthContext.tsx +82 -0
- package/resources/src/components/DataTable.tsx +459 -0
- package/resources/src/components/EChart.tsx +140 -0
- package/resources/src/components/FeedbackAiModal.tsx +476 -0
- package/resources/src/components/FeedbackReportModal.tsx +479 -0
- package/resources/src/components/Markdown.tsx +532 -0
- package/resources/src/components/OntologyGraph.tsx +1196 -0
- package/resources/src/components/Providers.tsx +99 -0
- package/resources/src/components/VisitTracker.tsx +89 -0
- package/resources/src/components/admin/AdminShell.tsx +749 -0
- package/resources/src/components/admin/CountBadge.tsx +67 -0
- package/resources/src/components/admin/GlobalSearch.tsx +509 -0
- package/resources/src/components/admin/InboxBell.tsx +78 -0
- package/resources/src/components/admin/VisitStats.tsx +145 -0
- package/resources/src/components/admin/menu-icons.tsx +56 -0
- package/resources/src/components/ai/AiInputForm.tsx +351 -0
- package/resources/src/components/appearance/AppearanceDrawer.tsx +268 -0
- package/resources/src/components/appearance/AppearanceProvider.tsx +160 -0
- package/resources/src/components/docs/DocArticle.tsx +106 -0
- package/resources/src/components/docs/DocToc.tsx +100 -0
- package/resources/src/components/docs/DocsChrome.tsx +259 -0
- package/resources/src/components/docs/DocsHome.tsx +209 -0
- package/resources/src/components/docs/DocsSearch.tsx +365 -0
- package/resources/src/components/docs/docs.css +1458 -0
- package/resources/src/components/feedback/AsyncButton.tsx +108 -0
- package/resources/src/components/feedback/LoadingOverlay.tsx +125 -0
- package/resources/src/components/feedback/PressableCard.tsx +92 -0
- package/resources/src/components/feedback/README.md +97 -0
- package/resources/src/components/feedback/RouteProgress.tsx +108 -0
- package/resources/src/components/feedback/Skeleton.tsx +249 -0
- package/resources/src/components/feedback/StatCard.tsx +70 -0
- package/resources/src/components/feedback/TaskHint.tsx +153 -0
- package/resources/src/components/feedback/index.ts +93 -0
- package/resources/src/components/feedback/motion.css +204 -0
- package/resources/src/components/feedback/motion.ts +63 -0
- package/resources/src/components/feedback/useCountUp.ts +110 -0
- package/resources/src/components/feedback/usePending.ts +209 -0
- package/resources/src/components/feedback/useRowFlash.ts +40 -0
- package/resources/src/components/inbox/InboxView.tsx +409 -0
- package/resources/src/components/ui/button.tsx +35 -0
- package/resources/src/components/ui/card.tsx +37 -0
- package/resources/src/components/ui/input.tsx +19 -0
- package/resources/src/lib/ai.ts +310 -0
- package/resources/src/lib/aiContext.ts +357 -0
- package/resources/src/lib/aiForm.test.ts +191 -0
- package/resources/src/lib/aiForm.ts +532 -0
- package/resources/src/lib/aiFormTypes.ts +83 -0
- package/resources/src/lib/aiToken.ts +72 -0
- package/resources/src/lib/aiTools.ts +823 -0
- package/resources/src/lib/api.ts +160 -0
- package/resources/src/lib/appInfo.ts +128 -0
- package/resources/src/lib/appearance.ts +643 -0
- package/resources/src/lib/badge-count.ts +146 -0
- package/resources/src/lib/badges.test.ts +58 -0
- package/resources/src/lib/badges.ts +79 -0
- package/resources/src/lib/brand-mark.test.ts +87 -0
- package/resources/src/lib/brand-mark.ts +45 -0
- package/resources/src/lib/docs-shared.test.ts +148 -0
- package/resources/src/lib/docs-shared.ts +422 -0
- package/resources/src/lib/docs.ts +381 -0
- package/resources/src/lib/err-text.ts +21 -0
- package/resources/src/lib/excel.ts +152 -0
- package/resources/src/lib/favorite-path.ts +60 -0
- package/resources/src/lib/favorites.ts +59 -0
- package/resources/src/lib/features.ts +69 -0
- package/resources/src/lib/feedback-access.ts +17 -0
- package/resources/src/lib/feedbackAi.ts +1621 -0
- package/resources/src/lib/formRules.ts +76 -0
- package/resources/src/lib/format.ts +52 -0
- package/resources/src/lib/graph-cascade.ts +304 -0
- package/resources/src/lib/graph-focus.ts +301 -0
- package/resources/src/lib/graph-link.ts +17 -0
- package/resources/src/lib/graph-node-write.ts +150 -0
- package/resources/src/lib/graph-schema.ts +475 -0
- package/resources/src/lib/graph-scope.test.ts +46 -0
- package/resources/src/lib/graph-scope.ts +28 -0
- package/resources/src/lib/http.ts +157 -0
- package/resources/src/lib/inbox-types.ts +57 -0
- package/resources/src/lib/inbox.ts +55 -0
- package/resources/src/lib/menu-service.ts +204 -0
- package/resources/src/lib/notifications.ts +113 -0
- package/resources/src/lib/office.ts +531 -0
- package/resources/src/lib/ontology.ts +277 -0
- package/resources/src/lib/openaiCompat.ts +257 -0
- package/resources/src/lib/pending.ts +98 -0
- package/resources/src/lib/prisma.ts +30 -0
- package/resources/src/lib/rbac.test.ts +106 -0
- package/resources/src/lib/rbac.ts +185 -0
- package/resources/src/lib/search-data.ts +158 -0
- package/resources/src/lib/security-questions.ts +20 -0
- package/resources/src/lib/skin-theme.ts +155 -0
- package/resources/src/lib/system-config.ts +55 -0
- package/resources/src/lib/theme.ts +96 -0
- package/resources/src/lib/todos/feedback.ts +86 -0
- package/resources/src/lib/todos/index.ts +73 -0
- package/resources/src/lib/todos/types.ts +33 -0
- package/resources/src/lib/upload.ts +106 -0
- package/resources/src/lib/utils.test.ts +9 -0
- package/resources/src/lib/utils.ts +6 -0
- package/resources/src/lib/validation.test.ts +297 -0
- package/resources/src/lib/validation.ts +658 -0
- package/resources/src/lib/visit-stats.ts +109 -0
- package/resources/src/lib/visits.ts +111 -0
- package/resources/src/lib/zodField.ts +44 -0
- package/resources/tsconfig.json +41 -0
- package/resources/vitest.config.ts +16 -0
- package/resources/vitest.setup.ts +1 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { prisma } from '@/lib/prisma';
|
|
2
|
+
import ONTOLOGY, { type EntityDef, type RelationDef } from '@/lib/ontology';
|
|
3
|
+
import { hasSoftDelete } from '@/lib/graph-schema';
|
|
4
|
+
/**
|
|
5
|
+
* 「业务表 ↔ 知识图谱」的关联推导(全站通用)。
|
|
6
|
+
*
|
|
7
|
+
* 背景:业务列表页(员工、账号、订单……)每行都要能跳进图谱,而且跳法要分两种:
|
|
8
|
+
* - 这条记录在图上只有 1 个节点 → 直接定位到该节点、打开详情面板
|
|
9
|
+
* - 这条记录牵着好几个实体(人 + 项目 + 部门)→ 跳过去并高亮这一簇节点之间的连接线
|
|
10
|
+
*
|
|
11
|
+
* ★ 为什么不把"关联谁"写死在页面里
|
|
12
|
+
* 写死的话,每加一个业务模块就要改一次页面、改一次图谱页,
|
|
13
|
+
* 而且两边一旦不一致,就会出现"点了关联跳到一张空图"。
|
|
14
|
+
* 这里全部由 ontology.ts 推导:
|
|
15
|
+
*
|
|
16
|
+
* 1. 模型名 → 本体 id(entities[].model)
|
|
17
|
+
* 2. 从该节点沿 relations 走,**只往 layer 更小的方向**(业务单据 → 组织/人员/字典)
|
|
18
|
+
* 3. 最多两跳(订单 → 客户 → 部门),且不回头,所以不会把
|
|
19
|
+
* "同一个客户的其它 30 条订单"顺着反向关系全拉进来
|
|
20
|
+
* 4. 走到 1 个节点 = single,多个 = multi
|
|
21
|
+
*
|
|
22
|
+
* 新增业务模块时只要在 ontology.ts 里给它 layer 和关系,这里一行都不用改。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface FocusNode {
|
|
26
|
+
/** 图内唯一键 `entity:dbId`(和 /api/graph 下发的节点 id 完全一致) */
|
|
27
|
+
id: string;
|
|
28
|
+
dbId: string;
|
|
29
|
+
entity: string;
|
|
30
|
+
entityLabel: string;
|
|
31
|
+
label: string;
|
|
32
|
+
subtitle: string;
|
|
33
|
+
/** 该节点在关联里的角色,如"客户" */
|
|
34
|
+
role: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface FocusEdge {
|
|
38
|
+
source: string;
|
|
39
|
+
target: string;
|
|
40
|
+
label: string;
|
|
41
|
+
relation: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FocusResult {
|
|
45
|
+
source: { model: string; dbId: string };
|
|
46
|
+
entity: { id: string; label: string };
|
|
47
|
+
/** 源记录在图上的标题(节点标签) */
|
|
48
|
+
title: string;
|
|
49
|
+
view: { id: string; label: string };
|
|
50
|
+
/** single:只有一个节点 → 打开详情;multi:多个节点 → 高亮连接线 */
|
|
51
|
+
mode: 'single' | 'multi';
|
|
52
|
+
/** `entity:dbId`,有它就能让 /api/graph?center= 只展开这一片 */
|
|
53
|
+
center: string;
|
|
54
|
+
nodes: FocusNode[];
|
|
55
|
+
edges: FocusEdge[];
|
|
56
|
+
/** 反向链接:回到这条记录所在的业务页面(ontology 里 entity.link 推导) */
|
|
57
|
+
backLink: string | null;
|
|
58
|
+
/** 一句话说明这行数据在图谱里连着什么(页面横幅直接显示) */
|
|
59
|
+
summary: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const MAX_FOCUS_NODES = 12;
|
|
63
|
+
|
|
64
|
+
/* ------------------------------ 本体解析 ------------------------------ */
|
|
65
|
+
|
|
66
|
+
export function entityOfModel(model: string): EntityDef | null {
|
|
67
|
+
return ONTOLOGY.entities.find((e) => e.model === model) ?? null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function entityById(id: string): EntityDef | null {
|
|
71
|
+
return ONTOLOGY.entities.find((e) => e.id === id) ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function layerOf(entityId: string): number {
|
|
75
|
+
const i = ONTOLOGY.entities.findIndex((e) => e.id === entityId);
|
|
76
|
+
return ONTOLOGY.entities[i]?.layer ?? i;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function defOf(entityId: string): EntityDef {
|
|
80
|
+
const e = ONTOLOGY.entities.find((x) => x.id === entityId);
|
|
81
|
+
if (!e) throw new Error(`本体 ${entityId} 未在 ontology.ts 中声明`);
|
|
82
|
+
return e;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* ------------------------------ 取数工具 ------------------------------ */
|
|
86
|
+
|
|
87
|
+
type Row = Record<string, unknown>;
|
|
88
|
+
|
|
89
|
+
function delegateFor(model: string) {
|
|
90
|
+
const d = (prisma as unknown as Record<string, unknown>)[model] as
|
|
91
|
+
| {
|
|
92
|
+
findUnique?: (a: unknown) => Promise<Row | null>;
|
|
93
|
+
findMany?: (a: unknown) => Promise<Row[]>;
|
|
94
|
+
}
|
|
95
|
+
| undefined;
|
|
96
|
+
if (!d?.findUnique) throw new Error(`模型 ${model} 不存在(检查 ontology.ts 里的 model 名)`);
|
|
97
|
+
return d as {
|
|
98
|
+
findUnique: (a: unknown) => Promise<Row | null>;
|
|
99
|
+
findMany: (a: unknown) => Promise<Row[]>;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function liveWhere(model: string): Row {
|
|
104
|
+
return hasSoftDelete(model) ? { deletedAt: null } : {};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function rowOf(model: string, id: string): Promise<Row | null> {
|
|
108
|
+
const row = await delegateFor(model).findUnique({ where: { id } });
|
|
109
|
+
if (!row) return null;
|
|
110
|
+
if (hasSoftDelete(model) && row.deletedAt) return null;
|
|
111
|
+
return row;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function subOf(def: EntityDef, row: Row): string {
|
|
115
|
+
return (def.subtitleFields ?? [])
|
|
116
|
+
.map((f) => String(row[f] ?? ''))
|
|
117
|
+
.filter(Boolean)
|
|
118
|
+
.slice(0, 2)
|
|
119
|
+
.join(' · ');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 与 /api/graph 完全一致的邻居取法(外键 / 中间表,两个方向都认) */
|
|
123
|
+
async function neighborsOf(
|
|
124
|
+
cur: { entity: string; id: string },
|
|
125
|
+
rel: RelationDef
|
|
126
|
+
): Promise<{ entity: string; id: string; label: string; subtitle: string }[]> {
|
|
127
|
+
const out: { entity: string; id: string; label: string; subtitle: string }[] = [];
|
|
128
|
+
|
|
129
|
+
if ('field' in rel.via) {
|
|
130
|
+
const fromDef = defOf(rel.from);
|
|
131
|
+
const toDef = defOf(rel.to);
|
|
132
|
+
|
|
133
|
+
if (cur.entity === rel.from) {
|
|
134
|
+
const row = await rowOf(fromDef.model, cur.id);
|
|
135
|
+
const fk = row?.[rel.via.field];
|
|
136
|
+
if (fk) {
|
|
137
|
+
const target = await rowOf(toDef.model, String(fk));
|
|
138
|
+
if (target) {
|
|
139
|
+
out.push({
|
|
140
|
+
entity: rel.to,
|
|
141
|
+
id: String(target.id),
|
|
142
|
+
label: String(target[toDef.titleField] ?? target.id),
|
|
143
|
+
subtitle: subOf(toDef, target),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} else if (cur.entity === rel.to) {
|
|
148
|
+
const rows = await delegateFor(fromDef.model).findMany({
|
|
149
|
+
where: { [rel.via.field]: cur.id, ...liveWhere(fromDef.model) },
|
|
150
|
+
take: 30,
|
|
151
|
+
});
|
|
152
|
+
for (const r of rows) {
|
|
153
|
+
out.push({
|
|
154
|
+
entity: rel.from,
|
|
155
|
+
id: String(r.id),
|
|
156
|
+
label: String(r[fromDef.titleField] ?? r.id),
|
|
157
|
+
subtitle: subOf(fromDef, r),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
const key = cur.entity === rel.from ? rel.via.fromField : rel.via.toField;
|
|
163
|
+
const otherEntity = cur.entity === rel.from ? rel.to : rel.from;
|
|
164
|
+
const otherDef = defOf(otherEntity);
|
|
165
|
+
const otherField = cur.entity === rel.from ? rel.via.toField : rel.via.fromField;
|
|
166
|
+
|
|
167
|
+
const mids = await delegateFor(rel.via.model).findMany({
|
|
168
|
+
where: { [key]: cur.id, ...liveWhere(rel.via.model) },
|
|
169
|
+
take: 50,
|
|
170
|
+
});
|
|
171
|
+
for (const m of mids) {
|
|
172
|
+
const other = await rowOf(otherDef.model, String(m[otherField]));
|
|
173
|
+
if (other) {
|
|
174
|
+
out.push({
|
|
175
|
+
entity: otherEntity,
|
|
176
|
+
id: String(other.id),
|
|
177
|
+
label: String(other[otherDef.titleField] ?? other.id),
|
|
178
|
+
subtitle: subOf(otherDef, other),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* ------------------------------ 主流程 ------------------------------ */
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 求一条业务记录在图谱里的"关联簇"。
|
|
190
|
+
*
|
|
191
|
+
* @param model Prisma 模型名(业务页面只需要知道自己的模型名)
|
|
192
|
+
* @param dbId 记录主键
|
|
193
|
+
* @param maxHops 往上游最多走几跳(默认 2:订单 → 客户 → 部门)
|
|
194
|
+
*/
|
|
195
|
+
export async function focusOf(
|
|
196
|
+
model: string,
|
|
197
|
+
dbId: string,
|
|
198
|
+
maxHops = 2
|
|
199
|
+
): Promise<FocusResult | null> {
|
|
200
|
+
const ent = entityOfModel(model);
|
|
201
|
+
if (!ent) return null;
|
|
202
|
+
|
|
203
|
+
const row = await rowOf(ent.model, dbId);
|
|
204
|
+
if (!row) return null;
|
|
205
|
+
|
|
206
|
+
const sourceKey = `${ent.id}:${dbId}`;
|
|
207
|
+
const nodes = new Map<string, FocusNode>();
|
|
208
|
+
const edges: FocusEdge[] = [];
|
|
209
|
+
const edgeKeys = new Set<string>();
|
|
210
|
+
|
|
211
|
+
const title = String(row[ent.titleField] ?? dbId);
|
|
212
|
+
nodes.set(sourceKey, {
|
|
213
|
+
id: sourceKey,
|
|
214
|
+
dbId,
|
|
215
|
+
entity: ent.id,
|
|
216
|
+
entityLabel: ent.label,
|
|
217
|
+
label: title,
|
|
218
|
+
subtitle: subOf(ent, row),
|
|
219
|
+
role: ent.label,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const addEdge = (a: string, b: string, label: string, relation: string) => {
|
|
223
|
+
if (a === b) return;
|
|
224
|
+
const [lo, hi] = a < b ? [a, b] : [b, a];
|
|
225
|
+
const k = `${relation}|${lo}|${hi}`;
|
|
226
|
+
if (edgeKeys.has(k)) return;
|
|
227
|
+
edgeKeys.add(k);
|
|
228
|
+
edges.push({ source: a, target: b, label, relation });
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
let frontier: { entity: string; id: string }[] = [{ entity: ent.id, id: dbId }];
|
|
232
|
+
|
|
233
|
+
for (let hop = 0; hop < maxHops && frontier.length; hop += 1) {
|
|
234
|
+
const next: { entity: string; id: string }[] = [];
|
|
235
|
+
for (const cur of frontier) {
|
|
236
|
+
const curKey = `${cur.entity}:${cur.id}`;
|
|
237
|
+
const curLayer = layerOf(cur.entity);
|
|
238
|
+
for (const rel of ONTOLOGY.relations) {
|
|
239
|
+
if (rel.from !== cur.entity && rel.to !== cur.entity) continue;
|
|
240
|
+
const nbs = await neighborsOf(cur, rel);
|
|
241
|
+
for (const nb of nbs) {
|
|
242
|
+
// ★ 只往 layer 更小的方向走:业务单据 → 人员/项目 → 部门。
|
|
243
|
+
// 反过来走会把"同一个客户的其它订单""同一部门的其它订单"全拉进来,
|
|
244
|
+
// 高亮就从"一簇"变成"一大片",反而看不出关联。
|
|
245
|
+
if (layerOf(nb.entity) >= curLayer) continue;
|
|
246
|
+
const nbKey = `${nb.entity}:${nb.id}`;
|
|
247
|
+
if (!nodes.has(nbKey)) {
|
|
248
|
+
if (nodes.size >= MAX_FOCUS_NODES) continue;
|
|
249
|
+
const def = defOf(nb.entity);
|
|
250
|
+
nodes.set(nbKey, {
|
|
251
|
+
id: nbKey,
|
|
252
|
+
dbId: nb.id,
|
|
253
|
+
entity: nb.entity,
|
|
254
|
+
entityLabel: def.label,
|
|
255
|
+
label: nb.label,
|
|
256
|
+
subtitle: nb.subtitle,
|
|
257
|
+
role: def.label,
|
|
258
|
+
});
|
|
259
|
+
next.push({ entity: nb.entity, id: nb.id });
|
|
260
|
+
}
|
|
261
|
+
addEdge(curKey, nbKey, rel.label, rel.id);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
frontier = next;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 视图:优先选"能覆盖最多已收集实体"的那个
|
|
269
|
+
const inView = (viewId: string) =>
|
|
270
|
+
ONTOLOGY.views.find((v) => v.id === viewId)?.entities ?? [];
|
|
271
|
+
const candidates = ONTOLOGY.views.filter((v) => inView(v.id).includes(ent.id));
|
|
272
|
+
const view =
|
|
273
|
+
candidates.sort(
|
|
274
|
+
(a, b) =>
|
|
275
|
+
Array.from(nodes.values()).filter((n) => b.entities.includes(n.entity)).length -
|
|
276
|
+
Array.from(nodes.values()).filter((n) => a.entities.includes(n.entity)).length
|
|
277
|
+
)[0] ?? ONTOLOGY.views[0];
|
|
278
|
+
|
|
279
|
+
const multi = nodes.size > 1;
|
|
280
|
+
const list = Array.from(nodes.values());
|
|
281
|
+
const others = list.filter((n) => n.id !== sourceKey);
|
|
282
|
+
|
|
283
|
+
const summary = multi
|
|
284
|
+
? `「${title}」在图谱中关联 ${list.length} 个实体:${list
|
|
285
|
+
.map((n) => `${n.label}(${n.role})`)
|
|
286
|
+
.join('、')}`
|
|
287
|
+
: `「${title}」在图谱中只有 1 个节点,已直接定位到节点详情`;
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
source: { model: ent.model, dbId },
|
|
291
|
+
entity: { id: ent.id, label: ent.label },
|
|
292
|
+
title,
|
|
293
|
+
view: { id: view?.id ?? ONTOLOGY.views[0]?.id ?? '', label: view?.label ?? '关系图' },
|
|
294
|
+
mode: multi ? 'multi' : 'single',
|
|
295
|
+
center: sourceKey,
|
|
296
|
+
nodes: list,
|
|
297
|
+
edges,
|
|
298
|
+
backLink: ent.link ? ent.link.replace(':id', dbId) : null,
|
|
299
|
+
summary: others.length ? summary : `${summary}`,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 业务表 → 知识图谱的链接工具(**纯函数,可被客户端组件引用**)。
|
|
3
|
+
*
|
|
4
|
+
* ★ 为什么单独一个文件:
|
|
5
|
+
* 推导关联簇要查库(prisma + node:fs 读 schema),只能在服务端跑;
|
|
6
|
+
* 而列表页的「关联」按钮只需要拼一个 URL。
|
|
7
|
+
* 两者放同一个模块时,客户端组件 import 一下就会把 prisma 打进浏览器包,
|
|
8
|
+
* 页面直接 500:Module build failed: Reading from "node:fs" is not handled by plugins。
|
|
9
|
+
* 所以:**URL 拼装留在这里,查库的留 graph-focus.ts**。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** 业务页面用的跳转地址:页面只需要传模型名 + 记录 id */
|
|
13
|
+
export function graphHref(model: string, dbId: string, view?: string) {
|
|
14
|
+
const qs = new URLSearchParams({ src: `${model}:${dbId}` });
|
|
15
|
+
if (view) qs.set('view', view);
|
|
16
|
+
return `/graph?${qs.toString()}`;
|
|
17
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { BizError, CODE } from '@/lib/api';
|
|
2
|
+
import { prisma } from '@/lib/prisma';
|
|
3
|
+
import ONTOLOGY from '@/lib/ontology';
|
|
4
|
+
import { deleteWithCleanup } from '@/lib/graph-cascade';
|
|
5
|
+
import { coercePayload, fillRequired, sharedFields } from '@/lib/graph-schema';
|
|
6
|
+
import { hasPerm, PERM_SUPER, type ScopeUser } from '@/lib/rbac';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 图谱直接写库只对超管开放。
|
|
10
|
+
*
|
|
11
|
+
* 业务数据的增删改必须走各自模块的接口 —— 那里才有业务权限码和数据范围校验。
|
|
12
|
+
* 否则任何登录用户都能绕过模块权限,用图谱接口改/删别人部门的单据,
|
|
13
|
+
* 前面在列表、导出、AI 工具上做的行级过滤就白做了。
|
|
14
|
+
* 三个写接口(node 的 POST/PATCH/DELETE、node/move)共用这一个判断。
|
|
15
|
+
*/
|
|
16
|
+
export function requireGraphWriter(user: ScopeUser) {
|
|
17
|
+
if (!hasPerm(user, PERM_SUPER)) {
|
|
18
|
+
throw new BizError(
|
|
19
|
+
'图谱直接写库仅限超级管理员;业务数据请走对应模块的编辑入口',
|
|
20
|
+
CODE.FORBIDDEN,
|
|
21
|
+
403
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 图谱节点的"重"写操作。
|
|
28
|
+
*
|
|
29
|
+
* 放在 lib 里而不是 route 里,有两个原因:
|
|
30
|
+
* 1. Next.js 的 route.ts 只允许导出 HTTP 方法,多导出一个函数会在 build 时报
|
|
31
|
+
* "does not match the required types of a Next.js Route";
|
|
32
|
+
* 2. 「变更类型」不是一次 update —— 是"在目标本体建新记录 + 清掉旧记录",
|
|
33
|
+
* 需要事务式的补偿逻辑,塞在接口里会很难读。
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export type Delegate = {
|
|
37
|
+
create: (a: unknown) => Promise<Record<string, unknown>>;
|
|
38
|
+
update: (a: unknown) => Promise<Record<string, unknown>>;
|
|
39
|
+
delete: (a: unknown) => Promise<Record<string, unknown>>;
|
|
40
|
+
findUnique: (a: unknown) => Promise<Record<string, unknown> | null>;
|
|
41
|
+
findFirst?: (a: unknown) => Promise<Record<string, unknown> | null>;
|
|
42
|
+
count?: (a: unknown) => Promise<number>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function delegateFor(model: string): Delegate {
|
|
46
|
+
const d = (prisma as unknown as Record<string, unknown>)[model];
|
|
47
|
+
if (!d || typeof (d as { create?: unknown }).create !== 'function') {
|
|
48
|
+
throw new BizError(`模型 ${model} 不存在,请检查 ontology.ts`, CODE.BAD_REQUEST);
|
|
49
|
+
}
|
|
50
|
+
return d as Delegate;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 把 Prisma 的错误码翻译成用户能看懂的话(否则前端只能看到 500) */
|
|
54
|
+
export function translatePrismaError(e: unknown): never {
|
|
55
|
+
const code = (e as { code?: string })?.code;
|
|
56
|
+
if (code === 'P2003') {
|
|
57
|
+
throw new BizError(
|
|
58
|
+
'该节点仍被其他数据引用(例如部门下还有员工、角色还绑定着用户)。请先解除关联再操作。',
|
|
59
|
+
CODE.CONFLICT
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (code === 'P2002') {
|
|
63
|
+
throw new BizError('字段值重复(唯一约束冲突),换个名称/编码再试', CODE.CONFLICT);
|
|
64
|
+
}
|
|
65
|
+
if (code === 'P2025') {
|
|
66
|
+
throw new BizError('节点不存在或已被删除', CODE.NOT_FOUND, 404);
|
|
67
|
+
}
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function entityOf(id: string) {
|
|
72
|
+
const e = ONTOLOGY.entities.find((x) => x.id === id);
|
|
73
|
+
if (!e) {
|
|
74
|
+
throw new BizError(
|
|
75
|
+
`${id} 不是已声明的本体。可用:${ONTOLOGY.entities.map((x) => x.id).join(', ')}`,
|
|
76
|
+
CODE.BAD_REQUEST
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return e;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 变更节点类型(迁移本体)。
|
|
84
|
+
*
|
|
85
|
+
* 语义说清楚:节点不是"换了个标签",而是**换了张表**。
|
|
86
|
+
* 所以做法是:目标本体建一条新记录(同名字段自动带过去)→ 按级联规则清掉旧记录。
|
|
87
|
+
* 旧记录被清掉时同样会置空可选外键、连带删除必填外键的依赖行,
|
|
88
|
+
* 前端必须先展示影响清单再让用户确认。
|
|
89
|
+
*
|
|
90
|
+
* 补偿:新记录建好、旧记录清理失败时,把新记录删掉再抛错,
|
|
91
|
+
* 避免留下一个"迁移了一半"的孤儿节点。
|
|
92
|
+
*/
|
|
93
|
+
export async function moveNode(params: {
|
|
94
|
+
fromEntity: string;
|
|
95
|
+
id: string;
|
|
96
|
+
toEntity: string;
|
|
97
|
+
data?: Record<string, unknown>;
|
|
98
|
+
}) {
|
|
99
|
+
const from = entityOf(params.fromEntity);
|
|
100
|
+
const to = entityOf(params.toEntity);
|
|
101
|
+
if (from.id === to.id || from.model === to.model) {
|
|
102
|
+
throw new BizError('目标类型与当前类型相同,不需要变更', CODE.BAD_REQUEST);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const src = await delegateFor(from.model).findUnique({ where: { id: params.id } });
|
|
106
|
+
if (!src) throw new BizError('节点不存在或已被删除', CODE.NOT_FOUND, 404);
|
|
107
|
+
|
|
108
|
+
// 1) 同名字段直接继承(唯一列不继承,否则必然撞唯一约束)
|
|
109
|
+
const shared = sharedFields(from.model, to.model);
|
|
110
|
+
const payload: Record<string, unknown> = {};
|
|
111
|
+
for (const name of shared) {
|
|
112
|
+
const v = src[name];
|
|
113
|
+
if (v !== undefined && v !== null) payload[name] = v;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 2) 用户刚在面板里改过的值覆盖上去(只保留目标模型认识的字段)
|
|
117
|
+
Object.assign(payload, coercePayload(to.model, params.data ?? {}));
|
|
118
|
+
|
|
119
|
+
// 3) 目标本体的必填字段兜底
|
|
120
|
+
await fillRequired(to.model, payload);
|
|
121
|
+
|
|
122
|
+
let created: Record<string, unknown>;
|
|
123
|
+
try {
|
|
124
|
+
created = await delegateFor(to.model).create({ data: payload });
|
|
125
|
+
} catch (e) {
|
|
126
|
+
translatePrismaError(e);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 4) 清掉旧记录(软删模型只置 deletedAt,引用不断)
|
|
130
|
+
let cleanup;
|
|
131
|
+
try {
|
|
132
|
+
cleanup = await deleteWithCleanup(from.id, params.id);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
try {
|
|
135
|
+
await delegateFor(to.model).delete({ where: { id: created.id } });
|
|
136
|
+
} catch {
|
|
137
|
+
// 补偿失败就算了,至少把原始错误抛给用户
|
|
138
|
+
}
|
|
139
|
+
translatePrismaError(e);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
id: String(created.id),
|
|
144
|
+
entity: to.id,
|
|
145
|
+
model: to.model,
|
|
146
|
+
from: { entity: from.id, model: from.model },
|
|
147
|
+
inherited: shared,
|
|
148
|
+
cleanup,
|
|
149
|
+
};
|
|
150
|
+
}
|