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.
- 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,76 @@
|
|
|
1
|
+
import type { Rule } from 'antd/es/form';
|
|
2
|
+
import {
|
|
3
|
+
fieldTypeLabel,
|
|
4
|
+
inferValidationType,
|
|
5
|
+
validateField,
|
|
6
|
+
type ValidationOptions,
|
|
7
|
+
} from '@/lib/validation';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 把校验底座接到 antd Form.Item 上。
|
|
11
|
+
*
|
|
12
|
+
* 用法:
|
|
13
|
+
* <Form.Item name="phone" label="手机号"
|
|
14
|
+
* rules={formRules('phone', { label: '手机号' })}>
|
|
15
|
+
*
|
|
16
|
+
* 字段类型推断得出来时也可以偷懒:
|
|
17
|
+
* <Form.Item name="idCard" label="身份证号" rules={autoFormRules({ name: 'idCard', label: '身份证号' })}>
|
|
18
|
+
*
|
|
19
|
+
* 提示语由底座统一产出(「请输入姓名」「身份证号校验位不正确…」),
|
|
20
|
+
* 页面里不再散落正则和提示文案。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface FormRuleOptions extends Omit<ValidationOptions, 'label'> {
|
|
24
|
+
/** 字段中文名 */
|
|
25
|
+
label?: string;
|
|
26
|
+
/** 字段名,用于自动推断(formRules 不需要,autoFormRules 使用) */
|
|
27
|
+
name?: string;
|
|
28
|
+
/** 追加到内置规则之后的规则 */
|
|
29
|
+
extra?: Rule[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function labelOf(type: string, options: FormRuleOptions): string {
|
|
33
|
+
return options.label?.trim() || fieldTypeLabel(type);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 指定类型 → antd 规则 */
|
|
37
|
+
export function formRules(type: string, options: FormRuleOptions = {}): Rule[] {
|
|
38
|
+
const label = labelOf(type, options);
|
|
39
|
+
const rules: Rule[] = [];
|
|
40
|
+
|
|
41
|
+
// required 规则让 Form.Item 自动显示红星;自定义 validator 负责格式与友好提示
|
|
42
|
+
if (options.required) {
|
|
43
|
+
rules.push({ required: true, whitespace: true, message: `请输入${label}` });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
rules.push({
|
|
47
|
+
validator: (_rule, value) => {
|
|
48
|
+
const result = validateField(type, value, { ...options, label, required: options.required });
|
|
49
|
+
return result.ok ? Promise.resolve() : Promise.reject(new Error(result.message));
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (options.extra?.length) rules.push(...options.extra);
|
|
54
|
+
return rules;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AutoFormRuleOptions extends FormRuleOptions {
|
|
58
|
+
name: string;
|
|
59
|
+
label: string;
|
|
60
|
+
/** 显式指定类型;不传则按 name/label 推断 */
|
|
61
|
+
type?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 按字段名/标签自动选类型。
|
|
66
|
+
* 推断不出来时退化为「只做必填」——不拿一个错误的格式去拦正常输入。
|
|
67
|
+
*/
|
|
68
|
+
export function autoFormRules(options: AutoFormRuleOptions): Rule[] {
|
|
69
|
+
const type = options.type ?? inferValidationType(options.name, options.label);
|
|
70
|
+
if (!type) {
|
|
71
|
+
return options.required
|
|
72
|
+
? [{ required: true, whitespace: true, message: `请输入${options.label}` }]
|
|
73
|
+
: [];
|
|
74
|
+
}
|
|
75
|
+
return formRules(type, options);
|
|
76
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import dayjs, { type Dayjs } from 'dayjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 展示层格式化工具。
|
|
5
|
+
*
|
|
6
|
+
* 为什么要单独抽出来:表格 cell、详情页、导出都会用到同一套格式,
|
|
7
|
+
* 散落在各处会导致「同一个日期在一页显示成 2024-01-01,另一页显示成 2024/1/1」。
|
|
8
|
+
* 统一入口后,改格式只改一个地方。
|
|
9
|
+
*
|
|
10
|
+
* 空值一律返回 '-':表格里 null 直接渲染会出现一片空白,视觉上分不清
|
|
11
|
+
* 「值为空」和「渲染出错」,给个占位符更利于排查。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const EMPTY = '-';
|
|
15
|
+
|
|
16
|
+
type DateInput = string | number | Date | Dayjs | null | undefined;
|
|
17
|
+
|
|
18
|
+
function toDayjs(v: DateInput): Dayjs | null {
|
|
19
|
+
if (v === null || v === undefined || v === '') return null;
|
|
20
|
+
const d = dayjs(v);
|
|
21
|
+
return d.isValid() ? d : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 日期:2024-01-01 */
|
|
25
|
+
export function fmtDate(v: DateInput, pattern = 'YYYY-MM-DD'): string {
|
|
26
|
+
return toDayjs(v)?.format(pattern) ?? EMPTY;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 日期时间:2024-01-01 12:30:00 */
|
|
30
|
+
export function fmtDateTime(v: DateInput): string {
|
|
31
|
+
return fmtDate(v, 'YYYY-MM-DD HH:mm:ss');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 金额:¥12,345.00 */
|
|
35
|
+
export function fmtMoney(v: number | string | null | undefined, prefix = '¥'): string {
|
|
36
|
+
if (v === null || v === undefined || v === '') return EMPTY;
|
|
37
|
+
const n = Number(v);
|
|
38
|
+
if (Number.isNaN(n)) return EMPTY;
|
|
39
|
+
return prefix + n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 把表单/组件里的日期值转成接口参数用的字符串。
|
|
44
|
+
*
|
|
45
|
+
* 注意:这里空值返回空串而不是 '-'。
|
|
46
|
+
* '-' 是「给人看的占位符」,一旦被当作查询参数发到后端,
|
|
47
|
+
* `startDate=-` 会让 dayjs 解析出一个非法日期,反而制造 bug;
|
|
48
|
+
* 空串在 URL 里会被安全忽略。
|
|
49
|
+
*/
|
|
50
|
+
export function parseDateInput(v: DateInput, pattern = 'YYYY-MM-DD'): string {
|
|
51
|
+
return toDayjs(v)?.format(pattern) ?? '';
|
|
52
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { prisma } from '@/lib/prisma';
|
|
4
|
+
import ONTOLOGY from '@/lib/ontology';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 图谱节点删除的级联清理。
|
|
8
|
+
*
|
|
9
|
+
* 设计前提(业务方确认过):
|
|
10
|
+
* 删一个节点**允许连带改动引用它的数据** —— 这是预期行为,不是 bug。
|
|
11
|
+
* 但必须满足两条:
|
|
12
|
+
* 1. 删之前能说清楚"会影响什么"(前端弹二次确认);
|
|
13
|
+
* 2. 删之后**不能留下悬挂外键**(可选外键置空、必填外键连带删除)。
|
|
14
|
+
*
|
|
15
|
+
* 规则:
|
|
16
|
+
* - 可选外键(`deptId String?`)→ 置 null(保留业务行,只解除关联)
|
|
17
|
+
* - 必填外键(`userId String`)→ 连带删除依赖行,并递归处理它的依赖
|
|
18
|
+
* - 自关联(如部门树 parentId)也是可选外键 → 下级提升为顶级,不删子树
|
|
19
|
+
* - 模型有 deletedAt → 只软删目标行,不清理引用(行还在,引用不断)
|
|
20
|
+
*
|
|
21
|
+
* 整个清理在**一个事务**里做完,并在提交前复核"引用已归零",
|
|
22
|
+
* 任何一步失败就整体回滚 —— 不会出现删了一半的中间状态。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type RefInfo = {
|
|
26
|
+
model: string;
|
|
27
|
+
field: string;
|
|
28
|
+
optional: boolean;
|
|
29
|
+
onDelete?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type ImpactAction = {
|
|
33
|
+
kind: 'delete' | 'nullify';
|
|
34
|
+
model: string;
|
|
35
|
+
label: string;
|
|
36
|
+
field: string;
|
|
37
|
+
count: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type Impact = {
|
|
41
|
+
entity: string;
|
|
42
|
+
model: string;
|
|
43
|
+
title: string;
|
|
44
|
+
soft: boolean;
|
|
45
|
+
actions: ImpactAction[];
|
|
46
|
+
deleteCount: number;
|
|
47
|
+
nullifyCount: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
let schemaCache: string | null | undefined;
|
|
51
|
+
|
|
52
|
+
function schemaText(): string | null {
|
|
53
|
+
if (schemaCache === undefined) {
|
|
54
|
+
try {
|
|
55
|
+
schemaCache = readFileSync(join(process.cwd(), 'prisma', 'schema.prisma'), 'utf8');
|
|
56
|
+
} catch {
|
|
57
|
+
schemaCache = null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return schemaCache;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 解析出所有模型的反向引用(谁的外键指向我) */
|
|
64
|
+
let refCache: Map<string, RefInfo[]> | undefined;
|
|
65
|
+
|
|
66
|
+
export function refsOf(targetModel: string): RefInfo[] {
|
|
67
|
+
if (!refCache) {
|
|
68
|
+
refCache = new Map();
|
|
69
|
+
const src = schemaText();
|
|
70
|
+
if (src) {
|
|
71
|
+
for (const block of src.matchAll(/model\s+(\w+)\s*\{([\s\S]*?)\n\}/g)) {
|
|
72
|
+
const [, model, body] = block;
|
|
73
|
+
for (const line of body.split('\n')) {
|
|
74
|
+
const m = line.match(
|
|
75
|
+
/^\s*\w+\s+([A-Za-z_]\w*)(\?)?\s+@relation\(([^)]*)\)/
|
|
76
|
+
);
|
|
77
|
+
if (!m) continue;
|
|
78
|
+
const [, type, opt, args] = m;
|
|
79
|
+
const fields = args.match(/fields:\s*\[(\w+)\]/);
|
|
80
|
+
const references = args.match(/references:\s*\[(\w+)\]/);
|
|
81
|
+
if (!fields || !references || references[1] !== 'id') continue;
|
|
82
|
+
const od = args.match(/onDelete:\s*(\w+)/);
|
|
83
|
+
const list = refCache.get(type) ?? [];
|
|
84
|
+
list.push({
|
|
85
|
+
model,
|
|
86
|
+
field: fields[1],
|
|
87
|
+
optional: !!opt,
|
|
88
|
+
onDelete: od?.[1],
|
|
89
|
+
});
|
|
90
|
+
refCache.set(type, list);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return refCache.get(targetModel) ?? [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function hasSoftDelete(model: string): boolean {
|
|
99
|
+
const src = schemaText();
|
|
100
|
+
if (!src) return false;
|
|
101
|
+
const block = src.match(new RegExp(`model\\s+${model}\\s*\\{([\\s\\S]*?)\\n\\}`));
|
|
102
|
+
return !!block && /\bdeletedAt\b/.test(block[1]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
type Delegate = {
|
|
106
|
+
findMany: (a: unknown) => Promise<Record<string, unknown>[]>;
|
|
107
|
+
findFirst: (a: unknown) => Promise<Record<string, unknown> | null>;
|
|
108
|
+
updateMany: (a: unknown) => Promise<{ count: number }>;
|
|
109
|
+
deleteMany: (a: unknown) => Promise<{ count: number }>;
|
|
110
|
+
delete: (a: unknown) => Promise<Record<string, unknown>>;
|
|
111
|
+
update: (a: unknown) => Promise<Record<string, unknown>>;
|
|
112
|
+
count: (a: unknown) => Promise<number>;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
function delegateOf(client: unknown, model: string): Delegate {
|
|
116
|
+
const d = (client as Record<string, unknown>)[model];
|
|
117
|
+
if (!d || typeof (d as { findMany?: unknown }).findMany !== 'function') {
|
|
118
|
+
throw new Error(`模型 ${model} 不存在,请检查 ontology.ts / schema.prisma`);
|
|
119
|
+
}
|
|
120
|
+
return d as Delegate;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function entityOf(entityId: string) {
|
|
124
|
+
const e = ONTOLOGY.entities.find((x) => x.id === entityId);
|
|
125
|
+
if (!e) throw new Error(`本体 ${entityId} 未在 ontology.ts 中声明`);
|
|
126
|
+
return e;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const MODEL_LABELS: Record<string, string> = {
|
|
130
|
+
User: '账号',
|
|
131
|
+
Department: '部门',
|
|
132
|
+
Employee: '员工',
|
|
133
|
+
Role: '角色',
|
|
134
|
+
Menu: '菜单',
|
|
135
|
+
UserRole: '用户角色关联',
|
|
136
|
+
RoleMenu: '角色菜单关联',
|
|
137
|
+
OperationLog: '操作日志',
|
|
138
|
+
Feedback: '反馈',
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export function labelOfModel(model: string): string {
|
|
142
|
+
return ONTOLOGY.entities.find((e) => e.model === model)?.label ?? MODEL_LABELS[model] ?? model;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 模型有没有 id 主键(UserRole / RoleMenu 是 @@id 复合主键,没有 id 字段) */
|
|
146
|
+
function hasIdField(model: string): boolean {
|
|
147
|
+
const src = schemaText();
|
|
148
|
+
if (!src) return true;
|
|
149
|
+
const block = src.match(new RegExp(`model\\s+${model}\\s*\\{([\\s\\S]*?)\\n\\}`));
|
|
150
|
+
if (!block) return true;
|
|
151
|
+
return /^\s*id\s+\w+/m.test(block[1]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
type PlannedDelete = { model: string; where: Record<string, unknown>; count: number };
|
|
155
|
+
type PlannedNullify = { model: string; field: string; where: Record<string, unknown>; count: number };
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 规划清理动作(只读,不写库)。
|
|
159
|
+
* deletes 的顺序是"依赖先、被依赖后",执行时直接按顺序删即可。
|
|
160
|
+
*/
|
|
161
|
+
async function plan(model: string, id: string) {
|
|
162
|
+
const deletes: PlannedDelete[] = [];
|
|
163
|
+
const nullifies: PlannedNullify[] = [];
|
|
164
|
+
const seen = new Set<string>();
|
|
165
|
+
|
|
166
|
+
async function walk(m: string, rowId: string, depth: number) {
|
|
167
|
+
if (depth > 5) return;
|
|
168
|
+
for (const ref of refsOf(m)) {
|
|
169
|
+
const where = { [ref.field]: rowId };
|
|
170
|
+
const rows = await delegateOf(prisma, ref.model).findMany({
|
|
171
|
+
where,
|
|
172
|
+
select: hasIdField(ref.model) ? { id: true } : { [ref.field]: true },
|
|
173
|
+
});
|
|
174
|
+
if (!rows.length) continue;
|
|
175
|
+
|
|
176
|
+
if (ref.optional) {
|
|
177
|
+
// 可选外键用条件更新(不依赖主键类型),把引用置空即可
|
|
178
|
+
nullifies.push({ model: ref.model, field: ref.field, where, count: rows.length });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 复合主键的关联表(UserRole/RoleMenu)没有 id,也没有下级依赖,直接按条件删
|
|
183
|
+
if (!hasIdField(ref.model)) {
|
|
184
|
+
deletes.push({ model: ref.model, where, count: rows.length });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const r of rows) {
|
|
189
|
+
const rid = String(r.id);
|
|
190
|
+
const key = `${ref.model}:${rid}`;
|
|
191
|
+
if (seen.has(key)) continue;
|
|
192
|
+
seen.add(key);
|
|
193
|
+
// 先处理它的依赖,再删它自己
|
|
194
|
+
await walk(ref.model, rid, depth + 1);
|
|
195
|
+
deletes.push({ model: ref.model, where: { id: rid }, count: 1 });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
await walk(model, id, 0);
|
|
201
|
+
return { deletes, nullifies };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 汇总成"给人看"的列表(同一模型同一字段合并计数) */
|
|
205
|
+
function summarize(model: string, deletes: PlannedDelete[], nullifies: PlannedNullify[]) {
|
|
206
|
+
const map = new Map<string, ImpactAction>();
|
|
207
|
+
const bump = (kind: 'delete' | 'nullify', m: string, field: string, n: number) => {
|
|
208
|
+
const key = `${kind}|${m}|${field}`;
|
|
209
|
+
const hit = map.get(key);
|
|
210
|
+
if (hit) hit.count += n;
|
|
211
|
+
else map.set(key, { kind, model: m, label: labelOfModel(m), field, count: n });
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
for (const n of nullifies) bump('nullify', n.model, n.field, n.count);
|
|
215
|
+
for (const d of deletes) bump('delete', d.model, '', d.count);
|
|
216
|
+
return [...map.values()].sort((a, b) => b.count - a.count);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 预览:这次删除会影响什么(不写库) */
|
|
220
|
+
export async function impactOf(entityId: string, id: string): Promise<Impact> {
|
|
221
|
+
const ent = entityOf(entityId);
|
|
222
|
+
const soft = hasSoftDelete(ent.model);
|
|
223
|
+
const row = await delegateOf(prisma, ent.model).findFirst({ where: { id } });
|
|
224
|
+
const title = row ? String(row[ent.titleField] ?? id) : id;
|
|
225
|
+
|
|
226
|
+
if (soft) {
|
|
227
|
+
return {
|
|
228
|
+
entity: ent.id,
|
|
229
|
+
model: ent.model,
|
|
230
|
+
title,
|
|
231
|
+
soft: true,
|
|
232
|
+
actions: [],
|
|
233
|
+
deleteCount: 0,
|
|
234
|
+
nullifyCount: 0,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const { deletes, nullifies } = await plan(ent.model, id);
|
|
239
|
+
const actions = summarize(ent.model, deletes, nullifies);
|
|
240
|
+
return {
|
|
241
|
+
entity: ent.id,
|
|
242
|
+
model: ent.model,
|
|
243
|
+
title,
|
|
244
|
+
soft: false,
|
|
245
|
+
actions,
|
|
246
|
+
deleteCount: deletes.reduce((s, d) => s + d.count, 0),
|
|
247
|
+
nullifyCount: nullifies.reduce((s, n) => s + n.count, 0),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 执行删除 + 清理(单事务)。
|
|
253
|
+
*
|
|
254
|
+
* 提交前会复核:目标模型的每条反向引用都应归零,
|
|
255
|
+
* 否则抛错回滚 —— 保证"删改彻底、没有引用残留"。
|
|
256
|
+
*/
|
|
257
|
+
export async function deleteWithCleanup(entityId: string, id: string) {
|
|
258
|
+
const ent = entityOf(entityId);
|
|
259
|
+
|
|
260
|
+
if (hasSoftDelete(ent.model)) {
|
|
261
|
+
await delegateOf(prisma, ent.model).update({
|
|
262
|
+
where: { id },
|
|
263
|
+
data: { deletedAt: new Date() },
|
|
264
|
+
});
|
|
265
|
+
return { soft: true, deleted: 1, nullified: 0 };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const { deletes, nullifies } = await plan(ent.model, id);
|
|
269
|
+
|
|
270
|
+
const result = await prisma.$transaction(
|
|
271
|
+
async (tx) => {
|
|
272
|
+
let nullified = 0;
|
|
273
|
+
for (const n of nullifies) {
|
|
274
|
+
const r = await delegateOf(tx, n.model).updateMany({
|
|
275
|
+
where: n.where,
|
|
276
|
+
data: { [n.field]: null },
|
|
277
|
+
});
|
|
278
|
+
nullified += r.count;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let deleted = 0;
|
|
282
|
+
for (const d of deletes) {
|
|
283
|
+
const r = await delegateOf(tx, d.model).deleteMany({ where: d.where });
|
|
284
|
+
deleted += r.count;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
await delegateOf(tx, ent.model).delete({ where: { id } });
|
|
288
|
+
deleted += 1;
|
|
289
|
+
|
|
290
|
+
// 复核:任何一条反向引用都不应再指向已删除的 id
|
|
291
|
+
for (const ref of refsOf(ent.model)) {
|
|
292
|
+
const left = await delegateOf(tx, ref.model).count({ where: { [ref.field]: id } });
|
|
293
|
+
if (left > 0) {
|
|
294
|
+
throw new Error(`清理不彻底:${ref.model}.${ref.field} 还有 ${left} 条引用`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return { soft: false, deleted, nullified };
|
|
299
|
+
},
|
|
300
|
+
{ timeout: 20000 }
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
return result;
|
|
304
|
+
}
|