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.
Files changed (229) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -2
  3. package/lib/sync.js +121 -0
  4. package/package.json +14 -4
  5. package/resources/.cache/ref.b64 +1 -0
  6. package/resources/README.md +97 -0
  7. package/resources/_.env +6 -0
  8. package/resources/_.env.example +25 -0
  9. package/resources/_.gitignore +18 -0
  10. package/resources/_.lintstagedrc.json +9 -0
  11. package/resources/_.npmrc +5 -0
  12. package/resources/_.prettierrc +7 -0
  13. package/resources/commitlint.config.mjs +1 -0
  14. package/resources/content/docs/README.md +63 -0
  15. package/resources/content/docs/faq.md +107 -0
  16. package/resources/content/docs/features/_meta.json +5 -0
  17. package/resources/content/docs/features/ai.md +71 -0
  18. package/resources/content/docs/features/appearance.md +47 -0
  19. package/resources/content/docs/features/feedback.md +79 -0
  20. package/resources/content/docs/features/files.md +74 -0
  21. package/resources/content/docs/features/graph.md +66 -0
  22. package/resources/content/docs/features/inbox.md +61 -0
  23. package/resources/content/docs/guide/_meta.json +5 -0
  24. package/resources/content/docs/guide/account.md +76 -0
  25. package/resources/content/docs/guide/data.md +87 -0
  26. package/resources/content/docs/guide/navigation.md +81 -0
  27. package/resources/content/docs/guide/permissions.md +67 -0
  28. package/resources/content/docs/guide/system.md +92 -0
  29. package/resources/content/docs/quick-start.md +70 -0
  30. package/resources/content/docs/shortcuts.md +69 -0
  31. package/resources/eslint.config.mjs +17 -0
  32. package/resources/next-env.d.ts +6 -0
  33. package/resources/next.config.ts +13 -0
  34. package/resources/postcss.config.mjs +7 -0
  35. package/resources/prisma/org.config.ts +98 -0
  36. package/resources/prisma/schema.prisma +336 -0
  37. package/resources/prisma/seed.ts +721 -0
  38. package/resources/prisma/seedSafe.ts +347 -0
  39. package/resources/prisma.config.ts +14 -0
  40. package/resources/scripts/check-config.mjs +773 -0
  41. package/resources/scripts/lib/schema-graph.mjs +410 -0
  42. package/resources/scripts/patch-next-trace.mjs +86 -0
  43. package/resources/scripts/suggest-relations.mjs +105 -0
  44. package/resources/src/app/(admin)/ai/page.tsx +469 -0
  45. package/resources/src/app/(admin)/dashboard/page.tsx +260 -0
  46. package/resources/src/app/(admin)/demo/employees/page.tsx +508 -0
  47. package/resources/src/app/(admin)/demo/files/page.tsx +451 -0
  48. package/resources/src/app/(admin)/demo/loading/page.tsx +513 -0
  49. package/resources/src/app/(admin)/error.tsx +96 -0
  50. package/resources/src/app/(admin)/feedback/page.tsx +573 -0
  51. package/resources/src/app/(admin)/graph/page.tsx +1224 -0
  52. package/resources/src/app/(admin)/inbox/page.tsx +31 -0
  53. package/resources/src/app/(admin)/layout.tsx +58 -0
  54. package/resources/src/app/(admin)/loading.tsx +31 -0
  55. package/resources/src/app/(admin)/system/depts/page.tsx +415 -0
  56. package/resources/src/app/(admin)/system/logs/page.tsx +215 -0
  57. package/resources/src/app/(admin)/system/menus/page.tsx +403 -0
  58. package/resources/src/app/(admin)/system/roles/page.tsx +433 -0
  59. package/resources/src/app/(admin)/system/users/page.tsx +620 -0
  60. package/resources/src/app/api/account-recovery/route.ts +132 -0
  61. package/resources/src/app/api/ai/act/route.ts +70 -0
  62. package/resources/src/app/api/ai/ask/route.ts +82 -0
  63. package/resources/src/app/api/ai/chat/route.ts +404 -0
  64. package/resources/src/app/api/ai/report/route.ts +164 -0
  65. package/resources/src/app/api/auth/[...nextauth]/route.ts +3 -0
  66. package/resources/src/app/api/auth/change-password/route.ts +57 -0
  67. package/resources/src/app/api/auth/forgot/route.ts +98 -0
  68. package/resources/src/app/api/auth/register/route.ts +95 -0
  69. package/resources/src/app/api/auth/register-config/route.ts +51 -0
  70. package/resources/src/app/api/badges/route.ts +39 -0
  71. package/resources/src/app/api/depts/[id]/route.ts +145 -0
  72. package/resources/src/app/api/depts/route.ts +126 -0
  73. package/resources/src/app/api/docs/export/route.ts +87 -0
  74. package/resources/src/app/api/docs/parse/route.ts +57 -0
  75. package/resources/src/app/api/docs/search/route.ts +22 -0
  76. package/resources/src/app/api/employees/[id]/route.ts +147 -0
  77. package/resources/src/app/api/employees/export/route.ts +115 -0
  78. package/resources/src/app/api/employees/import/route.ts +205 -0
  79. package/resources/src/app/api/employees/route.ts +149 -0
  80. package/resources/src/app/api/export/route.ts +254 -0
  81. package/resources/src/app/api/favorites/route.ts +56 -0
  82. package/resources/src/app/api/feedback/ai/route.ts +31 -0
  83. package/resources/src/app/api/feedback/approve/route.ts +67 -0
  84. package/resources/src/app/api/feedback/report/route.ts +213 -0
  85. package/resources/src/app/api/feedback/route.ts +246 -0
  86. package/resources/src/app/api/files/[name]/route.ts +82 -0
  87. package/resources/src/app/api/graph/focus/route.ts +75 -0
  88. package/resources/src/app/api/graph/node/move/route.ts +48 -0
  89. package/resources/src/app/api/graph/node/route.ts +274 -0
  90. package/resources/src/app/api/graph/route.ts +272 -0
  91. package/resources/src/app/api/health/route.ts +11 -0
  92. package/resources/src/app/api/inbox/read/route.ts +36 -0
  93. package/resources/src/app/api/inbox/summary/route.ts +15 -0
  94. package/resources/src/app/api/logs/route.ts +82 -0
  95. package/resources/src/app/api/menus/[id]/route.ts +132 -0
  96. package/resources/src/app/api/menus/mine/route.ts +23 -0
  97. package/resources/src/app/api/menus/route.ts +89 -0
  98. package/resources/src/app/api/roles/[id]/menus/route.ts +73 -0
  99. package/resources/src/app/api/roles/[id]/route.ts +96 -0
  100. package/resources/src/app/api/roles/route.ts +114 -0
  101. package/resources/src/app/api/search/route.ts +22 -0
  102. package/resources/src/app/api/stats/route.ts +92 -0
  103. package/resources/src/app/api/upload/route.ts +38 -0
  104. package/resources/src/app/api/users/[id]/password/route.ts +55 -0
  105. package/resources/src/app/api/users/[id]/route.ts +145 -0
  106. package/resources/src/app/api/users/route.ts +153 -0
  107. package/resources/src/app/api/visits/route.ts +48 -0
  108. package/resources/src/app/change-password/ChangePasswordForm.tsx +174 -0
  109. package/resources/src/app/change-password/page.tsx +19 -0
  110. package/resources/src/app/docs/[[...slug]]/page.tsx +56 -0
  111. package/resources/src/app/docs/layout.tsx +34 -0
  112. package/resources/src/app/docs/loading.tsx +19 -0
  113. package/resources/src/app/docs/not-found.tsx +25 -0
  114. package/resources/src/app/forgot/page.tsx +392 -0
  115. package/resources/src/app/global-error.tsx +75 -0
  116. package/resources/src/app/globals.css +235 -0
  117. package/resources/src/app/layout.tsx +44 -0
  118. package/resources/src/app/login/page.tsx +267 -0
  119. package/resources/src/app/page.tsx +14 -0
  120. package/resources/src/app/register/page.tsx +237 -0
  121. package/resources/src/auth.ts +215 -0
  122. package/resources/src/components/AiPanel.tsx +970 -0
  123. package/resources/src/components/AuthContext.tsx +82 -0
  124. package/resources/src/components/DataTable.tsx +459 -0
  125. package/resources/src/components/EChart.tsx +140 -0
  126. package/resources/src/components/FeedbackAiModal.tsx +476 -0
  127. package/resources/src/components/FeedbackReportModal.tsx +479 -0
  128. package/resources/src/components/Markdown.tsx +532 -0
  129. package/resources/src/components/OntologyGraph.tsx +1196 -0
  130. package/resources/src/components/Providers.tsx +99 -0
  131. package/resources/src/components/VisitTracker.tsx +89 -0
  132. package/resources/src/components/admin/AdminShell.tsx +749 -0
  133. package/resources/src/components/admin/CountBadge.tsx +67 -0
  134. package/resources/src/components/admin/GlobalSearch.tsx +509 -0
  135. package/resources/src/components/admin/InboxBell.tsx +78 -0
  136. package/resources/src/components/admin/VisitStats.tsx +145 -0
  137. package/resources/src/components/admin/menu-icons.tsx +56 -0
  138. package/resources/src/components/ai/AiInputForm.tsx +351 -0
  139. package/resources/src/components/appearance/AppearanceDrawer.tsx +268 -0
  140. package/resources/src/components/appearance/AppearanceProvider.tsx +160 -0
  141. package/resources/src/components/docs/DocArticle.tsx +106 -0
  142. package/resources/src/components/docs/DocToc.tsx +100 -0
  143. package/resources/src/components/docs/DocsChrome.tsx +259 -0
  144. package/resources/src/components/docs/DocsHome.tsx +209 -0
  145. package/resources/src/components/docs/DocsSearch.tsx +365 -0
  146. package/resources/src/components/docs/docs.css +1458 -0
  147. package/resources/src/components/feedback/AsyncButton.tsx +108 -0
  148. package/resources/src/components/feedback/LoadingOverlay.tsx +125 -0
  149. package/resources/src/components/feedback/PressableCard.tsx +92 -0
  150. package/resources/src/components/feedback/README.md +97 -0
  151. package/resources/src/components/feedback/RouteProgress.tsx +108 -0
  152. package/resources/src/components/feedback/Skeleton.tsx +249 -0
  153. package/resources/src/components/feedback/StatCard.tsx +70 -0
  154. package/resources/src/components/feedback/TaskHint.tsx +153 -0
  155. package/resources/src/components/feedback/index.ts +93 -0
  156. package/resources/src/components/feedback/motion.css +204 -0
  157. package/resources/src/components/feedback/motion.ts +63 -0
  158. package/resources/src/components/feedback/useCountUp.ts +110 -0
  159. package/resources/src/components/feedback/usePending.ts +209 -0
  160. package/resources/src/components/feedback/useRowFlash.ts +40 -0
  161. package/resources/src/components/inbox/InboxView.tsx +409 -0
  162. package/resources/src/components/ui/button.tsx +35 -0
  163. package/resources/src/components/ui/card.tsx +37 -0
  164. package/resources/src/components/ui/input.tsx +19 -0
  165. package/resources/src/lib/ai.ts +310 -0
  166. package/resources/src/lib/aiContext.ts +357 -0
  167. package/resources/src/lib/aiForm.test.ts +191 -0
  168. package/resources/src/lib/aiForm.ts +532 -0
  169. package/resources/src/lib/aiFormTypes.ts +83 -0
  170. package/resources/src/lib/aiToken.ts +72 -0
  171. package/resources/src/lib/aiTools.ts +823 -0
  172. package/resources/src/lib/api.ts +160 -0
  173. package/resources/src/lib/appInfo.ts +128 -0
  174. package/resources/src/lib/appearance.ts +643 -0
  175. package/resources/src/lib/badge-count.ts +146 -0
  176. package/resources/src/lib/badges.test.ts +58 -0
  177. package/resources/src/lib/badges.ts +79 -0
  178. package/resources/src/lib/brand-mark.test.ts +87 -0
  179. package/resources/src/lib/brand-mark.ts +45 -0
  180. package/resources/src/lib/docs-shared.test.ts +148 -0
  181. package/resources/src/lib/docs-shared.ts +422 -0
  182. package/resources/src/lib/docs.ts +381 -0
  183. package/resources/src/lib/err-text.ts +21 -0
  184. package/resources/src/lib/excel.ts +152 -0
  185. package/resources/src/lib/favorite-path.ts +60 -0
  186. package/resources/src/lib/favorites.ts +59 -0
  187. package/resources/src/lib/features.ts +69 -0
  188. package/resources/src/lib/feedback-access.ts +17 -0
  189. package/resources/src/lib/feedbackAi.ts +1621 -0
  190. package/resources/src/lib/formRules.ts +76 -0
  191. package/resources/src/lib/format.ts +52 -0
  192. package/resources/src/lib/graph-cascade.ts +304 -0
  193. package/resources/src/lib/graph-focus.ts +301 -0
  194. package/resources/src/lib/graph-link.ts +17 -0
  195. package/resources/src/lib/graph-node-write.ts +150 -0
  196. package/resources/src/lib/graph-schema.ts +475 -0
  197. package/resources/src/lib/graph-scope.test.ts +46 -0
  198. package/resources/src/lib/graph-scope.ts +28 -0
  199. package/resources/src/lib/http.ts +157 -0
  200. package/resources/src/lib/inbox-types.ts +57 -0
  201. package/resources/src/lib/inbox.ts +55 -0
  202. package/resources/src/lib/menu-service.ts +204 -0
  203. package/resources/src/lib/notifications.ts +113 -0
  204. package/resources/src/lib/office.ts +531 -0
  205. package/resources/src/lib/ontology.ts +277 -0
  206. package/resources/src/lib/openaiCompat.ts +257 -0
  207. package/resources/src/lib/pending.ts +98 -0
  208. package/resources/src/lib/prisma.ts +30 -0
  209. package/resources/src/lib/rbac.test.ts +106 -0
  210. package/resources/src/lib/rbac.ts +185 -0
  211. package/resources/src/lib/search-data.ts +158 -0
  212. package/resources/src/lib/security-questions.ts +20 -0
  213. package/resources/src/lib/skin-theme.ts +155 -0
  214. package/resources/src/lib/system-config.ts +55 -0
  215. package/resources/src/lib/theme.ts +96 -0
  216. package/resources/src/lib/todos/feedback.ts +86 -0
  217. package/resources/src/lib/todos/index.ts +73 -0
  218. package/resources/src/lib/todos/types.ts +33 -0
  219. package/resources/src/lib/upload.ts +106 -0
  220. package/resources/src/lib/utils.test.ts +9 -0
  221. package/resources/src/lib/utils.ts +6 -0
  222. package/resources/src/lib/validation.test.ts +297 -0
  223. package/resources/src/lib/validation.ts +658 -0
  224. package/resources/src/lib/visit-stats.ts +109 -0
  225. package/resources/src/lib/visits.ts +111 -0
  226. package/resources/src/lib/zodField.ts +44 -0
  227. package/resources/tsconfig.json +41 -0
  228. package/resources/vitest.config.ts +16 -0
  229. package/resources/vitest.setup.ts +1 -0
@@ -0,0 +1,92 @@
1
+ import dayjs from 'dayjs';
2
+ import { Prisma } from '@prisma/client';
3
+ import { ok, withRoute } from '@/lib/api';
4
+ import { prisma } from '@/lib/prisma';
5
+ import { requireUser } from '@/auth';
6
+ import { DATA_SCOPE, deptScope, effectiveDataScope } from '@/lib/rbac';
7
+
8
+ export const dynamic = 'force-dynamic';
9
+
10
+ /**
11
+ * GET /api/stats —— 工作台看板数据。
12
+ *
13
+ * 一次性把所有图表要的数据都算好返回,而不是每个图一个接口:
14
+ * 看板首屏要同时渲染多个图,拆成 N 个请求会让加载状态变得更碎。
15
+ * 演示数据量小,这里直接用 groupBy / count,不引入缓存层。
16
+ *
17
+ * ★ 看板同样要过数据范围:数字和图表如果统计全库,就等于给普通用户开了一个
18
+ * 「用汇总口径看别人数据」的后门(人数、部门分布、入职趋势全能反推出来)。
19
+ */
20
+ export const GET = withRoute(async () => {
21
+ const user = await requireUser();
22
+
23
+ const empWhere: Prisma.EmployeeWhereInput = { deletedAt: null, ...deptScope(user) };
24
+ const userWhere: Prisma.UserWhereInput = { deletedAt: null, ...deptScope(user) };
25
+ // 日志表没有 deptId,套不了部门范围:非「全部」的账号只能看自己的操作记录
26
+ const logWhere: Prisma.OperationLogWhereInput =
27
+ effectiveDataScope(user) === DATA_SCOPE.ALL ? {} : { userId: user.id };
28
+
29
+ const [userCount, roleCount, empCount, deptCount, logCount] = await Promise.all([
30
+ prisma.user.count({ where: userWhere }),
31
+ prisma.role.count(),
32
+ prisma.employee.count({ where: empWhere }),
33
+ prisma.department.count(),
34
+ prisma.operationLog.count({ where: logWhere }),
35
+ ]);
36
+
37
+ const [depts, empByDeptRaw, genderRaw, employees, recentLogs] = await Promise.all([
38
+ prisma.department.findMany({ select: { id: true, name: true } }),
39
+ prisma.employee.groupBy({
40
+ by: ['deptId'],
41
+ where: empWhere,
42
+ _count: { _all: true },
43
+ }),
44
+ prisma.employee.groupBy({
45
+ by: ['gender'],
46
+ where: empWhere,
47
+ _count: { _all: true },
48
+ }),
49
+ prisma.employee.findMany({
50
+ where: { ...empWhere, hireDate: { not: null } },
51
+ select: { hireDate: true },
52
+ }),
53
+ prisma.operationLog.findMany({ where: logWhere, orderBy: { createdAt: 'desc' }, take: 8 }),
54
+ ]);
55
+
56
+ const deptName = new Map(depts.map((d) => [d.id, d.name]));
57
+ const empByDept = empByDeptRaw
58
+ .map((r) => ({
59
+ name: r.deptId ? deptName.get(r.deptId) ?? '未知部门' : '未分配',
60
+ value: r._count._all,
61
+ }))
62
+ .sort((a, b) => b.value - a.value);
63
+
64
+ const genderRatio = genderRaw.map((r) => ({
65
+ name: r.gender === 'female' ? '女' : '男',
66
+ value: r._count._all,
67
+ }));
68
+
69
+ // 入职趋势按「月份」聚合:种子数据的入职日期跨越数年,
70
+ // 按天聚合会得到一条几乎全空的折线,看不出任何规律。
71
+ const trend = new Map<string, number>();
72
+ for (const e of employees) {
73
+ if (!e.hireDate) continue;
74
+ const key = dayjs(e.hireDate).format('YYYY-MM');
75
+ trend.set(key, (trend.get(key) ?? 0) + 1);
76
+ }
77
+ const empTrend = [...trend.entries()]
78
+ .sort((a, b) => a[0].localeCompare(b[0]))
79
+ .map(([date, count]) => ({ date, count }));
80
+
81
+ return ok({
82
+ userCount,
83
+ roleCount,
84
+ empCount,
85
+ deptCount,
86
+ logCount,
87
+ empByDept,
88
+ empTrend,
89
+ genderRatio,
90
+ recentLogs,
91
+ });
92
+ });
@@ -0,0 +1,38 @@
1
+ import { BizError, audit, clientInfo, ok, withRoute } from '@/lib/api';
2
+ import { requireUser } from '@/auth';
3
+ import { saveUpload } from '@/lib/upload';
4
+
5
+ export const dynamic = 'force-dynamic';
6
+
7
+ function isFile(v: unknown): v is File {
8
+ return typeof v === 'object' && v !== null && typeof (v as File).arrayBuffer === 'function';
9
+ }
10
+
11
+ /** POST /api/upload —— 通用文件上传,返回可访问 URL */
12
+ export const POST = withRoute(async (req) => {
13
+ const user = await requireUser();
14
+
15
+ const form = await req.formData();
16
+ const file = form.get('file');
17
+ if (!isFile(file)) throw new BizError('未收到文件(表单字段名应为 file)');
18
+ if (file.size === 0) throw new BizError('文件内容为空');
19
+ if (file.size > 50 * 1024 * 1024) throw new BizError('单个文件不能超过 50MB');
20
+
21
+ const saved = await saveUpload(file, '', user.id);
22
+
23
+ const { ip, userAgent } = clientInfo(req);
24
+ await audit({
25
+ userId: user.id,
26
+ username: user.username,
27
+ module: '文件管理',
28
+ action: '上传',
29
+ method: 'POST',
30
+ path: req.nextUrl.pathname,
31
+ targetId: saved.name,
32
+ detail: { original: file.name, size: saved.size },
33
+ ip,
34
+ userAgent,
35
+ });
36
+
37
+ return ok({ name: saved.name, url: saved.url, size: saved.size });
38
+ });
@@ -0,0 +1,55 @@
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 { P, deptScope, requirePerm } from '@/lib/rbac';
7
+ import { zField } from '@/lib/zodField';
8
+
9
+ export const dynamic = 'force-dynamic';
10
+
11
+ const schema = z.object({
12
+ password: zField('password', { label: '新密码' }),
13
+ });
14
+
15
+ type Ctx = { params: Promise<{ id: string }> };
16
+
17
+ /** PUT /api/users/[id]/password —— 管理员重置密码 */
18
+ export const PUT = withRoute<Ctx>(async (req, ctx) => {
19
+ const operator = await requireUser();
20
+ requirePerm(operator, P.userReset);
21
+
22
+ const { id } = await ctx.params;
23
+ // 重置密码也过数据范围:部门管理员不该能重置别的部门账号的密码
24
+ const target = await prisma.user.findFirst({
25
+ where: { id, deletedAt: null, ...deptScope(operator) },
26
+ });
27
+ if (!target) throw new BizError('用户不存在', CODE.NOT_FOUND);
28
+
29
+ const parsed = schema.parse(await req.json());
30
+ await prisma.user.update({
31
+ where: { id },
32
+ data: {
33
+ password: await bcrypt.hash(parsed.password, 10),
34
+ // 管理员知道明文,用户下一次登录必须自己改成新密码
35
+ mustChangePassword: 1,
36
+ },
37
+ });
38
+
39
+ const { ip, userAgent } = clientInfo(req);
40
+ await audit({
41
+ userId: operator.id,
42
+ username: operator.username,
43
+ module: '用户管理',
44
+ action: '重置密码',
45
+ method: 'PUT',
46
+ path: req.nextUrl.pathname,
47
+ targetId: id,
48
+ // 明文密码绝不落日志,只记「谁重置了谁的密码」以及「要求下次登录改密」
49
+ detail: { username: target.username, mustChangePassword: true },
50
+ ip,
51
+ userAgent,
52
+ });
53
+
54
+ return ok(null, '密码已重置');
55
+ });
@@ -0,0 +1,145 @@
1
+ import { z } from 'zod';
2
+ import { Prisma } from '@prisma/client';
3
+ import bcrypt from 'bcryptjs';
4
+ import { BizError, CODE, audit, clientInfo, ok, withRoute } from '@/lib/api';
5
+ import { prisma } from '@/lib/prisma';
6
+ import { requireUser } from '@/auth';
7
+ import { P, deptScope, requirePerm } from '@/lib/rbac';
8
+ import { zField, zNullableField } from '@/lib/zodField';
9
+
10
+ export const dynamic = 'force-dynamic';
11
+
12
+ const userSelect = {
13
+ id: true,
14
+ username: true,
15
+ name: true,
16
+ email: true,
17
+ phone: true,
18
+ avatar: true,
19
+ deptId: true,
20
+ status: true,
21
+ isSuper: true,
22
+ securityQuestion: true,
23
+ mustChangePassword: true,
24
+ lastLogin: true,
25
+ remark: true,
26
+ createdAt: true,
27
+ updatedAt: true,
28
+ dept: { select: { id: true, name: true } },
29
+ roles: { select: { role: { select: { id: true, code: true, name: true } } } },
30
+ } satisfies Prisma.UserSelect;
31
+
32
+ const updateSchema = z.object({
33
+ // 编辑时不改密码:允许不传、空串或 null;传了实际值才走格式校验
34
+ password: zNullableField('password', { label: '密码' }),
35
+ // 编辑时姓名可以不改,但一旦传了就不能是空串
36
+ name: zField('name', { label: '姓名' }).optional(),
37
+ email: zNullableField('email', { label: '邮箱' }),
38
+ phone: zNullableField('phone', { label: '手机号' }),
39
+ deptId: z.string().nullish(),
40
+ status: z.coerce.number().int().min(0).max(1).optional(),
41
+ remark: z.string().max(200).nullish(),
42
+ roleIds: z.array(z.string()).optional(),
43
+ });
44
+
45
+ type Ctx = { params: Promise<{ id: string }> };
46
+
47
+ /** GET /api/users/[id] —— 详情 */
48
+ export const GET = withRoute<Ctx>(async (_req, ctx) => {
49
+ const user = await requireUser();
50
+ requirePerm(user, P.userList);
51
+
52
+ const { id } = await ctx.params;
53
+ const found = await prisma.user.findFirst({
54
+ // 详情同样过数据范围,避免「知道 id 就能读到范围外的账号」
55
+ where: { id, deletedAt: null, ...deptScope(user) },
56
+ select: userSelect,
57
+ });
58
+ if (!found) throw new BizError('用户不存在', CODE.NOT_FOUND);
59
+
60
+ return ok({ ...found, roles: found.roles.map((r) => r.role) });
61
+ });
62
+
63
+ /** PUT /api/users/[id] —— 更新(可选择性改密) */
64
+ export const PUT = withRoute<Ctx>(async (req, ctx) => {
65
+ const operator = await requireUser();
66
+ requirePerm(operator, P.userEdit);
67
+
68
+ const { id } = await ctx.params;
69
+ const exist = await prisma.user.findFirst({
70
+ where: { id, deletedAt: null, ...deptScope(operator) },
71
+ });
72
+ if (!exist) throw new BizError('用户不存在', CODE.NOT_FOUND);
73
+
74
+ const parsed = updateSchema.parse(await req.json());
75
+
76
+ // Unchecked 变体:既能直接写外键 deptId,又能用嵌套操作整体替换角色
77
+ const data: Prisma.UserUncheckedUpdateInput = {};
78
+ if (parsed.name !== undefined) data.name = parsed.name;
79
+ if (parsed.email !== undefined) data.email = parsed.email || null;
80
+ if (parsed.phone !== undefined) data.phone = parsed.phone || null;
81
+ if (parsed.deptId !== undefined) data.deptId = parsed.deptId || null;
82
+ if (parsed.status !== undefined) data.status = parsed.status;
83
+ if (parsed.remark !== undefined) data.remark = parsed.remark || null;
84
+ if (parsed.password) {
85
+ data.password = await bcrypt.hash(parsed.password, 10);
86
+ // 管理员直接改密与「重置密码」同义:用户下次登录必须先自己改
87
+ data.mustChangePassword = 1;
88
+ }
89
+ if (parsed.roleIds) {
90
+ // 覆盖式:先清空再写入,前端只关心「最终绑定了哪些角色」
91
+ data.roles = { deleteMany: {}, create: parsed.roleIds.map((roleId) => ({ roleId })) };
92
+ }
93
+
94
+ await prisma.user.update({ where: { id }, data });
95
+
96
+ const { ip, userAgent } = clientInfo(req);
97
+ await audit({
98
+ userId: operator.id,
99
+ username: operator.username,
100
+ module: '用户管理',
101
+ action: '修改',
102
+ method: 'PUT',
103
+ path: req.nextUrl.pathname,
104
+ targetId: id,
105
+ detail: { ...parsed, password: parsed.password ? '***' : undefined },
106
+ ip,
107
+ userAgent,
108
+ });
109
+
110
+ return ok(null, '保存成功');
111
+ });
112
+
113
+ /** DELETE /api/users/[id] —— 软删除 */
114
+ export const DELETE = withRoute<Ctx>(async (req, ctx) => {
115
+ const operator = await requireUser();
116
+ requirePerm(operator, P.userDel);
117
+
118
+ const { id } = await ctx.params;
119
+ if (id === operator.id) throw new BizError('不能删除当前登录账号');
120
+
121
+ const target = await prisma.user.findFirst({
122
+ where: { id, deletedAt: null, ...deptScope(operator) },
123
+ });
124
+ if (!target) throw new BizError('用户不存在', CODE.NOT_FOUND);
125
+ if (target.isSuper === 1) throw new BizError('超级管理员账号不允许删除');
126
+
127
+ // 软删:保留行以便审计追溯,同时释放 username 之外的业务引用
128
+ await prisma.user.update({ where: { id }, data: { deletedAt: new Date() } });
129
+
130
+ const { ip, userAgent } = clientInfo(req);
131
+ await audit({
132
+ userId: operator.id,
133
+ username: operator.username,
134
+ module: '用户管理',
135
+ action: '删除',
136
+ method: 'DELETE',
137
+ path: req.nextUrl.pathname,
138
+ targetId: id,
139
+ detail: { username: target.username, name: target.name },
140
+ ip,
141
+ userAgent,
142
+ });
143
+
144
+ return ok(null, '删除成功');
145
+ });
@@ -0,0 +1,153 @@
1
+ import { z } from 'zod';
2
+ import { Prisma } from '@prisma/client';
3
+ import bcrypt from 'bcryptjs';
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
+ /** 列表/详情都不允许把密码哈希带出去,统一用这个 select */
22
+ const userSelect = {
23
+ id: true,
24
+ username: true,
25
+ name: true,
26
+ email: true,
27
+ phone: true,
28
+ avatar: true,
29
+ deptId: true,
30
+ status: true,
31
+ isSuper: true,
32
+ securityQuestion: true,
33
+ mustChangePassword: true,
34
+ lastLogin: true,
35
+ remark: true,
36
+ createdAt: true,
37
+ updatedAt: true,
38
+ dept: { select: { id: true, name: true } },
39
+ roles: { select: { role: { select: { id: true, code: true, name: true } } } },
40
+ } satisfies Prisma.UserSelect;
41
+
42
+ const createSchema = z.object({
43
+ username: zField('username', { label: '用户名' }),
44
+ password: zField('password', { label: '密码' }),
45
+ name: zField('name', { label: '姓名' }),
46
+ email: zNullableField('email', { label: '邮箱' }),
47
+ phone: zNullableField('phone', { label: '手机号' }),
48
+ deptId: z.string().nullish(),
49
+ status: z.coerce.number().int().min(0).max(1).default(1),
50
+ remark: z.string().max(200).nullish(),
51
+ roleIds: z.array(z.string()).default([]),
52
+ // 密保用于用户忘记密码时自助找回;答案只存 bcrypt 哈希
53
+ securityQuestion: z.string().trim().max(100, '密保问题过长').optional().or(z.literal('')),
54
+ securityAnswer: z.string().trim().max(100, '密保答案过长').optional().or(z.literal('')),
55
+ });
56
+
57
+ /** GET /api/users —— 分页列表 */
58
+ export const GET = withRoute(async (req) => {
59
+ const user = await requireUser();
60
+ requirePerm(user, P.userList);
61
+
62
+ const q = parseListQuery(req, { createdAt: 'desc' });
63
+ const sp = req.nextUrl.searchParams;
64
+ const status = sp.get('status');
65
+ const deptId = sp.get('deptId');
66
+
67
+ const where: Prisma.UserWhereInput = { deletedAt: null };
68
+
69
+ if (q.keyword) {
70
+ where.OR = [
71
+ { username: { contains: q.keyword } },
72
+ { name: { contains: q.keyword } },
73
+ { phone: { contains: q.keyword } },
74
+ { email: { contains: q.keyword } },
75
+ ];
76
+ }
77
+ if (status === '0' || status === '1') where.status = Number(status);
78
+ if (deptId) where.deptId = deptId;
79
+
80
+ // 数据范围:超管看全部,其余只看本部门。放在最后合并,保证「越权筛选」筛不出别人的数据。
81
+ Object.assign(where, deptScope(user));
82
+
83
+ const [rows, total] = await Promise.all([
84
+ prisma.user.findMany({
85
+ where,
86
+ skip: q.skip,
87
+ take: q.take,
88
+ orderBy: q.orderBy as Prisma.UserOrderByWithRelationInput,
89
+ select: userSelect,
90
+ }),
91
+ prisma.user.count({ where }),
92
+ ]);
93
+
94
+ const list = rows.map((u) => ({ ...u, roles: u.roles.map((r) => r.role) }));
95
+ return ok(pageResult(list, total, q));
96
+ });
97
+
98
+ /** POST /api/users —— 新建用户 */
99
+ export const POST = withRoute(async (req) => {
100
+ const user = await requireUser();
101
+ requirePerm(user, P.userAdd);
102
+
103
+ const parsed = createSchema.parse(await req.json());
104
+
105
+ // 唯一约束是「全局」的(软删的账号仍占用用户名),所以这里不能带 deletedAt 条件
106
+ const exists = await prisma.user.findUnique({ where: { username: parsed.username } });
107
+ if (exists) throw new BizError('用户名已存在', CODE.CONFLICT);
108
+
109
+ const question = parsed.securityQuestion?.trim() || '';
110
+ const answer = parsed.securityAnswer?.trim() || '';
111
+ if (Boolean(question) !== Boolean(answer)) {
112
+ throw new BizError('密保问题和答案需要同时填写', CODE.BAD_REQUEST);
113
+ }
114
+
115
+ const password = await bcrypt.hash(parsed.password, 10);
116
+
117
+ const created = await prisma.user.create({
118
+ data: {
119
+ username: parsed.username,
120
+ password,
121
+ name: parsed.name,
122
+ email: parsed.email || null,
123
+ phone: parsed.phone || null,
124
+ deptId: parsed.deptId || null,
125
+ status: parsed.status,
126
+ remark: parsed.remark || null,
127
+ securityQuestion: question || null,
128
+ securityAnswerHash: answer ? await bcrypt.hash(answer, 10) : null,
129
+ roles: { create: parsed.roleIds.map((roleId) => ({ roleId })) },
130
+ },
131
+ });
132
+
133
+ const { ip, userAgent } = clientInfo(req);
134
+ await audit({
135
+ userId: user.id,
136
+ username: user.username,
137
+ module: '用户管理',
138
+ action: '新增',
139
+ method: 'POST',
140
+ path: req.nextUrl.pathname,
141
+ targetId: created.id,
142
+ detail: {
143
+ username: parsed.username,
144
+ name: parsed.name,
145
+ roleIds: parsed.roleIds,
146
+ securityQuestion: question || undefined,
147
+ },
148
+ ip,
149
+ userAgent,
150
+ });
151
+
152
+ return ok({ id: created.id }, '新增成功');
153
+ });
@@ -0,0 +1,48 @@
1
+ import { z } from 'zod';
2
+ import { auth, requireUser } from '@/auth';
3
+ import { clientInfo, ok, withRoute } from '@/lib/api';
4
+ import { isVisitorId, normalizeVisitPath } from '@/lib/visits';
5
+ import { getVisitStats, trackVisit } from '@/lib/visit-stats';
6
+
7
+ export const dynamic = 'force-dynamic';
8
+
9
+ /**
10
+ * 访问统计接口。
11
+ *
12
+ * POST /api/visits { visitorId, path? }
13
+ * path 有值 = 一次页面浏览;没有 = 心跳(只刷新在线状态)。
14
+ * 刻意**不要求登录**:埋点挂在根布局上,登录页 / 帮助文档也要统计;
15
+ * 已登录时顺手把 userId 记下来,方便以后按账号口径分析。
16
+ *
17
+ * GET /api/visits
18
+ * → { online, today, total }。只有登录用户能看(顶栏组件本来就是登录后才有),
19
+ * 游客拿不到全站访问量,避免把运营数据暴露在公网。
20
+ */
21
+
22
+ const trackSchema = z.object({
23
+ visitorId: z.string().trim().refine(isVisitorId, 'visitorId 格式不合法'),
24
+ path: z.string().trim().max(500).optional(),
25
+ });
26
+
27
+ export const POST = withRoute(async (req) => {
28
+ const body = await req.json().catch(() => ({}));
29
+ const { visitorId, path } = trackSchema.parse(body);
30
+ const { ip, userAgent } = clientInfo(req);
31
+ const session = await auth();
32
+
33
+ const recorded = await trackVisit({
34
+ visitorId,
35
+ userId: session?.user?.id ?? null,
36
+ // 非法路径按心跳处理:统计接口不因为一条脏数据报错,业务页面更不该被打断
37
+ path: normalizeVisitPath(path),
38
+ ip,
39
+ userAgent,
40
+ });
41
+
42
+ return ok({ recorded });
43
+ });
44
+
45
+ export const GET = withRoute(async () => {
46
+ await requireUser();
47
+ return ok(await getVisitStats());
48
+ });