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,205 @@
|
|
|
1
|
+
import dayjs from 'dayjs';
|
|
2
|
+
import { BizError, audit, clientInfo, ok, withRoute } from '@/lib/api';
|
|
3
|
+
import { prisma } from '@/lib/prisma';
|
|
4
|
+
import { requireUser } from '@/auth';
|
|
5
|
+
import { P, deptAllowed, requirePerm } from '@/lib/rbac';
|
|
6
|
+
import { parseExcel } from '@/lib/excel';
|
|
7
|
+
import { readUpload } from '@/lib/upload';
|
|
8
|
+
import { validateField } from '@/lib/validation';
|
|
9
|
+
|
|
10
|
+
export const dynamic = 'force-dynamic';
|
|
11
|
+
|
|
12
|
+
/** 表头别名 → 内部字段。中英文都认,用户从别处导出的模板也能直接导入 */
|
|
13
|
+
const HEADER_MAP: Record<string, string> = {
|
|
14
|
+
工号: 'code',
|
|
15
|
+
code: 'code',
|
|
16
|
+
编号: 'code',
|
|
17
|
+
姓名: 'name',
|
|
18
|
+
name: 'name',
|
|
19
|
+
身份证号: 'idCard',
|
|
20
|
+
身份证: 'idCard',
|
|
21
|
+
证件号: 'idCard',
|
|
22
|
+
idcard: 'idCard',
|
|
23
|
+
idCard: 'idCard',
|
|
24
|
+
idno: 'idCard',
|
|
25
|
+
性别: 'gender',
|
|
26
|
+
gender: 'gender',
|
|
27
|
+
部门: 'deptName',
|
|
28
|
+
dept: 'deptName',
|
|
29
|
+
部门名称: 'deptName',
|
|
30
|
+
职位: 'position',
|
|
31
|
+
岗位: 'position',
|
|
32
|
+
position: 'position',
|
|
33
|
+
手机号: 'phone',
|
|
34
|
+
手机: 'phone',
|
|
35
|
+
phone: 'phone',
|
|
36
|
+
邮箱: 'email',
|
|
37
|
+
email: 'email',
|
|
38
|
+
入职日期: 'hireDate',
|
|
39
|
+
入职时间: 'hireDate',
|
|
40
|
+
hireDate: 'hireDate',
|
|
41
|
+
月薪: 'salary',
|
|
42
|
+
薪资: 'salary',
|
|
43
|
+
salary: 'salary',
|
|
44
|
+
状态: 'status',
|
|
45
|
+
status: 'status',
|
|
46
|
+
备注: 'remark',
|
|
47
|
+
remark: 'remark',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function isFile(v: unknown): v is File {
|
|
51
|
+
return typeof v === 'object' && v !== null && typeof (v as File).arrayBuffer === 'function';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function pick(row: Record<string, string>, headers: string[], field: string): string {
|
|
55
|
+
for (const h of headers) {
|
|
56
|
+
if (HEADER_MAP[h.replace(/\s/g, '')] === field) return (row[h] ?? '').trim();
|
|
57
|
+
}
|
|
58
|
+
return '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function toGender(v: string): 'male' | 'female' {
|
|
62
|
+
const s = v.trim().toLowerCase();
|
|
63
|
+
if (s === '女' || s === 'female' || s === 'f' || s === '2') return 'female';
|
|
64
|
+
return 'male';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function toStatus(v: string): number {
|
|
68
|
+
const s = v.trim().toLowerCase();
|
|
69
|
+
if (!s) return 1;
|
|
70
|
+
if (['离职', '0', 'false', '在职-否', 'resigned'].includes(s)) return 0;
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* POST /api/employees/import —— Excel 批量导入。
|
|
76
|
+
*
|
|
77
|
+
* 逐行处理而不是整批事务:一行脏数据不该让 499 行正常的也导不进去。
|
|
78
|
+
* 失败的行走 failed[] 返回给前端展示「第几行、什么原因」。
|
|
79
|
+
*/
|
|
80
|
+
export const POST = withRoute(async (req) => {
|
|
81
|
+
const user = await requireUser();
|
|
82
|
+
requirePerm(user, P.empImport);
|
|
83
|
+
|
|
84
|
+
const form = await req.formData();
|
|
85
|
+
const file = form.get('file');
|
|
86
|
+
if (!isFile(file)) throw new BizError('请上传 Excel 文件(字段名 file)');
|
|
87
|
+
|
|
88
|
+
const { filename, buffer } = await readUpload(file);
|
|
89
|
+
if (!/\.xlsx?$/i.test(filename)) {
|
|
90
|
+
throw new BizError('仅支持 .xlsx 文件(.xls 请先用 Excel 另存为 .xlsx)');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const { headers, rows } = await parseExcel(buffer, { headerRow: 1 });
|
|
94
|
+
if (!rows.length) throw new BizError('未从文件中解析到任何数据行');
|
|
95
|
+
if (rows.length > 5000) throw new BizError('单次导入最多 5000 行,请拆分后再试');
|
|
96
|
+
|
|
97
|
+
const depts = await prisma.department.findMany({ select: { id: true, name: true } });
|
|
98
|
+
const deptIdByName = new Map(depts.map((d) => [d.name.trim(), d.id]));
|
|
99
|
+
|
|
100
|
+
let inserted = 0;
|
|
101
|
+
let updated = 0;
|
|
102
|
+
const failed: { row: number; reason: string }[] = [];
|
|
103
|
+
|
|
104
|
+
for (let i = 0; i < rows.length; i++) {
|
|
105
|
+
const rowNo = i + 2; // +2:第 1 行是表头
|
|
106
|
+
const row = rows[i];
|
|
107
|
+
try {
|
|
108
|
+
// 逐列走统一校验底座:哪一格不合法,就把「第几行 + 哪一列 + 为什么」还给用户
|
|
109
|
+
const codeCheck = validateField('code', pick(row, headers, 'code'), {
|
|
110
|
+
label: '工号',
|
|
111
|
+
required: true,
|
|
112
|
+
});
|
|
113
|
+
if (!codeCheck.ok) throw new Error(codeCheck.message);
|
|
114
|
+
const code = codeCheck.value;
|
|
115
|
+
|
|
116
|
+
const nameCheck = validateField('name', pick(row, headers, 'name'), {
|
|
117
|
+
label: '姓名',
|
|
118
|
+
required: true,
|
|
119
|
+
});
|
|
120
|
+
if (!nameCheck.ok) throw new Error(nameCheck.message);
|
|
121
|
+
const name = nameCheck.value;
|
|
122
|
+
|
|
123
|
+
const idCardCheck = validateField('idCard', pick(row, headers, 'idCard'), {
|
|
124
|
+
label: '身份证号',
|
|
125
|
+
});
|
|
126
|
+
if (!idCardCheck.ok) throw new Error(idCardCheck.message);
|
|
127
|
+
|
|
128
|
+
const phoneCheck = validateField('phone', pick(row, headers, 'phone'), {
|
|
129
|
+
label: '手机号',
|
|
130
|
+
});
|
|
131
|
+
if (!phoneCheck.ok) throw new Error(phoneCheck.message);
|
|
132
|
+
|
|
133
|
+
const emailCheck = validateField('email', pick(row, headers, 'email'), {
|
|
134
|
+
label: '邮箱',
|
|
135
|
+
});
|
|
136
|
+
if (!emailCheck.ok) throw new Error(emailCheck.message);
|
|
137
|
+
|
|
138
|
+
const deptName = pick(row, headers, 'deptName');
|
|
139
|
+
const salaryRaw = pick(row, headers, 'salary');
|
|
140
|
+
const hireRaw = pick(row, headers, 'hireDate');
|
|
141
|
+
|
|
142
|
+
const hireCheck = validateField('date', hireRaw, { label: '入职日期' });
|
|
143
|
+
if (!hireCheck.ok) throw new Error(hireCheck.message);
|
|
144
|
+
|
|
145
|
+
const salaryCheck = validateField('amount', salaryRaw, { label: '月薪' });
|
|
146
|
+
if (!salaryCheck.ok) throw new Error(salaryCheck.message);
|
|
147
|
+
|
|
148
|
+
const salary = salaryCheck.value ? Number(salaryCheck.value) : null;
|
|
149
|
+
const hireDate = hireCheck.value ? dayjs(hireCheck.value).toDate() : null;
|
|
150
|
+
|
|
151
|
+
const targetDept = deptName ? deptIdByName.get(deptName) ?? null : null;
|
|
152
|
+
// 数据范围:不能借导入把员工写到别的部门去(读能过滤只是看不到,写不校验就是换个入口照样改)
|
|
153
|
+
if (!deptAllowed(user, targetDept)) {
|
|
154
|
+
throw new Error(`部门「${deptName}」不在你的数据范围内`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const payload = {
|
|
158
|
+
name,
|
|
159
|
+
idCard: idCardCheck.value || null,
|
|
160
|
+
gender: toGender(pick(row, headers, 'gender')),
|
|
161
|
+
deptId: deptName ? deptIdByName.get(deptName) ?? null : null,
|
|
162
|
+
position: pick(row, headers, 'position') || null,
|
|
163
|
+
phone: phoneCheck.value || null,
|
|
164
|
+
email: emailCheck.value || null,
|
|
165
|
+
hireDate,
|
|
166
|
+
salary,
|
|
167
|
+
status: toStatus(pick(row, headers, 'status')),
|
|
168
|
+
remark: pick(row, headers, 'remark') || null,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const exist = await prisma.employee.findUnique({ where: { code } });
|
|
172
|
+
if (exist) {
|
|
173
|
+
// 软删过的同工号记录按「恢复并覆盖」处理,否则 unique 约束会一直挡住重新导入
|
|
174
|
+
await prisma.employee.update({
|
|
175
|
+
where: { code },
|
|
176
|
+
data: { ...payload, deletedAt: null },
|
|
177
|
+
});
|
|
178
|
+
updated++;
|
|
179
|
+
} else {
|
|
180
|
+
await prisma.employee.create({ data: { code, ...payload } });
|
|
181
|
+
inserted++;
|
|
182
|
+
}
|
|
183
|
+
} catch (e) {
|
|
184
|
+
failed.push({ row: rowNo, reason: (e as Error).message || '未知错误' });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const { ip, userAgent } = clientInfo(req);
|
|
189
|
+
await audit({
|
|
190
|
+
userId: user.id,
|
|
191
|
+
username: user.username,
|
|
192
|
+
module: '员工管理',
|
|
193
|
+
action: '导入',
|
|
194
|
+
method: 'POST',
|
|
195
|
+
path: req.nextUrl.pathname,
|
|
196
|
+
detail: { inserted, updated, failed: failed.length, filename },
|
|
197
|
+
ip,
|
|
198
|
+
userAgent,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return ok(
|
|
202
|
+
{ inserted, updated, failed },
|
|
203
|
+
`导入完成:新增 ${inserted} 条,更新 ${updated} 条${failed.length ? `,失败 ${failed.length} 条` : ''}`
|
|
204
|
+
);
|
|
205
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { Prisma } from '@prisma/client';
|
|
3
|
+
import dayjs from 'dayjs';
|
|
4
|
+
import {
|
|
5
|
+
BizError,
|
|
6
|
+
CODE,
|
|
7
|
+
audit,
|
|
8
|
+
clientInfo,
|
|
9
|
+
ok,
|
|
10
|
+
pageResult,
|
|
11
|
+
parseListQuery,
|
|
12
|
+
withRoute,
|
|
13
|
+
} from '@/lib/api';
|
|
14
|
+
import { prisma } from '@/lib/prisma';
|
|
15
|
+
import { requireUser } from '@/auth';
|
|
16
|
+
import { P, deptScope, requirePerm } from '@/lib/rbac';
|
|
17
|
+
import { zField, zNullableField } from '@/lib/zodField';
|
|
18
|
+
|
|
19
|
+
export const dynamic = 'force-dynamic';
|
|
20
|
+
|
|
21
|
+
const employeeSelect = {
|
|
22
|
+
id: true,
|
|
23
|
+
code: true,
|
|
24
|
+
name: true,
|
|
25
|
+
idCard: true,
|
|
26
|
+
gender: true,
|
|
27
|
+
deptId: true,
|
|
28
|
+
position: true,
|
|
29
|
+
phone: true,
|
|
30
|
+
email: true,
|
|
31
|
+
hireDate: true,
|
|
32
|
+
salary: true,
|
|
33
|
+
status: true,
|
|
34
|
+
remark: true,
|
|
35
|
+
createdAt: true,
|
|
36
|
+
updatedAt: true,
|
|
37
|
+
dept: { select: { id: true, name: true } },
|
|
38
|
+
} satisfies Prisma.EmployeeSelect;
|
|
39
|
+
|
|
40
|
+
/** 员工写入校验(route 文件不能导出额外符号,故不 export) */
|
|
41
|
+
const employeeSchema = z.object({
|
|
42
|
+
code: zField('code', { label: '工号' }),
|
|
43
|
+
name: zField('name', { label: '姓名' }),
|
|
44
|
+
idCard: zNullableField('idCard', { label: '身份证号' }),
|
|
45
|
+
gender: z.enum(['male', 'female']).default('male'),
|
|
46
|
+
deptId: z.string().nullish(),
|
|
47
|
+
position: z.string().trim().max(64).nullish(),
|
|
48
|
+
phone: zNullableField('phone', { label: '手机号' }),
|
|
49
|
+
email: zNullableField('email', { label: '邮箱' }),
|
|
50
|
+
hireDate: zNullableField('date', { label: '入职日期' }),
|
|
51
|
+
// InputNumber 清空后是 null;用 union 而不是 coerce.number,避免 null 被强转成 0
|
|
52
|
+
salary: z.union([z.number(), z.null()]).optional(),
|
|
53
|
+
status: z.coerce.number().int().min(0).max(1).default(1),
|
|
54
|
+
remark: z.string().max(200).nullish(),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** 日期字符串 → Date,非法值按空处理(表单里清空日期很常见,不该报错) */
|
|
58
|
+
function toDate(v?: string | null): Date | null {
|
|
59
|
+
if (!v) return null;
|
|
60
|
+
const d = dayjs(v);
|
|
61
|
+
return d.isValid() ? d.toDate() : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** GET /api/employees —— 分页列表 */
|
|
65
|
+
export const GET = withRoute(async (req) => {
|
|
66
|
+
const user = await requireUser();
|
|
67
|
+
requirePerm(user, P.empList);
|
|
68
|
+
|
|
69
|
+
const q = parseListQuery(req, { createdAt: 'desc' });
|
|
70
|
+
const sp = req.nextUrl.searchParams;
|
|
71
|
+
const status = sp.get('status');
|
|
72
|
+
const deptId = sp.get('deptId');
|
|
73
|
+
const gender = sp.get('gender');
|
|
74
|
+
|
|
75
|
+
const where: Prisma.EmployeeWhereInput = { deletedAt: null };
|
|
76
|
+
if (q.keyword) {
|
|
77
|
+
where.OR = [
|
|
78
|
+
{ code: { contains: q.keyword } },
|
|
79
|
+
{ name: { contains: q.keyword } },
|
|
80
|
+
{ idCard: { contains: q.keyword } },
|
|
81
|
+
{ phone: { contains: q.keyword } },
|
|
82
|
+
{ email: { contains: q.keyword } },
|
|
83
|
+
{ position: { contains: q.keyword } },
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
if (status === '0' || status === '1') where.status = Number(status);
|
|
87
|
+
if (deptId) where.deptId = deptId;
|
|
88
|
+
if (gender === 'male' || gender === 'female') where.gender = gender;
|
|
89
|
+
|
|
90
|
+
// 数据范围:必须放在所有筛选之后合并,否则用户传 ?deptId= 就能筛出别人的数据
|
|
91
|
+
Object.assign(where, deptScope(user));
|
|
92
|
+
|
|
93
|
+
const [list, total] = await Promise.all([
|
|
94
|
+
prisma.employee.findMany({
|
|
95
|
+
where,
|
|
96
|
+
skip: q.skip,
|
|
97
|
+
take: q.take,
|
|
98
|
+
orderBy: q.orderBy as Prisma.EmployeeOrderByWithRelationInput,
|
|
99
|
+
select: employeeSelect,
|
|
100
|
+
}),
|
|
101
|
+
prisma.employee.count({ where }),
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
return ok(pageResult(list, total, q));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/** POST /api/employees —— 新建员工 */
|
|
108
|
+
export const POST = withRoute(async (req) => {
|
|
109
|
+
const user = await requireUser();
|
|
110
|
+
requirePerm(user, P.empAdd);
|
|
111
|
+
|
|
112
|
+
const parsed = employeeSchema.parse(await req.json());
|
|
113
|
+
|
|
114
|
+
const exists = await prisma.employee.findUnique({ where: { code: parsed.code } });
|
|
115
|
+
if (exists) throw new BizError('工号已存在', CODE.CONFLICT);
|
|
116
|
+
|
|
117
|
+
const created = await prisma.employee.create({
|
|
118
|
+
data: {
|
|
119
|
+
code: parsed.code,
|
|
120
|
+
name: parsed.name,
|
|
121
|
+
idCard: parsed.idCard || null,
|
|
122
|
+
gender: parsed.gender,
|
|
123
|
+
deptId: parsed.deptId || null,
|
|
124
|
+
position: parsed.position || null,
|
|
125
|
+
phone: parsed.phone || null,
|
|
126
|
+
email: parsed.email || null,
|
|
127
|
+
hireDate: toDate(parsed.hireDate),
|
|
128
|
+
salary: parsed.salary ?? null,
|
|
129
|
+
status: parsed.status,
|
|
130
|
+
remark: parsed.remark || null,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const { ip, userAgent } = clientInfo(req);
|
|
135
|
+
await audit({
|
|
136
|
+
userId: user.id,
|
|
137
|
+
username: user.username,
|
|
138
|
+
module: '员工管理',
|
|
139
|
+
action: '新增',
|
|
140
|
+
method: 'POST',
|
|
141
|
+
path: req.nextUrl.pathname,
|
|
142
|
+
targetId: created.id,
|
|
143
|
+
detail: { code: parsed.code, name: parsed.name },
|
|
144
|
+
ip,
|
|
145
|
+
userAgent,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return ok({ id: created.id }, '新增成功');
|
|
149
|
+
});
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { Prisma } from '@prisma/client';
|
|
2
|
+
import dayjs from 'dayjs';
|
|
3
|
+
import { BizError, audit, clientInfo, withRoute } from '@/lib/api';
|
|
4
|
+
import { prisma } from '@/lib/prisma';
|
|
5
|
+
import { requireUser } from '@/auth';
|
|
6
|
+
import { P, DATA_SCOPE, deptScope, effectiveDataScope, requirePerm } from '@/lib/rbac';
|
|
7
|
+
import { buildExcelResponse, exportToExcel, type ExcelColumn } from '@/lib/excel';
|
|
8
|
+
|
|
9
|
+
export const dynamic = 'force-dynamic';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* GET /api/export?module=xxx —— 通用导出入口。
|
|
13
|
+
*
|
|
14
|
+
* 列表页 DataTable 的「导出 Excel」按钮统一打到这里,把当前筛选参数原样带上;
|
|
15
|
+
* 后端按 module 分发到对应的查询。这样新增一个可导出的模块,
|
|
16
|
+
* 只需要在这里加一个分支,前端不用改。
|
|
17
|
+
*
|
|
18
|
+
* 每个分支都会单独校验权限码 —— 通用入口最容易变成越权后门。
|
|
19
|
+
*/
|
|
20
|
+
function dateRangeWhere(sp: URLSearchParams): Prisma.DateTimeFilter | undefined {
|
|
21
|
+
const startDate = sp.get('startDate');
|
|
22
|
+
const endDate = sp.get('endDate');
|
|
23
|
+
if (!startDate && !endDate) return undefined;
|
|
24
|
+
const filter: Prisma.DateTimeFilter = {};
|
|
25
|
+
if (startDate) filter.gte = dayjs(startDate).startOf('day').toDate();
|
|
26
|
+
if (endDate) filter.lte = dayjs(endDate).endOf('day').toDate();
|
|
27
|
+
return filter;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseStatus(sp: URLSearchParams): number | undefined {
|
|
31
|
+
const s = sp.get('status');
|
|
32
|
+
return s === '0' || s === '1' ? Number(s) : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const GET = withRoute(async (req) => {
|
|
36
|
+
const user = await requireUser();
|
|
37
|
+
const sp = req.nextUrl.searchParams;
|
|
38
|
+
const targetModule = sp.get('module') || '';
|
|
39
|
+
const keyword = (sp.get('keyword') || '').trim();
|
|
40
|
+
|
|
41
|
+
let columns: ExcelColumn[] = [];
|
|
42
|
+
let rows: Record<string, unknown>[] = [];
|
|
43
|
+
let filename = '导出数据.xlsx';
|
|
44
|
+
|
|
45
|
+
if (targetModule === 'employees') {
|
|
46
|
+
requirePerm(user, P.empExport);
|
|
47
|
+
const deptId = sp.get('deptId');
|
|
48
|
+
const status = parseStatus(sp);
|
|
49
|
+
const gender = sp.get('gender');
|
|
50
|
+
|
|
51
|
+
const where: Prisma.EmployeeWhereInput = { deletedAt: null };
|
|
52
|
+
if (keyword) {
|
|
53
|
+
where.OR = [
|
|
54
|
+
{ code: { contains: keyword } },
|
|
55
|
+
{ name: { contains: keyword } },
|
|
56
|
+
{ idCard: { contains: keyword } },
|
|
57
|
+
{ phone: { contains: keyword } },
|
|
58
|
+
{ email: { contains: keyword } },
|
|
59
|
+
{ position: { contains: keyword } },
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
if (status !== undefined) where.status = status;
|
|
63
|
+
if (deptId) where.deptId = deptId;
|
|
64
|
+
if (gender === 'male' || gender === 'female') where.gender = gender;
|
|
65
|
+
Object.assign(where, deptScope(user));
|
|
66
|
+
|
|
67
|
+
const list = await prisma.employee.findMany({
|
|
68
|
+
where,
|
|
69
|
+
orderBy: { code: 'asc' },
|
|
70
|
+
take: 20000,
|
|
71
|
+
select: {
|
|
72
|
+
code: true,
|
|
73
|
+
name: true,
|
|
74
|
+
idCard: true,
|
|
75
|
+
gender: true,
|
|
76
|
+
position: true,
|
|
77
|
+
phone: true,
|
|
78
|
+
email: true,
|
|
79
|
+
hireDate: true,
|
|
80
|
+
salary: true,
|
|
81
|
+
status: true,
|
|
82
|
+
remark: true,
|
|
83
|
+
dept: { select: { name: true } },
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
columns = [
|
|
88
|
+
{ header: '工号', key: 'code' },
|
|
89
|
+
{ header: '姓名', key: 'name' },
|
|
90
|
+
{ header: '身份证号', key: 'idCard' },
|
|
91
|
+
{ header: '性别', key: 'gender' },
|
|
92
|
+
{ header: '部门', key: 'deptName' },
|
|
93
|
+
{ header: '职位', key: 'position' },
|
|
94
|
+
{ header: '手机号', key: 'phone' },
|
|
95
|
+
{ header: '邮箱', key: 'email' },
|
|
96
|
+
{ header: '入职日期', key: 'hireDate' },
|
|
97
|
+
{ header: '月薪', key: 'salary' },
|
|
98
|
+
{ header: '状态', key: 'status' },
|
|
99
|
+
{ header: '备注', key: 'remark' },
|
|
100
|
+
];
|
|
101
|
+
rows = list.map((r) => ({
|
|
102
|
+
code: r.code,
|
|
103
|
+
name: r.name,
|
|
104
|
+
idCard: r.idCard ?? '',
|
|
105
|
+
gender: r.gender === 'female' ? '女' : '男',
|
|
106
|
+
deptName: r.dept?.name ?? '',
|
|
107
|
+
position: r.position ?? '',
|
|
108
|
+
phone: r.phone ?? '',
|
|
109
|
+
email: r.email ?? '',
|
|
110
|
+
hireDate: r.hireDate ? dayjs(r.hireDate).format('YYYY-MM-DD') : '',
|
|
111
|
+
salary: r.salary ?? '',
|
|
112
|
+
status: r.status === 1 ? '在职' : '离职',
|
|
113
|
+
remark: r.remark ?? '',
|
|
114
|
+
}));
|
|
115
|
+
filename = '员工信息';
|
|
116
|
+
} else if (targetModule === 'users') {
|
|
117
|
+
requirePerm(user, P.userList);
|
|
118
|
+
const status = parseStatus(sp);
|
|
119
|
+
const deptId = sp.get('deptId');
|
|
120
|
+
|
|
121
|
+
const where: Prisma.UserWhereInput = { deletedAt: null };
|
|
122
|
+
if (keyword) {
|
|
123
|
+
where.OR = [
|
|
124
|
+
{ username: { contains: keyword } },
|
|
125
|
+
{ name: { contains: keyword } },
|
|
126
|
+
{ phone: { contains: keyword } },
|
|
127
|
+
{ email: { contains: keyword } },
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
if (status !== undefined) where.status = status;
|
|
131
|
+
if (deptId) where.deptId = deptId;
|
|
132
|
+
Object.assign(where, deptScope(user));
|
|
133
|
+
|
|
134
|
+
const list = await prisma.user.findMany({
|
|
135
|
+
where,
|
|
136
|
+
orderBy: { createdAt: 'desc' },
|
|
137
|
+
take: 20000,
|
|
138
|
+
select: {
|
|
139
|
+
username: true,
|
|
140
|
+
name: true,
|
|
141
|
+
email: true,
|
|
142
|
+
phone: true,
|
|
143
|
+
status: true,
|
|
144
|
+
isSuper: true,
|
|
145
|
+
lastLogin: true,
|
|
146
|
+
createdAt: true,
|
|
147
|
+
dept: { select: { name: true } },
|
|
148
|
+
roles: { select: { role: { select: { name: true } } } },
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
columns = [
|
|
153
|
+
{ header: '用户名', key: 'username' },
|
|
154
|
+
{ header: '姓名', key: 'name' },
|
|
155
|
+
{ header: '部门', key: 'deptName' },
|
|
156
|
+
{ header: '角色', key: 'roleNames' },
|
|
157
|
+
{ header: '邮箱', key: 'email' },
|
|
158
|
+
{ header: '手机号', key: 'phone' },
|
|
159
|
+
{ header: '状态', key: 'status' },
|
|
160
|
+
{ header: '账号类型', key: 'isSuper' },
|
|
161
|
+
{ header: '最后登录', key: 'lastLogin' },
|
|
162
|
+
{ header: '创建时间', key: 'createdAt' },
|
|
163
|
+
];
|
|
164
|
+
rows = list.map((u) => ({
|
|
165
|
+
username: u.username,
|
|
166
|
+
name: u.name,
|
|
167
|
+
deptName: u.dept?.name ?? '',
|
|
168
|
+
roleNames: u.roles.map((r) => r.role.name).join('、'),
|
|
169
|
+
email: u.email ?? '',
|
|
170
|
+
phone: u.phone ?? '',
|
|
171
|
+
status: u.status === 1 ? '启用' : '停用',
|
|
172
|
+
isSuper: u.isSuper === 1 ? '超级管理员' : '普通用户',
|
|
173
|
+
lastLogin: u.lastLogin ? dayjs(u.lastLogin).format('YYYY-MM-DD HH:mm:ss') : '',
|
|
174
|
+
createdAt: dayjs(u.createdAt).format('YYYY-MM-DD HH:mm:ss'),
|
|
175
|
+
}));
|
|
176
|
+
filename = '用户信息';
|
|
177
|
+
} else if (targetModule === 'logs') {
|
|
178
|
+
requirePerm(user, P.logList);
|
|
179
|
+
const action = sp.get('action');
|
|
180
|
+
// 日志页的模块筛选走 logModule,module 已被「导出模块」占用
|
|
181
|
+
const logModule = sp.get('logModule');
|
|
182
|
+
const createdAt = dateRangeWhere(sp);
|
|
183
|
+
|
|
184
|
+
const where: Prisma.OperationLogWhereInput = {};
|
|
185
|
+
if (keyword) {
|
|
186
|
+
where.OR = [
|
|
187
|
+
{ username: { contains: keyword } },
|
|
188
|
+
{ module: { contains: keyword } },
|
|
189
|
+
{ action: { contains: keyword } },
|
|
190
|
+
{ path: { contains: keyword } },
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
if (logModule) where.module = logModule;
|
|
194
|
+
if (action) where.action = action;
|
|
195
|
+
if (createdAt) where.createdAt = createdAt;
|
|
196
|
+
// 日志表没有 deptId,套不了部门范围:非「全部」的账号只能导出自己的操作记录
|
|
197
|
+
if (effectiveDataScope(user) !== DATA_SCOPE.ALL) where.userId = user.id;
|
|
198
|
+
|
|
199
|
+
const list = await prisma.operationLog.findMany({
|
|
200
|
+
where,
|
|
201
|
+
orderBy: { createdAt: 'desc' },
|
|
202
|
+
take: 20000,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
columns = [
|
|
206
|
+
{ header: '时间', key: 'createdAt' },
|
|
207
|
+
{ header: '操作人', key: 'username' },
|
|
208
|
+
{ header: '模块', key: 'module' },
|
|
209
|
+
{ header: '动作', key: 'action' },
|
|
210
|
+
{ header: '方法', key: 'method' },
|
|
211
|
+
{ header: '路径', key: 'path' },
|
|
212
|
+
{ header: 'IP', key: 'ip' },
|
|
213
|
+
{ header: '结果', key: 'status' },
|
|
214
|
+
{ header: '耗时(ms)', key: 'durationMs' },
|
|
215
|
+
];
|
|
216
|
+
rows = list.map((l) => ({
|
|
217
|
+
createdAt: dayjs(l.createdAt).format('YYYY-MM-DD HH:mm:ss'),
|
|
218
|
+
username: l.username ?? '',
|
|
219
|
+
module: l.module,
|
|
220
|
+
action: l.action,
|
|
221
|
+
method: l.method ?? '',
|
|
222
|
+
path: l.path ?? '',
|
|
223
|
+
ip: l.ip ?? '',
|
|
224
|
+
status: l.status === 1 ? '成功' : '失败',
|
|
225
|
+
durationMs: l.durationMs ?? '',
|
|
226
|
+
}));
|
|
227
|
+
filename = '操作日志';
|
|
228
|
+
} else {
|
|
229
|
+
throw new BizError(`不支持的导出模块:${targetModule || '(空)'}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const buffer = await exportToExcel({
|
|
233
|
+
filename: `${filename}.xlsx`,
|
|
234
|
+
sheetName: filename,
|
|
235
|
+
columns,
|
|
236
|
+
rows,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const { ip, userAgent } = clientInfo(req);
|
|
240
|
+
await audit({
|
|
241
|
+
userId: user.id,
|
|
242
|
+
username: user.username,
|
|
243
|
+
module: '数据导出',
|
|
244
|
+
action: '导出',
|
|
245
|
+
method: 'GET',
|
|
246
|
+
path: req.nextUrl.pathname,
|
|
247
|
+
detail: { module: targetModule, count: rows.length, keyword },
|
|
248
|
+
ip,
|
|
249
|
+
userAgent,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const outName = `${filename}_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
|
|
253
|
+
return buildExcelResponse(buffer, outName);
|
|
254
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { BizError, CODE, ok, withRoute } from '@/lib/api';
|
|
3
|
+
import { requireUser } from '@/auth';
|
|
4
|
+
import { buildSearchEntries, getUserMenuTree } from '@/lib/menu-service';
|
|
5
|
+
import { addFavoritePath, listFavoritePaths, removeFavoritePath } from '@/lib/favorites';
|
|
6
|
+
|
|
7
|
+
export const dynamic = 'force-dynamic';
|
|
8
|
+
|
|
9
|
+
const bodySchema = z.object({
|
|
10
|
+
path: z.string().trim().min(1, '缺少收藏目标').max(200, '收藏路径过长'),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 收藏(书签)接口。
|
|
15
|
+
*
|
|
16
|
+
* 能力很小,但有一条安全线必须守住:**前端传来的 path 不可信**。
|
|
17
|
+
* 它必须落在「当前用户自己菜单树能到达的路由」里 —— 否则这个接口就成了
|
|
18
|
+
* 往收藏表写任意字符串的口子,还会攒出一堆点进去 404 的死链。
|
|
19
|
+
*
|
|
20
|
+
* 可见性判断直接复用 buildSearchEntries:功能开关、隐藏菜单、角色权限
|
|
21
|
+
* 已经在这一层过滤干净了,这里不用再判第二次(判两次早晚会判不一致)。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
async function visiblePaths() {
|
|
25
|
+
const user = await requireUser();
|
|
26
|
+
const entries = buildSearchEntries(await getUserMenuTree(user));
|
|
27
|
+
return { userId: user.id, paths: new Set(entries.map((e) => e.path)) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** GET /api/favorites —— 当前用户的收藏路径,按收藏顺序 */
|
|
31
|
+
export const GET = withRoute(async () => {
|
|
32
|
+
const user = await requireUser();
|
|
33
|
+
return ok(await listFavoritePaths(user.id));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** POST /api/favorites { path } —— 收藏一个页面,返回最新列表 */
|
|
37
|
+
export const POST = withRoute(async (req) => {
|
|
38
|
+
const { path } = bodySchema.parse(await req.json());
|
|
39
|
+
const { userId, paths } = await visiblePaths();
|
|
40
|
+
|
|
41
|
+
if (!paths.has(path)) {
|
|
42
|
+
throw new BizError('该页面不在你的可见范围内,无法收藏', CODE.FORBIDDEN);
|
|
43
|
+
}
|
|
44
|
+
return ok(await addFavoritePath(userId, path));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** DELETE /api/favorites?path=/system/users —— 取消收藏,返回最新列表 */
|
|
48
|
+
export const DELETE = withRoute(async (req) => {
|
|
49
|
+
const user = await requireUser();
|
|
50
|
+
const path = (req.nextUrl.searchParams.get('path') || '').trim();
|
|
51
|
+
if (!path) throw new BizError('缺少要取消的收藏路径', CODE.BAD_REQUEST);
|
|
52
|
+
|
|
53
|
+
// 刻意**不**校验 path 是否仍可见:权限被收回的收藏本来就渲染不出来,
|
|
54
|
+
// 若再拦住删除,孤儿记录就永远清不掉了。deleteMany 已按 userId 收窄,不会误删他人。
|
|
55
|
+
return ok(await removeFavoritePath(user.id, path));
|
|
56
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { NextRequest } from 'next/server';
|
|
2
|
+
import { BizError, CODE, ok, withRoute } from '@/lib/api';
|
|
3
|
+
import { currentUser } from '@/auth';
|
|
4
|
+
import { processFeedbackWithAi } from '@/lib/feedbackAi';
|
|
5
|
+
|
|
6
|
+
export const dynamic = 'force-dynamic';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* POST /api/feedback/ai —— 对一条反馈执行智能处理。
|
|
10
|
+
*
|
|
11
|
+
* 请求:{ id: string }
|
|
12
|
+
* 返回:{ code: 0, data: FeedbackAiResult }
|
|
13
|
+
*
|
|
14
|
+
* ★ 这个接口**永远不返回 404 / 500**:
|
|
15
|
+
* 反馈不存在、模型不可用、业务规则冲突,全部转成
|
|
16
|
+
* `code: 0 + outcome` 的结构化结果,由前端在弹窗里展示分析与提示。
|
|
17
|
+
* 反馈页因此不会出现红色报错弹窗,评委点任何一条都能得到回应。
|
|
18
|
+
*/
|
|
19
|
+
export const POST = withRoute(async (req: NextRequest) => {
|
|
20
|
+
const user = await currentUser();
|
|
21
|
+
if (!user) {
|
|
22
|
+
throw new BizError('登录已过期,请重新登录后再试', CODE.UNAUTHORIZED, 401);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const body = (await req.json().catch(() => ({}))) as { id?: unknown };
|
|
26
|
+
const id = String(body.id ?? '').trim();
|
|
27
|
+
if (!id) throw new BizError('请先选择一条反馈', CODE.BAD_REQUEST);
|
|
28
|
+
|
|
29
|
+
const result = await processFeedbackWithAi(id, user);
|
|
30
|
+
return ok(result, '处理完成');
|
|
31
|
+
});
|