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,109 @@
1
+ import { prisma } from '@/lib/prisma';
2
+ import {
3
+ ONLINE_WINDOW_MS,
4
+ VISIT_DEDUPE_MS,
5
+ startOfDayInTz,
6
+ type VisitStats,
7
+ } from '@/lib/visits';
8
+
9
+ /**
10
+ * 访问统计的**取数与写数实现**(仅服务端,客户端不要 import —— 会拖进 prisma)。
11
+ *
12
+ * 分工和徽标那套一致:
13
+ * visits.ts 口径、常量、纯函数(客户端安全)
14
+ * visit-stats.ts 查库 / 写库(仅服务端)
15
+ *
16
+ * 两条硬约定:
17
+ * 1. **统计永远不能拖垮业务**。埋点写失败、计数查失败,由调用方决定降级,
18
+ * 但这里不做任何"失败就 throw 到页面上"的动作。
19
+ * 2. **心跳只更新访客,不写访问记录**。在线状态和访问量是两件事,
20
+ * 混在一起会让"总访问量"每隔一分钟自己涨一格。
21
+ */
22
+
23
+ export type TrackVisitInput = {
24
+ /** 客户端生成的匿名访客 id(已通过 isVisitorId 校验) */
25
+ visitorId: string;
26
+ /** 已登录时记下账号;未登录为 null */
27
+ userId?: string | null;
28
+ /** 页面路由;缺省 = 心跳 */
29
+ path?: string;
30
+ ip?: string;
31
+ userAgent?: string;
32
+ };
33
+
34
+ /**
35
+ * 记录一次访客活动。
36
+ *
37
+ * @param input 访客信息;path 有值才写 Visit(页面浏览),否则只刷新在线状态
38
+ * @returns 是否真的写了一条访问记录(用于接口回执)
39
+ */
40
+ export async function trackVisit(input: TrackVisitInput, now: Date = new Date()): Promise<boolean> {
41
+ const { visitorId, userId = null, path, ip, userAgent } = input;
42
+
43
+ const touch = prisma.visitor.upsert({
44
+ where: { visitorId },
45
+ create: {
46
+ visitorId,
47
+ userId,
48
+ ip,
49
+ userAgent,
50
+ firstSeenAt: now,
51
+ lastSeenAt: now,
52
+ },
53
+ update: {
54
+ lastSeenAt: now,
55
+ // 登录 / 登出都可能发生,每次以当前会话为准;
56
+ // 保留游客行不删,历史访问记录仍然算数
57
+ userId,
58
+ ip,
59
+ userAgent,
60
+ },
61
+ });
62
+
63
+ if (!path) {
64
+ await touch;
65
+ return false;
66
+ }
67
+
68
+ // 同一访客同一页面短时间内重复上报(StrictMode、F5、并发心跳)只算一次。
69
+ // 查最近一条即可,[visitorId, createdAt] 索引就是为这个场景建的。
70
+ const [recent] = await Promise.all([
71
+ prisma.visit.findFirst({
72
+ where: {
73
+ visitorId,
74
+ path,
75
+ createdAt: { gte: new Date(now.getTime() - VISIT_DEDUPE_MS) },
76
+ },
77
+ select: { id: true },
78
+ }),
79
+ touch,
80
+ ]);
81
+
82
+ if (recent) return false;
83
+
84
+ await prisma.visit.create({
85
+ data: { visitorId, userId, path, ip, userAgent, createdAt: now },
86
+ });
87
+ return true;
88
+ }
89
+
90
+ /**
91
+ * 顶栏三个数字。
92
+ *
93
+ * 三条 count 并行发:在线数走 Visitor.lastSeenAt 索引,今日 / 总量走 Visit.createdAt 索引,
94
+ * 在 SQLite 上都是毫秒级。演示规模不引入缓存层 —— 数字要是"缓存过的旧值",
95
+ * 反而会让评委怀疑系统没在实时统计。
96
+ */
97
+ export async function getVisitStats(now: Date = new Date()): Promise<VisitStats> {
98
+ const [online, today, total] = await Promise.all([
99
+ prisma.visitor.count({
100
+ where: { lastSeenAt: { gte: new Date(now.getTime() - ONLINE_WINDOW_MS) } },
101
+ }),
102
+ prisma.visit.count({
103
+ where: { createdAt: { gte: startOfDayInTz(now) } },
104
+ }),
105
+ prisma.visit.count(),
106
+ ]);
107
+
108
+ return { online, today, total, updatedAt: now.toISOString() };
109
+ }
@@ -0,0 +1,111 @@
1
+ import dayjs from 'dayjs';
2
+ import utc from 'dayjs/plugin/utc';
3
+ import timezone from 'dayjs/plugin/timezone';
4
+
5
+ /**
6
+ * 访问统计 —— 共享元信息与纯函数(客户端安全)。
7
+ *
8
+ * ★ 这个文件**必须保持客户端安全**:顶栏组件、埋点组件都会 import 它,
9
+ * 所以这里只放常量与纯函数,一行数据库代码都不能有。
10
+ * 真正的"怎么记、怎么数"在 visit-stats.ts(仅服务端)。
11
+ *
12
+ * 口径先说清楚,避免三个人看到三个不同的数字:
13
+ * 在线人数 = 最近 ONLINE_WINDOW_MS 内有心跳的访客数(一个浏览器算一个)
14
+ * 今日访问 = 今天 00:00(VISIT_TIMEZONE)起的页面浏览量(PV)
15
+ * 总访问量 = 上线以来的累计页面浏览量(PV)
16
+ *
17
+ * 为什么"访问"按 PV 算而不是按访客:
18
+ * 后台系统的价值在于"哪些功能被反复使用",按人去重会把翻十页和只看一眼
19
+ * 算成一样。需要 UV 时,Visitor / Visit 两张表都留着 visitorId,随时能补。
20
+ */
21
+
22
+ dayjs.extend(utc);
23
+ dayjs.extend(timezone);
24
+
25
+ /** 顶栏一次 GET 拉回来的三个数字 */
26
+ export type VisitStats = {
27
+ /** 当前在线访客数(去重到浏览器) */
28
+ online: number;
29
+ /** 今日页面浏览量 */
30
+ today: number;
31
+ /** 历史累计页面浏览量 */
32
+ total: number;
33
+ /** 服务端统计时刻(ISO),便于前端判断数据新鲜度 */
34
+ updatedAt: string;
35
+ };
36
+
37
+ /** 「在线」口径:最近 3 分钟内有心跳。必须大于心跳间隔,否则停在某页的人会莫名掉线 */
38
+ export const ONLINE_WINDOW_MS = 3 * 60_000;
39
+ /** 页面停留时的心跳间隔(只刷新 lastSeenAt,不产生访问量) */
40
+ export const VISIT_HEARTBEAT_MS = 60_000;
41
+ /** 顶栏数字的刷新间隔 */
42
+ export const VISIT_REFRESH_MS = 30_000;
43
+ /** 同一访客同一页面在这个窗口内只记一次:防 React StrictMode / 快速刷新重复计数 */
44
+ export const VISIT_DEDUPE_MS = 15_000;
45
+ /** 「今日」按这个时区切分,跟服务器部署在哪个时区无关 */
46
+ export const VISIT_TIMEZONE = process.env.NEXT_PUBLIC_APP_TIMEZONE || 'Asia/Shanghai';
47
+
48
+ /** 埋点写成功后广播的事件:顶栏收到后立刻刷新,不必干等下一轮轮询 */
49
+ export const VISIT_STATS_EVENT = 'biz:visit-stats-refresh';
50
+
51
+ /** 匿名访客 id 的存储键 */
52
+ export const VISITOR_ID_KEY = 'biz:visitor-id';
53
+
54
+ /** 8-64 位字母 / 数字 / 下划线 / 连字符;服务端与客户端共用同一套校验 */
55
+ const VISITOR_ID_RE = /^[A-Za-z0-9_-]{8,64}$/;
56
+
57
+ export function isVisitorId(value: unknown): value is string {
58
+ return typeof value === 'string' && VISITOR_ID_RE.test(value);
59
+ }
60
+
61
+ /** 生成一个新的匿名访客 id(不是安全凭证,够唯一即可) */
62
+ export function newVisitorId(): string {
63
+ const c = globalThis.crypto;
64
+ if (c && typeof c.randomUUID === 'function') return c.randomUUID();
65
+ return `v${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
66
+ }
67
+
68
+ /** localStorage 不可用时的兜底:至少保证本次页面生命周期内 id 稳定 */
69
+ let fallbackVisitorId: string | null = null;
70
+
71
+ /**
72
+ * 取当前浏览器的匿名访客 id,没有就生成并存进 localStorage。
73
+ * SSR 阶段返回 null(调用方只应在 effect 里使用)。
74
+ */
75
+ export function getOrCreateVisitorId(): string | null {
76
+ if (typeof window === 'undefined') return null;
77
+ try {
78
+ const saved = window.localStorage.getItem(VISITOR_ID_KEY);
79
+ if (isVisitorId(saved)) return saved;
80
+ const id = newVisitorId();
81
+ window.localStorage.setItem(VISITOR_ID_KEY, id);
82
+ return id;
83
+ } catch {
84
+ // 隐私模式 / 禁用存储:退回进程内 id,不影响统计,只是不跨页面持久
85
+ fallbackVisitorId ??= newVisitorId();
86
+ return fallbackVisitorId;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * 页面路径归一化:去掉 query / hash / 首尾空白,只留站内路由。
92
+ * 非法路径返回 undefined(当作心跳处理,不写访问记录)——
93
+ * 统计接口是公开的,不能让它变成任意字符串的垃圾桶。
94
+ */
95
+ export function normalizeVisitPath(raw: unknown): string | undefined {
96
+ if (typeof raw !== 'string') return undefined;
97
+ const path = raw.trim().split('?')[0].split('#')[0].slice(0, 300);
98
+ if (!path.startsWith('/') || path.startsWith('//')) return undefined;
99
+ return path;
100
+ }
101
+
102
+ /** 某个时刻在指定时区的"今天 00:00"对应的 Date */
103
+ export function startOfDayInTz(now: Date, tz: string = VISIT_TIMEZONE): Date {
104
+ return dayjs(now).tz(tz).startOf('day').toDate();
105
+ }
106
+
107
+ /** 数字 → 顶栏展示文案;未加载 / 非法值显示占位符,不显示误导性的 0 */
108
+ export function formatVisitCount(n: number | null | undefined): string {
109
+ if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return '—';
110
+ return Math.floor(n).toLocaleString('zh-CN');
111
+ }
@@ -0,0 +1,44 @@
1
+ import { z } from 'zod';
2
+ import { normalizeFieldValue, validateField, type ValidationOptions } from '@/lib/validation';
3
+
4
+ /**
5
+ * 把统一的校验底座接到 zod 上。
6
+ *
7
+ * 用法(接口入参):
8
+ * username: zField('username', { label: '用户名' }) // 必填
9
+ * phone: zNullableField('phone', { label: '手机号' }) // 选填,允许 null
10
+ * password: zOptionalField('password', { label: '密码' }) // 选填,允许缺省
11
+ *
12
+ * 这样后端和前端表单用的是同一套规则、同一句提示语 ——
13
+ * 前端被绕过(直接调接口)时,后端仍然能用用户看得懂的话拒绝。
14
+ */
15
+
16
+ function build(type: string, options: ValidationOptions) {
17
+ return z
18
+ .string()
19
+ .transform((value) => normalizeFieldValue(type, value))
20
+ .superRefine((value, ctx) => {
21
+ const result = validateField(type, value, options);
22
+ if (!result.ok) {
23
+ ctx.addIssue({ code: 'custom', message: result.message });
24
+ }
25
+ });
26
+ }
27
+
28
+ /** 必填字段。缺省/null 也先用空串走一遍底座,保证提示是「请输入 xxx」而不是英文类型错误 */
29
+ export function zField(type: string, options: Omit<ValidationOptions, 'required'> = {}) {
30
+ return z.preprocess(
31
+ (value) => (value === undefined || value === null ? '' : value),
32
+ build(type, { ...options, required: true })
33
+ );
34
+ }
35
+
36
+ /** 选填字符串字段:允许不传、允许空字符串 */
37
+ export function zOptionalField(type: string, options: Omit<ValidationOptions, 'required'> = {}) {
38
+ return build(type, { ...options, required: false }).optional();
39
+ }
40
+
41
+ /** 可空字段:允许 null / undefined / 空字符串(接口里清空选填项很常见) */
42
+ export function zNullableField(type: string, options: Omit<ValidationOptions, 'required'> = {}) {
43
+ return build(type, { ...options, required: false }).nullish();
44
+ }
@@ -0,0 +1,41 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": [
5
+ "dom",
6
+ "dom.iterable",
7
+ "esnext"
8
+ ],
9
+ "allowJs": true,
10
+ "skipLibCheck": true,
11
+ "strict": true,
12
+ "noEmit": true,
13
+ "esModuleInterop": true,
14
+ "module": "esnext",
15
+ "moduleResolution": "bundler",
16
+ "resolveJsonModule": true,
17
+ "isolatedModules": true,
18
+ "jsx": "preserve",
19
+ "incremental": true,
20
+ "plugins": [
21
+ {
22
+ "name": "next"
23
+ }
24
+ ],
25
+ "paths": {
26
+ "@/*": [
27
+ "./src/*"
28
+ ]
29
+ }
30
+ },
31
+ "include": [
32
+ "**/*.ts",
33
+ "**/*.tsx",
34
+ ".next/types/**/*.ts",
35
+ "next-env.d.ts"
36
+ ],
37
+ "exclude": [
38
+ "node_modules",
39
+ ".next"
40
+ ]
41
+ }
@@ -0,0 +1,16 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import react from '@vitejs/plugin-react';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ test: {
8
+ environment: 'jsdom',
9
+ globals: true,
10
+ setupFiles: ['./vitest.setup.ts'],
11
+ include: ['src/**/*.{test,spec}.{ts,tsx}'],
12
+ },
13
+ resolve: {
14
+ alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
15
+ },
16
+ });
@@ -0,0 +1 @@
1
+ import '@testing-library/jest-dom/vitest';