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,164 @@
|
|
|
1
|
+
import { ok, withRoute, audit, BizError, CODE } from '@/lib/api';
|
|
2
|
+
import { currentUser } from '@/auth';
|
|
3
|
+
import { prisma } from '@/lib/prisma';
|
|
4
|
+
import { aiChat, cleanAiText } from '@/lib/ai';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 「数据 → 报告」生成器 —— 这是整套骨架里最有差异化的一块。
|
|
8
|
+
*
|
|
9
|
+
* 别的队伍在用大模型写代码,你把**大模型用进了产品里**。
|
|
10
|
+
* 评委打开系统看到一个「一键生成报告」,印象分直接拉开差距。
|
|
11
|
+
*
|
|
12
|
+
* 用法(模型接业务时只要调这一个接口):
|
|
13
|
+
* POST /api/ai/report
|
|
14
|
+
* {
|
|
15
|
+
* "table": "dutyLog", // Prisma 模型名(会校验是否真实存在)
|
|
16
|
+
* "title": "值班周报",
|
|
17
|
+
* "dateField": "createdAt", // 用哪个字段做时间过滤(可选)
|
|
18
|
+
* "from": "2026-10-06",
|
|
19
|
+
* "to": "2026-10-12",
|
|
20
|
+
* "focus": "突出处理了什么问题、值班强度、需要注意的风险", // 可选
|
|
21
|
+
* "limit": 200
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* 返回: { report, rows, title, model, generatedAt }
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** 不让调用方碰账号/权限这类敏感表 */
|
|
28
|
+
const BLOCKED_MODELS = new Set(['User', 'Role', 'Menu', 'UserRole', 'RoleMenu']);
|
|
29
|
+
|
|
30
|
+
/** Prisma delegate —— 只用到 findMany */
|
|
31
|
+
type Delegate = { findMany: (args: unknown) => Promise<Record<string, unknown>[]> };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 取某个模型的字段名集合。
|
|
35
|
+
*
|
|
36
|
+
* 为什么要自己取:`deletedAt` 只存在于有软删的表上,
|
|
37
|
+
* 无条件往 where 里塞 `deletedAt: null`,OperationLog / Department 这类表
|
|
38
|
+
* 会直接抛 `Unknown argument`。字段集从 `_runtimeDataModel` 读,最可靠。
|
|
39
|
+
*/
|
|
40
|
+
function fieldsOf(table: string): Set<string> | null {
|
|
41
|
+
const rt = (prisma as unknown as {
|
|
42
|
+
_runtimeDataModel?: {
|
|
43
|
+
models?: Record<string, { fields?: { name: string }[] }>;
|
|
44
|
+
};
|
|
45
|
+
})._runtimeDataModel;
|
|
46
|
+
const fields = rt?.models?.[table]?.fields;
|
|
47
|
+
if (Array.isArray(fields)) return new Set(fields.map((f) => f.name));
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const POST = withRoute(async (req) => {
|
|
52
|
+
const user = await currentUser();
|
|
53
|
+
if (!user) throw new BizError('请先登录', CODE.UNAUTHORIZED, 401);
|
|
54
|
+
|
|
55
|
+
// 注意:NextRequest 的 req.body 是 ReadableStream,不能直接 JSON.parse,必须走 req.json()
|
|
56
|
+
const body = (await req.json().catch(() => ({}))) as {
|
|
57
|
+
table?: string;
|
|
58
|
+
title?: string;
|
|
59
|
+
dateField?: string;
|
|
60
|
+
from?: string;
|
|
61
|
+
to?: string;
|
|
62
|
+
focus?: string;
|
|
63
|
+
limit?: number;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const table = String(body.table ?? '').trim();
|
|
67
|
+
if (!table) throw new BizError('请指定要统计的数据表(table)', CODE.BAD_REQUEST);
|
|
68
|
+
if (BLOCKED_MODELS.has(table)) {
|
|
69
|
+
throw new BizError(`不允许对 ${table} 生成报告`, CODE.FORBIDDEN, 403);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 校验表名真实存在 —— 防拼错、防注入
|
|
73
|
+
const delegate = (prisma as unknown as Record<string, unknown>)[table] as Delegate | undefined;
|
|
74
|
+
if (!delegate || typeof delegate.findMany !== 'function') {
|
|
75
|
+
throw new BizError(`数据表 ${table} 不存在,请检查 Prisma 模型名`, CODE.BAD_REQUEST);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const limit = Math.min(Math.max(Number(body.limit) || 200, 1), 1000);
|
|
79
|
+
const modelFields = fieldsOf(table);
|
|
80
|
+
const has = (f: string) => (modelFields ? modelFields.has(f) : true);
|
|
81
|
+
|
|
82
|
+
const where: Record<string, unknown> = {};
|
|
83
|
+
// deletedAt 只存在于有软删的表上;无条件过滤会让 OperationLog 这类表直接报错
|
|
84
|
+
if (has('deletedAt')) where.deletedAt = null;
|
|
85
|
+
|
|
86
|
+
const dateField = body.dateField ? String(body.dateField) : null;
|
|
87
|
+
if (dateField && !has(dateField)) {
|
|
88
|
+
throw new BizError(`数据表 ${table} 没有字段 ${dateField}`, CODE.BAD_REQUEST);
|
|
89
|
+
}
|
|
90
|
+
if (dateField && (body.from || body.to)) {
|
|
91
|
+
const range: Record<string, Date> = {};
|
|
92
|
+
if (body.from) range.gte = new Date(`${body.from}T00:00:00`);
|
|
93
|
+
if (body.to) range.lte = new Date(`${body.to}T23:59:59`);
|
|
94
|
+
where[dateField] = range;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const rows = await delegate.findMany({ where, take: limit });
|
|
98
|
+
if (!rows.length) {
|
|
99
|
+
throw new BizError(
|
|
100
|
+
'该时间段内没有数据,无法生成报告。请调整日期范围,或先补充演示数据。',
|
|
101
|
+
CODE.BAD_REQUEST
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 把数据压成紧凑 JSON,避免上下文爆掉
|
|
106
|
+
const payload = rows
|
|
107
|
+
.map((r) => {
|
|
108
|
+
const keep: Record<string, unknown> = {};
|
|
109
|
+
for (const [k, v] of Object.entries(r)) {
|
|
110
|
+
if (k === 'password' || k === 'deletedAt') continue;
|
|
111
|
+
if (v instanceof Date) keep[k] = v.toISOString().slice(0, 10);
|
|
112
|
+
else if (typeof v === 'object' && v !== null) keep[k] = '…';
|
|
113
|
+
else keep[k] = v;
|
|
114
|
+
}
|
|
115
|
+
return JSON.stringify(keep);
|
|
116
|
+
})
|
|
117
|
+
.join('\n');
|
|
118
|
+
|
|
119
|
+
const title = String(body.title ?? '业务报告');
|
|
120
|
+
const focus = String(body.focus ?? '客观汇总,突出重点与需要关注的问题');
|
|
121
|
+
|
|
122
|
+
const report = cleanAiText(
|
|
123
|
+
await aiChat({
|
|
124
|
+
system:
|
|
125
|
+
'你是一名严谨的业务分析人员。你会根据给定的真实业务数据写报告,' +
|
|
126
|
+
'不编造数据,不夸大,所有结论都能在数据里找到依据。输出 Markdown 格式中文报告。',
|
|
127
|
+
messages: [
|
|
128
|
+
{
|
|
129
|
+
role: 'user',
|
|
130
|
+
content:
|
|
131
|
+
`请根据下面的数据写一份《${title}》。\n\n` +
|
|
132
|
+
`写作要求:\n` +
|
|
133
|
+
`- ${focus}\n` +
|
|
134
|
+
`- 结构:一段总体概述 + 一个 Markdown 表格(关键指标)+ 2~4 条要点分析 + 下一步建议\n` +
|
|
135
|
+
`- 语言平实,不要套话,不要用"赋能""闭环"这类空词\n` +
|
|
136
|
+
`- 只使用下面出现的数据,不要编造任何数字\n\n` +
|
|
137
|
+
`数据来源表:${table},共 ${rows.length} 条。\n` +
|
|
138
|
+
`${body.from ? `统计区间:${body.from} 至 ${body.to}\n` : ''}\n` +
|
|
139
|
+
`数据如下(每行一条 JSON):\n${payload}`,
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
maxTokens: 1400,
|
|
143
|
+
temperature: 0.3,
|
|
144
|
+
})
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
void audit({
|
|
148
|
+
module: 'AI 报告',
|
|
149
|
+
action: '生成',
|
|
150
|
+
method: req.method,
|
|
151
|
+
path: req.url,
|
|
152
|
+
detail: `表=${table} 行数=${rows.length} 标题=${title}`,
|
|
153
|
+
userId: user.id,
|
|
154
|
+
username: user.username,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return ok({
|
|
158
|
+
report,
|
|
159
|
+
rows: rows.length,
|
|
160
|
+
title,
|
|
161
|
+
model: process.env.AI_MODEL ?? 'qwen3.8-27b-gsq',
|
|
162
|
+
generatedAt: new Date().toISOString(),
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import bcrypt from 'bcryptjs';
|
|
3
|
+
import { BizError, CODE, audit, clientInfo, ok, withRoute } from '@/lib/api';
|
|
4
|
+
import { prisma } from '@/lib/prisma';
|
|
5
|
+
import { requireUser } from '@/auth';
|
|
6
|
+
import { zField } from '@/lib/zodField';
|
|
7
|
+
|
|
8
|
+
export const dynamic = 'force-dynamic';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 修改密码(需登录)。
|
|
12
|
+
*
|
|
13
|
+
* 管理员重置密码后 mustChangePassword = 1,登录会被强制带到 /change-password,
|
|
14
|
+
* 这个接口负责校验原密码、写入新密码并清掉标记。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const schema = z.object({
|
|
18
|
+
oldPassword: z.string().min(1, '请输入原密码'),
|
|
19
|
+
newPassword: zField('password', { label: '新密码' }),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const POST = withRoute(async (req) => {
|
|
23
|
+
const current = await requireUser();
|
|
24
|
+
const parsed = schema.parse(await req.json());
|
|
25
|
+
|
|
26
|
+
const user = await prisma.user.findFirst({ where: { id: current.id, deletedAt: null } });
|
|
27
|
+
if (!user) throw new BizError('账号不存在', CODE.NOT_FOUND);
|
|
28
|
+
if (!(await bcrypt.compare(parsed.oldPassword, user.password))) {
|
|
29
|
+
throw new BizError('原密码不正确', CODE.BAD_REQUEST);
|
|
30
|
+
}
|
|
31
|
+
if (await bcrypt.compare(parsed.newPassword, user.password)) {
|
|
32
|
+
throw new BizError('新密码不能与原密码相同', CODE.BAD_REQUEST);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await prisma.user.update({
|
|
36
|
+
where: { id: user.id },
|
|
37
|
+
data: {
|
|
38
|
+
password: await bcrypt.hash(parsed.newPassword, 10),
|
|
39
|
+
mustChangePassword: 0,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const { ip, userAgent } = clientInfo(req);
|
|
44
|
+
await audit({
|
|
45
|
+
userId: user.id,
|
|
46
|
+
username: user.username,
|
|
47
|
+
module: '账号安全',
|
|
48
|
+
action: '修改密码',
|
|
49
|
+
method: 'POST',
|
|
50
|
+
path: req.nextUrl.pathname,
|
|
51
|
+
targetId: user.id,
|
|
52
|
+
ip,
|
|
53
|
+
userAgent,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return ok(null, '密码修改成功,请使用新密码重新登录');
|
|
57
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import bcrypt from 'bcryptjs';
|
|
3
|
+
import { BizError, CODE, audit, clientInfo, ok, withRoute } from '@/lib/api';
|
|
4
|
+
import { prisma } from '@/lib/prisma';
|
|
5
|
+
import { zField } from '@/lib/zodField';
|
|
6
|
+
|
|
7
|
+
export const dynamic = 'force-dynamic';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 忘记密码 —— 密保问题自助找回(不需要登录)。
|
|
11
|
+
*
|
|
12
|
+
* GET /api/auth/forgot?username=xxx 取该账号的密保问题
|
|
13
|
+
* POST /api/auth/forgot 校验密保答案并设置新密码
|
|
14
|
+
*
|
|
15
|
+
* 答案和密码都只存 bcrypt 哈希。密保答案熵低,加一层内存限流:
|
|
16
|
+
* 同一账号 10 分钟内最多试 5 次,防止被逐个猜。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const MAX_ATTEMPTS = 5;
|
|
20
|
+
const WINDOW_MS = 10 * 60 * 1000;
|
|
21
|
+
const attempts = new Map<string, { count: number; resetAt: number }>();
|
|
22
|
+
|
|
23
|
+
/** 返回 true = 已超限,本次直接拒绝 */
|
|
24
|
+
function hitLimit(key: string): boolean {
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
const rec = attempts.get(key);
|
|
27
|
+
if (!rec || rec.resetAt <= now) {
|
|
28
|
+
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
if (rec.count >= MAX_ATTEMPTS) return true;
|
|
32
|
+
rec.count += 1;
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const GET = withRoute(async (req) => {
|
|
37
|
+
const username = (req.nextUrl.searchParams.get('username') ?? '').trim();
|
|
38
|
+
if (!username) throw new BizError('请输入用户名', CODE.BAD_REQUEST);
|
|
39
|
+
|
|
40
|
+
const user = await prisma.user.findFirst({
|
|
41
|
+
where: { username, deletedAt: null },
|
|
42
|
+
select: { username: true, status: true, securityQuestion: true, securityAnswerHash: true },
|
|
43
|
+
});
|
|
44
|
+
if (!user) throw new BizError('账号不存在,请核对用户名', CODE.NOT_FOUND);
|
|
45
|
+
if (user.status !== 1) throw new BizError('该账号已被停用,请联系管理员', CODE.FORBIDDEN, 403);
|
|
46
|
+
|
|
47
|
+
// 只有问题和答案都在才算「已设置密保」,防止数据残缺把用户卡死
|
|
48
|
+
const question = user.securityQuestion && user.securityAnswerHash ? user.securityQuestion : null;
|
|
49
|
+
return ok({ username: user.username, question });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const resetSchema = z.object({
|
|
53
|
+
username: zField('username', { label: '用户名' }),
|
|
54
|
+
answer: z.string().trim().min(1, '请输入密保答案').max(100, '答案过长'),
|
|
55
|
+
newPassword: zField('password', { label: '新密码' }),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const POST = withRoute(async (req) => {
|
|
59
|
+
const parsed = resetSchema.parse(await req.json());
|
|
60
|
+
const key = parsed.username.toLowerCase();
|
|
61
|
+
|
|
62
|
+
if (hitLimit(key)) {
|
|
63
|
+
throw new BizError('密保答案尝试次数过多,请 10 分钟后再试,或改用人工申诉', CODE.BAD_REQUEST);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const user = await prisma.user.findFirst({ where: { username: parsed.username, deletedAt: null } });
|
|
67
|
+
if (!user) throw new BizError('账号不存在,请核对用户名', CODE.NOT_FOUND);
|
|
68
|
+
if (user.status !== 1) throw new BizError('该账号已被停用,请联系管理员', CODE.FORBIDDEN, 403);
|
|
69
|
+
if (!user.securityQuestion || !user.securityAnswerHash) {
|
|
70
|
+
throw new BizError('该账号未设置密保问题,请在忘记密码页使用「人工申诉」', CODE.BAD_REQUEST);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const matched = await bcrypt.compare(parsed.answer, user.securityAnswerHash);
|
|
74
|
+
if (!matched) throw new BizError('密保答案不正确', CODE.BAD_REQUEST);
|
|
75
|
+
|
|
76
|
+
// 用户自己设置的密码,不需要再强制改密
|
|
77
|
+
await prisma.user.update({
|
|
78
|
+
where: { id: user.id },
|
|
79
|
+
data: { password: await bcrypt.hash(parsed.newPassword, 10), mustChangePassword: 0 },
|
|
80
|
+
});
|
|
81
|
+
attempts.delete(key);
|
|
82
|
+
|
|
83
|
+
const { ip, userAgent } = clientInfo(req);
|
|
84
|
+
await audit({
|
|
85
|
+
userId: user.id,
|
|
86
|
+
username: user.username,
|
|
87
|
+
module: '账号安全',
|
|
88
|
+
action: '密保找回密码',
|
|
89
|
+
method: 'POST',
|
|
90
|
+
path: req.nextUrl.pathname,
|
|
91
|
+
targetId: user.id,
|
|
92
|
+
detail: { username: user.username },
|
|
93
|
+
ip,
|
|
94
|
+
userAgent,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return ok(null, '密码已重置,请使用新密码登录');
|
|
98
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import bcrypt from 'bcryptjs';
|
|
3
|
+
import { BizError, CODE, audit, clientInfo, ok, withRoute } from '@/lib/api';
|
|
4
|
+
import { prisma } from '@/lib/prisma';
|
|
5
|
+
import { isRegistrationEnabled } from '@/lib/system-config';
|
|
6
|
+
import { zField, zNullableField } from '@/lib/zodField';
|
|
7
|
+
|
|
8
|
+
export const dynamic = 'force-dynamic';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 自助注册(不需要登录)。
|
|
12
|
+
*
|
|
13
|
+
* POST /api/auth/register 用户名 + 姓名 + 密码(手机号/邮箱选填)
|
|
14
|
+
*
|
|
15
|
+
* 与「管理员在用户管理页建号」的分工:
|
|
16
|
+
* - 这里只建**裸账号**:启用状态、无部门、无角色、非超管。
|
|
17
|
+
* 能不能用、能用哪些功能,由管理员事后在用户管理里分配角色决定 ——
|
|
18
|
+
* 自动塞一个默认角色看着贴心,反而会让「谁批准了这个账号」说不清。
|
|
19
|
+
* - 用户名唯一性是**全局**的(软删账号仍占名),与管理员建号同一把尺子。
|
|
20
|
+
*
|
|
21
|
+
* 滥用防护:同一 IP 10 分钟内最多 5 次(与忘记密码同一思路,
|
|
22
|
+
* 内存计数,重启清零 —— 防脚本刷号,不防真人手动慢慢注册)。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const MAX_PER_IP = 5;
|
|
26
|
+
const WINDOW_MS = 10 * 60 * 1000;
|
|
27
|
+
const ipHits = new Map<string, { count: number; resetAt: number }>();
|
|
28
|
+
|
|
29
|
+
function overIpLimit(ip: string): boolean {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
const rec = ipHits.get(ip);
|
|
32
|
+
if (!rec || rec.resetAt <= now) {
|
|
33
|
+
ipHits.set(ip, { count: 1, resetAt: now + WINDOW_MS });
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if (rec.count >= MAX_PER_IP) return true;
|
|
37
|
+
rec.count += 1;
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const registerSchema = z.object({
|
|
42
|
+
username: zField('username', { label: '用户名' }),
|
|
43
|
+
password: zField('password', { label: '密码' }),
|
|
44
|
+
name: zField('name', { label: '姓名' }),
|
|
45
|
+
phone: zNullableField('phone', { label: '手机号' }),
|
|
46
|
+
email: zNullableField('email', { label: '邮箱' }),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const POST = withRoute(async (req) => {
|
|
50
|
+
// 开关关着时直接拒:注册入口在登录页是隐藏的,但接口不能假设前端一定听话
|
|
51
|
+
if (!(await isRegistrationEnabled())) {
|
|
52
|
+
throw new BizError('注册功能暂未开放,请联系管理员', CODE.FORBIDDEN, 403);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { ip } = clientInfo(req);
|
|
56
|
+
if (overIpLimit(ip)) {
|
|
57
|
+
// 与忘记密码的限流同一档处理:业务码走 BAD_REQUEST,传输层 429
|
|
58
|
+
throw new BizError('注册尝试过于频繁,请稍后再试', CODE.BAD_REQUEST, 429);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const parsed = registerSchema.parse(await req.json());
|
|
62
|
+
|
|
63
|
+
// 与管理端建号一致:全局唯一(软删账号仍占用用户名),不能带 deletedAt 条件
|
|
64
|
+
const exists = await prisma.user.findUnique({ where: { username: parsed.username } });
|
|
65
|
+
if (exists) throw new BizError('用户名已存在', CODE.CONFLICT);
|
|
66
|
+
|
|
67
|
+
const created = await prisma.user.create({
|
|
68
|
+
data: {
|
|
69
|
+
username: parsed.username,
|
|
70
|
+
password: await bcrypt.hash(parsed.password, 10),
|
|
71
|
+
name: parsed.name,
|
|
72
|
+
phone: parsed.phone || null,
|
|
73
|
+
email: parsed.email || null,
|
|
74
|
+
status: 1,
|
|
75
|
+
isSuper: 0,
|
|
76
|
+
// 不设部门 / 不设角色 / 不设密保:都由管理员在用户管理里补
|
|
77
|
+
},
|
|
78
|
+
select: { id: true, username: true },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await audit({
|
|
82
|
+
userId: created.id,
|
|
83
|
+
username: created.username,
|
|
84
|
+
module: '用户管理',
|
|
85
|
+
action: '注册',
|
|
86
|
+
method: 'POST',
|
|
87
|
+
path: req.nextUrl.pathname,
|
|
88
|
+
targetId: created.id,
|
|
89
|
+
detail: { username: parsed.username, name: parsed.name, source: 'self-service' },
|
|
90
|
+
ip,
|
|
91
|
+
userAgent: clientInfo(req).userAgent,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return ok({ id: created.id, username: created.username }, '注册成功,请使用新账号登录');
|
|
95
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { BizError, CODE, audit, clientInfo, ok, withRoute } from '@/lib/api';
|
|
3
|
+
import { requireUser } from '@/auth';
|
|
4
|
+
import { isRegistrationEnabled, setRegistrationEnabled } from '@/lib/system-config';
|
|
5
|
+
|
|
6
|
+
export const dynamic = 'force-dynamic';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 注册开关。
|
|
10
|
+
*
|
|
11
|
+
* GET /api/auth/register-config 读开关(公开)—— 登录页要依它决定显不显示注册入口,
|
|
12
|
+
* 注册页自身也要依它决定显示表单还是「未开放」
|
|
13
|
+
* PUT /api/auth/register-config 切开关(**仅超级管理员**)—— 用户管理页的注册设置卡片
|
|
14
|
+
*
|
|
15
|
+
* 为什么 GET 公开:开关本身不是敏感信息(登录页人人都要看),
|
|
16
|
+
* 敏感的是「谁能改」。改的口子只认 isSuper,不新增权限码 ——
|
|
17
|
+
* 权限码是给角色授权用的,而这个开关按需求只归超管。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const toggleSchema = z.object({
|
|
21
|
+
enabled: z.boolean(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const GET = withRoute(async () => {
|
|
25
|
+
return ok({ enabled: await isRegistrationEnabled() });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const PUT = withRoute(async (req) => {
|
|
29
|
+
const user = await requireUser();
|
|
30
|
+
if (!user.isSuper) {
|
|
31
|
+
throw new BizError('只有超级管理员可以修改注册开关', CODE.FORBIDDEN, 403);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const parsed = toggleSchema.parse(await req.json());
|
|
35
|
+
await setRegistrationEnabled(parsed.enabled);
|
|
36
|
+
|
|
37
|
+
const { ip, userAgent } = clientInfo(req);
|
|
38
|
+
await audit({
|
|
39
|
+
userId: user.id,
|
|
40
|
+
username: user.username,
|
|
41
|
+
module: '用户管理',
|
|
42
|
+
action: '修改注册开关',
|
|
43
|
+
method: 'PUT',
|
|
44
|
+
path: req.nextUrl.pathname,
|
|
45
|
+
detail: { enabled: parsed.enabled },
|
|
46
|
+
ip,
|
|
47
|
+
userAgent,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return ok({ enabled: parsed.enabled }, parsed.enabled ? '注册功能已开放' : '注册功能已关闭');
|
|
51
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ok, withRoute } from '@/lib/api';
|
|
3
|
+
import { requireUser } from '@/auth';
|
|
4
|
+
import { getUserMenuTree } from '@/lib/menu-service';
|
|
5
|
+
import { ackBadge, collectBadges } from '@/lib/badge-count';
|
|
6
|
+
|
|
7
|
+
export const dynamic = 'force-dynamic';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 侧边栏徽标接口。
|
|
11
|
+
*
|
|
12
|
+
* GET /api/badges → { '/feedback': 3 } 当前用户所有菜单徽标
|
|
13
|
+
* POST /api/badges {path} → 同上,但先把该页标记为已读再返回
|
|
14
|
+
*
|
|
15
|
+
* 为什么不建 SSE / WebSocket 推送:
|
|
16
|
+
* 待办数字的变化频率是以"分钟"计的,不是聊天消息。60 秒轮询 + 回到前台刷新,
|
|
17
|
+
* 代价是一个几乎不耗资源的 count 查询,换来的是"断线、多标签页、休眠唤醒"
|
|
18
|
+
* 全都不用额外处理。这套骨架的定位是比赛演示与内网部署,简单可靠优先。
|
|
19
|
+
*
|
|
20
|
+
* 为什么 path 而不是 menuId:菜单重跑 seed 会换 id,path 稳定;
|
|
21
|
+
* 而且前端菜单树里天然有 path,不用为徽标额外传一份菜单 id 映射。
|
|
22
|
+
*/
|
|
23
|
+
export const GET = withRoute(async () => {
|
|
24
|
+
const user = await requireUser();
|
|
25
|
+
const menus = await getUserMenuTree(user);
|
|
26
|
+
return ok(await collectBadges(user, menus));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const ackSchema = z.object({
|
|
30
|
+
path: z.string().trim().min(1, '缺少 path'),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** 进入某页面后调用:把"未读"类计数清掉,并返回最新计数 */
|
|
34
|
+
export const POST = withRoute(async (req) => {
|
|
35
|
+
const user = await requireUser();
|
|
36
|
+
const { path } = ackSchema.parse(await req.json().catch(() => ({})));
|
|
37
|
+
const menus = await getUserMenuTree(user);
|
|
38
|
+
return ok(await ackBadge(user, menus, path));
|
|
39
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { BizError, CODE, audit, clientInfo, ok, withRoute } from '@/lib/api';
|
|
3
|
+
import { prisma } from '@/lib/prisma';
|
|
4
|
+
import { requireUser } from '@/auth';
|
|
5
|
+
import { P, requirePerm } from '@/lib/rbac';
|
|
6
|
+
|
|
7
|
+
export const dynamic = 'force-dynamic';
|
|
8
|
+
|
|
9
|
+
const deptSchema = z.object({
|
|
10
|
+
name: z.string().trim().min(1, '部门名称必填').max(32),
|
|
11
|
+
code: z
|
|
12
|
+
.string()
|
|
13
|
+
.trim()
|
|
14
|
+
.min(2, '部门编码至少 2 个字符')
|
|
15
|
+
.max(32)
|
|
16
|
+
.regex(/^[A-Za-z][A-Za-z0-9_-]*$/, '编码需以字母开头,仅含字母/数字/下划线/短横线'),
|
|
17
|
+
parentId: z.string().trim().nullish(),
|
|
18
|
+
sort: z.coerce.number().int().min(0).default(0),
|
|
19
|
+
status: z.coerce.number().int().min(0).max(1).default(1),
|
|
20
|
+
remark: z.string().max(200).nullish(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
type Ctx = { params: Promise<{ id: string }> };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 判断 candidate 是不是 id 的后代(用于禁止把父级设成自己的子孙,否则树会成环)
|
|
27
|
+
*/
|
|
28
|
+
async function isDescendant(id: string, candidate: string) {
|
|
29
|
+
let cur: string | null = candidate;
|
|
30
|
+
const guard = new Set<string>();
|
|
31
|
+
while (cur) {
|
|
32
|
+
if (cur === id) return true;
|
|
33
|
+
if (guard.has(cur)) return true; // 脏数据成环时直接当作是,拒绝操作
|
|
34
|
+
guard.add(cur);
|
|
35
|
+
const row: { parentId: string | null } | null = await prisma.department.findUnique({
|
|
36
|
+
where: { id: cur },
|
|
37
|
+
select: { parentId: true },
|
|
38
|
+
});
|
|
39
|
+
cur = row?.parentId ?? null;
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** PUT /api/depts/[id] —— 更新部门 */
|
|
45
|
+
export const PUT = withRoute<Ctx>(async (req, ctx) => {
|
|
46
|
+
const user = await requireUser();
|
|
47
|
+
requirePerm(user, P.deptEdit);
|
|
48
|
+
|
|
49
|
+
const { id } = await ctx.params;
|
|
50
|
+
const exist = await prisma.department.findUnique({ where: { id } });
|
|
51
|
+
if (!exist) throw new BizError('部门不存在', CODE.NOT_FOUND);
|
|
52
|
+
|
|
53
|
+
const parsed = deptSchema.parse(await req.json());
|
|
54
|
+
const parentId = parsed.parentId || null;
|
|
55
|
+
|
|
56
|
+
if (parentId) {
|
|
57
|
+
if (parentId === id) throw new BizError('上级部门不能是自己');
|
|
58
|
+
if (await isDescendant(id, parentId)) {
|
|
59
|
+
throw new BizError('上级部门不能是自己的下级,否则会形成循环');
|
|
60
|
+
}
|
|
61
|
+
const parent = await prisma.department.findUnique({
|
|
62
|
+
where: { id: parentId },
|
|
63
|
+
select: { id: true },
|
|
64
|
+
});
|
|
65
|
+
if (!parent) throw new BizError('上级部门不存在');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const dup = await prisma.department.findFirst({ where: { code: parsed.code, NOT: { id } } });
|
|
69
|
+
if (dup) throw new BizError('部门编码已存在', CODE.CONFLICT);
|
|
70
|
+
|
|
71
|
+
await prisma.department.update({
|
|
72
|
+
where: { id },
|
|
73
|
+
data: {
|
|
74
|
+
name: parsed.name,
|
|
75
|
+
code: parsed.code,
|
|
76
|
+
parentId,
|
|
77
|
+
sort: parsed.sort,
|
|
78
|
+
status: parsed.status,
|
|
79
|
+
remark: parsed.remark || null,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const { ip, userAgent } = clientInfo(req);
|
|
84
|
+
await audit({
|
|
85
|
+
userId: user.id,
|
|
86
|
+
username: user.username,
|
|
87
|
+
module: '部门管理',
|
|
88
|
+
action: '修改',
|
|
89
|
+
method: 'PUT',
|
|
90
|
+
path: req.nextUrl.pathname,
|
|
91
|
+
targetId: id,
|
|
92
|
+
detail: { code: parsed.code, name: parsed.name, parentId },
|
|
93
|
+
ip,
|
|
94
|
+
userAgent,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return ok({ id }, '保存成功');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* DELETE /api/depts/[id] —— 删除部门
|
|
102
|
+
*
|
|
103
|
+
* 有子部门 / 有用户 / 有员工时**不允许删**:
|
|
104
|
+
* 直接删会把用户和员工的外键置空,形成"孤儿数据",比报错难查得多。
|
|
105
|
+
*/
|
|
106
|
+
export const DELETE = withRoute<Ctx>(async (req, ctx) => {
|
|
107
|
+
const user = await requireUser();
|
|
108
|
+
requirePerm(user, P.deptDel);
|
|
109
|
+
|
|
110
|
+
const { id } = await ctx.params;
|
|
111
|
+
const exist = await prisma.department.findUnique({
|
|
112
|
+
where: { id },
|
|
113
|
+
include: { _count: { select: { children: true, users: true, employees: true } } },
|
|
114
|
+
});
|
|
115
|
+
if (!exist) throw new BizError('部门不存在', CODE.NOT_FOUND);
|
|
116
|
+
|
|
117
|
+
const { children, users, employees } = exist._count;
|
|
118
|
+
if (children > 0) {
|
|
119
|
+
throw new BizError(`该部门下还有 ${children} 个子部门,请先删除或移走子部门`);
|
|
120
|
+
}
|
|
121
|
+
if (users > 0 || employees > 0) {
|
|
122
|
+
const parts: string[] = [];
|
|
123
|
+
if (users) parts.push(`${users} 个用户`);
|
|
124
|
+
if (employees) parts.push(`${employees} 名员工`);
|
|
125
|
+
throw new BizError(`该部门下还有 ${parts.join('、')},请先调整归属再删除`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
await prisma.department.delete({ where: { id } });
|
|
129
|
+
|
|
130
|
+
const { ip, userAgent } = clientInfo(req);
|
|
131
|
+
await audit({
|
|
132
|
+
userId: user.id,
|
|
133
|
+
username: user.username,
|
|
134
|
+
module: '部门管理',
|
|
135
|
+
action: '删除',
|
|
136
|
+
method: 'DELETE',
|
|
137
|
+
path: req.nextUrl.pathname,
|
|
138
|
+
targetId: id,
|
|
139
|
+
detail: { code: exist.code, name: exist.name },
|
|
140
|
+
ip,
|
|
141
|
+
userAgent,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return ok({ id }, '已删除');
|
|
145
|
+
});
|