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.
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,157 @@
1
+ 'use client';
2
+
3
+ import axios, { type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios';
4
+ import { beginTask, endTask } from './pending';
5
+
6
+ /**
7
+ * 请求级等待追踪。
8
+ *
9
+ * ★ 这是「任何耗时场景都有提示」的**兜底层**:
10
+ * 在 Providers 里装一次拦截器,全站所有 axios 请求(现有 34 个接口 +
11
+ * 比赛新加的接口)都会自动登记到全局任务表,
12
+ * 超过 3 秒没有结束,右下角就会浮出一张「正在处理…已等待 N 秒」的小卡片。
13
+ *
14
+ * 也就是说:哪怕新写的页面忘了加 loading,用户也不会对着静止的界面猜。
15
+ * 页面里自己加的 spinner 是「锦上添花」,这一层是「不会翻车」。
16
+ *
17
+ * 不追踪的情况:给请求加 `{ __silent: true }`。
18
+ * 用于轮询、后台预取、以及自己已经有精细进度的上传。
19
+ */
20
+
21
+ declare module 'axios' {
22
+ export interface AxiosRequestConfig {
23
+ /**
24
+ * 设为 true 时,这次请求不进入全局等待提示。
25
+ * 适合:定时轮询、页面预取、已有自己进度条的上传。
26
+ */
27
+ __silent?: boolean;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * 路径 → 人话文案。
33
+ *
34
+ * 命中不到不算错,会回退成「正在处理数据…」。
35
+ * 比赛出了新题、加了新接口,想让提示更具体,在这里补一行正则即可 ——
36
+ * 不需要改页面、不需要改组件。
37
+ */
38
+ const LABELS: { test: RegExp; text: string }[] = [
39
+ { test: /^\/api\/ai\/chat$/, text: 'AI 正在思考,请稍候…' },
40
+ { test: /^\/api\/ai\/report$/, text: '正在把真实数据交给本地模型…' },
41
+ { test: /^\/api\/ai\/act$/, text: '正在执行确认过的操作…' },
42
+ { test: /^\/api\/graph\/focus$/, text: '正在解析这条记录与图谱的关联…' },
43
+ { test: /^\/api\/graph\/node$/, text: '正在读取节点字段…' },
44
+ { test: /^\/api\/graph/, text: '正在加载图谱数据…' },
45
+ { test: /^\/api\/export/, text: '正在生成导出文件…' },
46
+ { test: /^\/api\/upload/, text: '正在上传文件…' },
47
+ { test: /^\/api\/stats/, text: '正在汇总统计数据…' },
48
+ { test: /^\/api\/search/, text: '正在全库检索…' },
49
+ { test: /^\/api\/auth/, text: '正在验证身份…' },
50
+ { test: /^\/api\/logs/, text: '正在读取操作日志…' },
51
+ { test: /^\/api\/files/, text: '正在读取文件…' },
52
+ { test: /^\/api\/docs/, text: '正在生成文档…' },
53
+ ];
54
+
55
+ /** 把请求 URL 转成面向用户的一句话 */
56
+ export function labelForRequest(url?: string, method?: string): string {
57
+ if (!url) return '正在处理数据…';
58
+ // 去掉 query / hash / 可能的绝对地址前缀,只留 pathname
59
+ const path = url.split('?')[0].split('#')[0].replace(/^https?:\/\/[^/]+/, '');
60
+ for (const item of LABELS) {
61
+ if (item.test.test(path)) return item.text;
62
+ }
63
+ if (method === 'DELETE') return '正在删除,请稍候…';
64
+ if (method === 'POST' || method === 'PUT' || method === 'PATCH') return '正在提交数据…';
65
+ return '正在加载数据…';
66
+ }
67
+
68
+ type TrackedConfig = InternalAxiosRequestConfig & { __pendingId?: string };
69
+
70
+ function endFor(config?: AxiosRequestConfig | TrackedConfig | null) {
71
+ endTask((config as TrackedConfig | undefined)?.__pendingId);
72
+ }
73
+
74
+ let installed = false;
75
+
76
+ /**
77
+ * 装上全局拦截器。幂等:重复调用只会生效一次。
78
+ * 由 Providers 在客户端挂载时调用,不放进模块顶层 —— 顶层执行会在 SSR 时也跑一遍。
79
+ */
80
+ export function installRequestTracking(): void {
81
+ if (installed) return;
82
+ installed = true;
83
+
84
+ axios.interceptors.request.use((config) => {
85
+ const c = config as TrackedConfig;
86
+ if (!c.__silent) {
87
+ c.__pendingId = beginTask(labelForRequest(c.url, c.method?.toUpperCase()), 'request');
88
+ }
89
+ return config;
90
+ });
91
+
92
+ axios.interceptors.response.use(
93
+ (res) => {
94
+ endFor(res.config as TrackedConfig);
95
+ return res;
96
+ },
97
+ (err) => {
98
+ // 请求没发出去(断网、被取消)时 err.config 可能为空,endTask 对空 id 是安全的
99
+ endFor(err?.config as TrackedConfig | undefined);
100
+ return Promise.reject(err);
101
+ }
102
+ );
103
+ }
104
+
105
+ /**
106
+ * 客户端模块一被求值就装好拦截器。
107
+ *
108
+ * ★ 为什么不能只靠 Providers 的 useEffect:
109
+ * React 的 effect 是「子先父后」,页面组件(DataTable 的 useQuery)的 effect
110
+ * 会比 Providers 的 effect 先跑 —— 首屏那一发请求就会漏掉追踪。
111
+ * 模块求值早于一切渲染,这里装最保险。 Providers 里那次调用留作幂等兜底。
112
+ */
113
+ if (typeof window !== 'undefined') {
114
+ installRequestTracking();
115
+ }
116
+
117
+ /**
118
+ * fetch 版追踪包装。
119
+ *
120
+ * 为什么需要它:axios 的拦截器管不到原生 fetch,而 AI 对话(SSE 流式)、
121
+ * 图谱聚焦这些"最慢的请求"恰好都是 fetch。新代码一律用这个函数,
122
+ * 就能和 axios 请求一样,慢到一定程度自动出现在全局浮层里。
123
+ *
124
+ * 用法:
125
+ * const res = await trackedFetch('/api/ai/chat', { method: 'POST', body });
126
+ * // 需要精细控制时自己收尾:
127
+ * const { res, done } = await trackedFetch.withHandle(...); done();
128
+ */
129
+ export async function trackedFetch(input: string, init?: RequestInit): Promise<Response> {
130
+ const label = labelForRequest(input, init?.method?.toUpperCase());
131
+ const id = beginTask(label, 'request');
132
+ try {
133
+ return await fetch(input, init);
134
+ } finally {
135
+ endTask(id);
136
+ }
137
+ }
138
+
139
+ /**
140
+ * 流式/长连接专用:返回 res 的同时把「结束」的控制权交给调用方。
141
+ *
142
+ * 因为 SSE 是 `await fetch()` 就返回了 Response,真正的耗时在后续读流里,
143
+ * 用普通 trackedFetch 会在响应头到达时就把任务注销掉 —— 恰恰漏掉了最慢的那段。
144
+ */
145
+ export async function trackedFetchStream(
146
+ input: string,
147
+ init?: RequestInit
148
+ ): Promise<{ res: Response; done: () => void }> {
149
+ const id = beginTask(labelForRequest(input, init?.method?.toUpperCase()), 'request');
150
+ try {
151
+ const res = await fetch(input, init);
152
+ return { res, done: () => endTask(id) };
153
+ } catch (e) {
154
+ endTask(id);
155
+ throw e;
156
+ }
157
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 待办与消息中心的共享类型。
3
+ *
4
+ * 这个文件**不碰 Prisma / 文件系统**,服务端聚合结果和客户端组件共用同一套形状,
5
+ * 避免「接口返回的字段和页面取的字段对不上」这类低级错误。
6
+ */
7
+
8
+ export type InboxTodoLevel = 'info' | 'warning' | 'error';
9
+
10
+ export type InboxTodoItem = {
11
+ /** 业务侧唯一 id,仅用于 React key;不需要全局唯一 */
12
+ id: string;
13
+ title: string;
14
+ subtitle?: string;
15
+ /** 点击后去的内部路由,必须带足够信息定位到具体记录 */
16
+ link: string;
17
+ level?: InboxTodoLevel;
18
+ createdAt?: string;
19
+ };
20
+
21
+ export type InboxTodoGroup = {
22
+ key: string;
23
+ name: string;
24
+ description?: string;
25
+ /** 菜单图标名(与 menu-icons 的映射表一致) */
26
+ icon?: string;
27
+ /** 该来源的待办总数,可能大于 items.length */
28
+ count: number;
29
+ items: InboxTodoItem[];
30
+ };
31
+
32
+ export type NotificationLevel = 'info' | 'success' | 'warning' | 'error';
33
+
34
+ export type InboxNotification = {
35
+ id: string;
36
+ title: string;
37
+ body: string | null;
38
+ type: string;
39
+ level: NotificationLevel;
40
+ link: string | null;
41
+ readAt: string | null;
42
+ createdAt: string;
43
+ };
44
+
45
+ /** 顶栏铃铛只需要这个摘要,不拉全量列表 */
46
+ export type InboxSummary = {
47
+ todoCount: number;
48
+ unreadCount: number;
49
+ total: number;
50
+ };
51
+
52
+ export type InboxData = {
53
+ summary: InboxSummary;
54
+ todoGroups: InboxTodoGroup[];
55
+ /** 最近的消息(含已读),客户端按未读筛选 */
56
+ notifications: InboxNotification[];
57
+ };
@@ -0,0 +1,55 @@
1
+ import type { SessionUser } from '@/auth';
2
+ import type { InboxData, InboxNotification, InboxSummary, InboxTodoGroup } from './inbox-types';
3
+ import { listTodoGroups, countTodos } from './todos';
4
+ import { countUnreadNotifications, listNotifications } from './notifications';
5
+
6
+ /**
7
+ * 待办与消息的聚合层(服务端)。
8
+ *
9
+ * 顶栏铃铛只要摘要,/inbox 要摘要 + 分组待办 + 最近消息。
10
+ * 所有子查询都做失败降级:某个来源、消息表或计数出错时,
11
+ * 页面仍然能打开,只是对应区块为空,而不是整页 500。
12
+ */
13
+
14
+ export async function getInboxSummary(user: SessionUser): Promise<InboxSummary> {
15
+ const [todoResult, unreadResult] = await Promise.allSettled([
16
+ countTodos(user),
17
+ countUnreadNotifications(user.id),
18
+ ]);
19
+
20
+ if (todoResult.status === 'rejected') {
21
+ console.error('[inbox] 待办计数失败(已按 0 处理)', todoResult.reason);
22
+ }
23
+ if (unreadResult.status === 'rejected') {
24
+ console.error('[inbox] 未读消息计数失败(已按 0 处理)', unreadResult.reason);
25
+ }
26
+
27
+ const todoCount = todoResult.status === 'fulfilled' ? todoResult.value : 0;
28
+ const unreadCount = unreadResult.status === 'fulfilled' ? unreadResult.value : 0;
29
+
30
+ return { todoCount, unreadCount, total: todoCount + unreadCount };
31
+ }
32
+
33
+ export async function getInboxData(user: SessionUser): Promise<InboxData> {
34
+ const [groupsResult, notificationsResult] = await Promise.allSettled([
35
+ listTodoGroups(user, 20),
36
+ listNotifications(user.id, 100),
37
+ ]);
38
+
39
+ let todoGroups: InboxTodoGroup[] = [];
40
+ if (groupsResult.status === 'fulfilled') {
41
+ todoGroups = groupsResult.value;
42
+ } else {
43
+ console.error('[inbox] 待办列表加载失败(已按空处理)', groupsResult.reason);
44
+ }
45
+
46
+ let notifications: InboxNotification[] = [];
47
+ if (notificationsResult.status === 'fulfilled') {
48
+ notifications = notificationsResult.value;
49
+ } else {
50
+ console.error('[inbox] 消息列表加载失败(已按空处理)', notificationsResult.reason);
51
+ }
52
+
53
+ const summary = await getInboxSummary(user);
54
+ return { summary, todoGroups, notifications };
55
+ }
@@ -0,0 +1,204 @@
1
+ import { prisma } from '@/lib/prisma';
2
+ import type { SessionUser } from '@/auth';
3
+ import { isPathEnabled } from './features';
4
+
5
+ /**
6
+ * 菜单树服务。
7
+ *
8
+ * 为什么单独抽一层:`/api/menus/mine`(前端动态菜单)和 `(admin)/layout.tsx`
9
+ * (服务端首屏渲染侧边栏)要的是同一份数据。抽出来后,
10
+ * 「谁能看到哪些菜单」这条规则全项目只有一处实现,不会出现
11
+ * 「接口给的菜单和布局给的菜单不一致」这种诡异 bug。
12
+ */
13
+
14
+ export type MenuNode = {
15
+ id: string;
16
+ parentId: string | null;
17
+ name: string;
18
+ path: string | null;
19
+ icon: string | null;
20
+ keywords: string | null;
21
+ /** 侧边栏徽标的计数源(见 badges.ts);null = 不显示徽标 */
22
+ badgeKey: string | null;
23
+ type: string; // dir | menu | button
24
+ permCode: string | null;
25
+ sort: number;
26
+ hidden: number;
27
+ status: number;
28
+ };
29
+
30
+ export type TreeItem<T> = T & { children: TreeItem<T>[] };
31
+ export type MenuTreeNode = TreeItem<MenuNode>;
32
+
33
+ const MENU_SELECT = {
34
+ id: true,
35
+ parentId: true,
36
+ name: true,
37
+ path: true,
38
+ icon: true,
39
+ keywords: true,
40
+ badgeKey: true,
41
+ type: true,
42
+ permCode: true,
43
+ sort: true,
44
+ hidden: true,
45
+ status: true,
46
+ } as const;
47
+
48
+ /**
49
+ * 扁平列表 → 树。
50
+ * 找不到父节点的条目会被当成根节点,而不是被丢弃 ——
51
+ * 权限只勾了子菜单没勾父目录时,子菜单依然要能显示出来。
52
+ */
53
+ export function buildTree<T extends { id: string; parentId: string | null }>(list: T[]): TreeItem<T>[] {
54
+ const map = new Map<string, TreeItem<T>>();
55
+ for (const item of list) {
56
+ map.set(item.id, { ...item, children: [] } as TreeItem<T>);
57
+ }
58
+
59
+ const roots: TreeItem<T>[] = [];
60
+ for (const node of map.values()) {
61
+ const parent = node.parentId ? map.get(node.parentId) : undefined;
62
+ if (parent) parent.children.push(node);
63
+ else roots.push(node);
64
+ }
65
+
66
+ const sortRec = (arr: TreeItem<T>[]) => {
67
+ arr.sort(
68
+ (a, b) =>
69
+ Number((a as { sort?: number }).sort ?? 0) - Number((b as { sort?: number }).sort ?? 0)
70
+ );
71
+ for (const n of arr) sortRec(n.children);
72
+ };
73
+ sortRec(roots);
74
+
75
+ return roots;
76
+ }
77
+
78
+ /**
79
+ * 当前用户可见的菜单树。
80
+ *
81
+ * - 超管:全部启用中的菜单
82
+ * - 普通用户:其「启用角色」关联到的、且本身启用中的菜单
83
+ *
84
+ * 注意返回里包含 type='button' 的条目:前端按钮级鉴权要用它,
85
+ * 渲染侧边栏时由前端自己过滤掉。
86
+ */
87
+ export async function getUserMenuTree(user: SessionUser): Promise<MenuTreeNode[]> {
88
+ const where = user.isSuper
89
+ ? { status: 1 }
90
+ : {
91
+ status: 1,
92
+ roles: {
93
+ some: {
94
+ role: {
95
+ status: 1,
96
+ users: { some: { userId: user.id } },
97
+ },
98
+ },
99
+ },
100
+ };
101
+
102
+ const menus = await prisma.menu.findMany({
103
+ where,
104
+ orderBy: { sort: 'asc' },
105
+ select: MENU_SELECT,
106
+ });
107
+
108
+ return buildTree(menus as MenuNode[]);
109
+ }
110
+
111
+ /**
112
+ * 按功能开关过滤菜单树。
113
+ *
114
+ * 与 toSidebarTree 的区别:这里**保留按钮节点**,因为菜单管理页和角色授权
115
+ * 抽屉需要维护/勾选按钮级权限。但被开关关闭的模块(连同其下的按钮)整体移除,
116
+ * 否则在管理界面里仍能直接看到,等于没关。
117
+ */
118
+ function filterByFeatures(nodes: MenuTreeNode[]): MenuTreeNode[] {
119
+ return nodes
120
+ .map((n) => ({ ...n, children: filterByFeatures(n.children) }))
121
+ .filter((n) => (n.path ? isPathEnabled(n.path) : n.children.length > 0));
122
+ }
123
+
124
+ /** 全部菜单树(菜单管理页 / 角色授权抽屉用) */
125
+ export async function getFullMenuTree(): Promise<MenuTreeNode[]> {
126
+ const menus = await prisma.menu.findMany({ orderBy: { sort: 'asc' }, select: MENU_SELECT });
127
+ return filterByFeatures(buildTree(menus as MenuNode[]));
128
+ }
129
+
130
+ /** 侧边栏渲染用:剔除按钮、隐藏项,以及功能开关关闭的模块 */
131
+ export function toSidebarTree(nodes: MenuTreeNode[]): MenuTreeNode[] {
132
+ const walk = (list: MenuTreeNode[]): MenuTreeNode[] =>
133
+ list
134
+ .filter((n) => n.type !== 'button' && n.hidden !== 1)
135
+ .map((n) => ({ ...n, children: walk(n.children) }))
136
+ // 开关关掉的模块不出现;目录的子项全被滤掉时,目录本身也一并去掉,
137
+ // 否则会留下一个点开什么都没有的空分组。
138
+ .filter((n) => (n.path ? isPathEnabled(n.path) : n.children.length > 0));
139
+ return walk(nodes);
140
+ }
141
+
142
+ /* ============================ 全局搜索索引 ============================ */
143
+
144
+ /**
145
+ * 搜索索引条目。
146
+ *
147
+ * `path` 一定是**可跳转的真实路由**:目录节点自己没有 path,
148
+ * 取其第一个可跳转的后代页面,这样搜「AI 能力」也能落到 AI 中心。
149
+ */
150
+ export type SearchEntry = {
151
+ id: string;
152
+ name: string;
153
+ path: string;
154
+ icon: string | null;
155
+ type: string;
156
+ /** 空格/逗号分隔的别名,原样传给前端做匹配 */
157
+ keywords: string;
158
+ /** 面包屑文案(不含自己),如「AI 能力」;顶级菜单为空串 */
159
+ trail: string;
160
+ };
161
+
162
+ /** 目录的第一个可跳转后代(按 sort 深度优先);找不到返回 null */
163
+ function firstLeafPath(node: MenuTreeNode): string | null {
164
+ for (const child of node.children) {
165
+ if (child.type !== 'button' && child.hidden !== 1 && child.path) return child.path;
166
+ const deep = firstLeafPath(child);
167
+ if (deep) return deep;
168
+ }
169
+ return null;
170
+ }
171
+
172
+ /**
173
+ * 菜单树 → 全局搜索索引。
174
+ *
175
+ * 为什么拿菜单树当唯一数据源:**菜单就是这个系统的「功能注册表」** ——
176
+ * AI 每加一个业务模块都会在 seed 里登记菜单(见通用提示词铁律),
177
+ * 所以新功能不用为搜索写任何代码,进了侧边栏就等于进了搜索。
178
+ * 按钮(type=button)没有页面、隐藏菜单用户看不见,两者都不进索引。
179
+ */
180
+ export function buildSearchEntries(nodes: MenuTreeNode[]): SearchEntry[] {
181
+ const out: SearchEntry[] = [];
182
+ const walk = (list: MenuTreeNode[], trail: string) => {
183
+ for (const n of list) {
184
+ if (n.type === 'button' || n.hidden === 1) continue;
185
+ const path = n.path || firstLeafPath(n);
186
+ // 开关关掉的模块不进搜索索引,否则搜到了点进去是空
187
+ if (path && !isPathEnabled(path)) continue;
188
+ if (path) {
189
+ out.push({
190
+ id: n.id,
191
+ name: n.name,
192
+ path,
193
+ icon: n.icon,
194
+ type: n.type,
195
+ keywords: n.keywords ?? '',
196
+ trail,
197
+ });
198
+ }
199
+ walk(n.children, trail ? `${trail} / ${n.name}` : n.name);
200
+ }
201
+ };
202
+ walk(nodes, '');
203
+ return out;
204
+ }
@@ -0,0 +1,113 @@
1
+ import { prisma } from '@/lib/prisma';
2
+ import type { InboxNotification, NotificationLevel } from './inbox-types';
3
+
4
+ /**
5
+ * 站内消息服务(服务端)。
6
+ *
7
+ * 任何业务模块都可以调用 `notify()` 给一个或多个用户写消息;
8
+ * 收件人、已读状态和跳转链接都由调用方决定,消息中心本身不关心业务。
9
+ *
10
+ * 设计取舍:
11
+ * - 一条记录只属于一个用户。广播消息由这里拆成多条写入,
12
+ * 换取最简单的「单用户已读」语义,也避免再维护一张已读表。
13
+ * - 写消息失败绝不阻塞主业务:消息是通知,不是交易。
14
+ * - link 只允许站内路径,防止业务侧传进外链造成开放重定向。
15
+ */
16
+
17
+ export type NotifyInput = {
18
+ userId?: string;
19
+ userIds?: string[];
20
+ title: string;
21
+ body?: string | null;
22
+ type?: string;
23
+ level?: NotificationLevel;
24
+ link?: string | null;
25
+ };
26
+
27
+ const LEVELS = new Set<NotificationLevel>(['info', 'success', 'warning', 'error']);
28
+
29
+ function normalizeLevel(value: unknown): NotificationLevel {
30
+ return LEVELS.has(value as NotificationLevel) ? (value as NotificationLevel) : 'info';
31
+ }
32
+
33
+ function normalizeLink(value: unknown): string | null {
34
+ const link = typeof value === 'string' ? value.trim() : '';
35
+ if (!link || !link.startsWith('/')) return null;
36
+ return link.slice(0, 500);
37
+ }
38
+
39
+ /** 给一个或多个用户写消息;重复 userId 会自动去重 */
40
+ export async function notify(input: NotifyInput): Promise<void> {
41
+ try {
42
+ const ids = Array.from(
43
+ new Set([...(input.userIds ?? []), ...(input.userId ? [input.userId] : [])].filter(Boolean))
44
+ );
45
+ if (!ids.length) return;
46
+
47
+ const title = String(input.title ?? '').trim();
48
+ if (!title) return;
49
+
50
+ await prisma.notification.createMany({
51
+ data: ids.map((userId) => ({
52
+ userId,
53
+ title: title.slice(0, 200),
54
+ body: input.body ? String(input.body).slice(0, 1000) : null,
55
+ type: (input.type ?? 'system').slice(0, 40),
56
+ level: normalizeLevel(input.level),
57
+ link: normalizeLink(input.link),
58
+ })),
59
+ });
60
+ } catch (e) {
61
+ // 消息写不进去也不能让业务回滚;日志留给管理员排查
62
+ console.error('[notify] 写入站内消息失败(已忽略,不影响主业务)', e);
63
+ }
64
+ }
65
+
66
+ export async function notifyUsers(
67
+ userIds: string[],
68
+ input: Omit<NotifyInput, 'userId' | 'userIds'>
69
+ ): Promise<void> {
70
+ await notify({ ...input, userIds });
71
+ }
72
+
73
+ /** 最近消息(含已读);消息中心不需要无限历史,默认取最近 100 条 */
74
+ export async function listNotifications(userId: string, limit = 100): Promise<InboxNotification[]> {
75
+ const rows = await prisma.notification.findMany({
76
+ where: { userId },
77
+ orderBy: { createdAt: 'desc' },
78
+ take: Math.min(200, Math.max(1, limit)),
79
+ });
80
+
81
+ return rows.map((row) => ({
82
+ id: row.id,
83
+ title: row.title,
84
+ body: row.body,
85
+ type: row.type,
86
+ level: normalizeLevel(row.level),
87
+ link: row.link,
88
+ readAt: row.readAt ? row.readAt.toISOString() : null,
89
+ createdAt: row.createdAt.toISOString(),
90
+ }));
91
+ }
92
+
93
+ export async function countUnreadNotifications(userId: string): Promise<number> {
94
+ return prisma.notification.count({ where: { userId, readAt: null } });
95
+ }
96
+
97
+ /** 标记一条消息已读,返回剩余未读数;只能操作自己的消息 */
98
+ export async function markNotificationRead(userId: string, id: string): Promise<number> {
99
+ await prisma.notification.updateMany({
100
+ where: { id, userId, readAt: null },
101
+ data: { readAt: new Date() },
102
+ });
103
+ return countUnreadNotifications(userId);
104
+ }
105
+
106
+ /** 标记当前用户全部消息已读 */
107
+ export async function markAllNotificationsRead(userId: string): Promise<number> {
108
+ await prisma.notification.updateMany({
109
+ where: { userId, readAt: null },
110
+ data: { readAt: new Date() },
111
+ });
112
+ return 0;
113
+ }