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,204 @@
1
+ /* ---------------------------------------------------------------
2
+ 操作反馈层(区别于「等待反馈」)
3
+ 归属 components/feedback,由 app/globals.css 统一 @import 进来。
4
+
5
+ 这一层只回答一个问题:**我这一下,系统收到了吗?**
6
+ 等待反馈回答的是「系统还在忙吗」,两者不要混着做。
7
+
8
+ 三条约束(改之前先读):
9
+ 1. 只动 transform / opacity / box-shadow / 背景色 —— 不触发重排
10
+ 2. 时长只用下面四个变量,位移 ≤2px,缩放 ≤0.02
11
+ 3. 所有规则都要在 prefers-reduced-motion 下有静默版本
12
+
13
+ ★ 调力度只改 :root 里的变量,不要改下面的规则。
14
+ 新题想更"稳"一点:把 --wb-dur 调到 0.24s、--wb-lift 保持 -1px。
15
+ 动效超过 0.24s 就开始像"系统在表演",别加。
16
+ --------------------------------------------------------------- */
17
+
18
+ :root {
19
+ /* 手感:按下最快,悬停居中;这两个是"瞬时反应",不能用慢曲线 */
20
+ --wb-dur-fast: 0.08s;
21
+ --wb-dur: 0.18s;
22
+ --wb-dur-slow: 0.24s;
23
+
24
+ /* 结果落地:一行闪完的总时长。0.5s 是"看见但不打断"的平衡点 */
25
+ --wb-flash-dur: 0.5s;
26
+
27
+ --wb-ease: cubic-bezier(0.4, 0, 0.2, 1);
28
+ --wb-lift: -1px;
29
+ --wb-shrink: 0.995;
30
+
31
+ /* 阴影 / 描边 / 悬停底色不在这里定死:外观设置换皮肤时要跟着变,
32
+ 所以它们由 lib/appearance.ts 的 motion 字段生成,见 globals.css。
33
+ 这里只保留"手感"常量(时长、位移、曲线)。 */
34
+ }
35
+
36
+ /* ① 可点击卡片:悬停抬起 1px,按下回落到 0 并轻微内缩
37
+ ---------------------------------------------------------------
38
+ 这个类**只在真能点的时候加**。
39
+ 由 PressableCard 自动判断(有 href / onClick 才加),
40
+ 手写 div 的时候自己负责 —— 不能点的卡加了,比完全不动更像 bug。
41
+ --------------------------------------------------------------- */
42
+
43
+ /* 基础态写在最前面:后面的 :hover / :active 才能盖住它 */
44
+ .ant-card.wb-pressable {
45
+ position: relative;
46
+ border-color: var(--wb-border-idle);
47
+ box-shadow: 0 1px 2px var(--wb-shade-idle);
48
+ }
49
+
50
+ .wb-pressable {
51
+ transition:
52
+ transform var(--wb-dur) var(--wb-ease),
53
+ box-shadow var(--wb-dur) var(--wb-ease),
54
+ border-color var(--wb-dur) var(--wb-ease),
55
+ background-color var(--wb-dur) var(--wb-ease);
56
+ }
57
+
58
+ .wb-pressable:hover {
59
+ transform: translateY(var(--wb-lift));
60
+ box-shadow: 0 4px 12px var(--wb-shade-hover);
61
+ border-color: var(--wb-border-hover);
62
+ }
63
+
64
+ /* 卡片底色也提一点点暖白:抬起 + 变亮,比只加阴影更像"浮起来" */
65
+ .ant-card.wb-pressable:hover {
66
+ background-color: var(--wb-surface-hover);
67
+ }
68
+
69
+ /* 按下用更短的时长:按下去要"利落",回弹才从容 */
70
+ .wb-pressable:active {
71
+ transform: translateY(0) scale(var(--wb-shrink));
72
+ box-shadow: 0 1px 3px var(--wb-shade-press);
73
+ transition-duration: var(--wb-dur-fast);
74
+ }
75
+
76
+ /* 键盘用户也得看得见焦点,不能只有鼠标有反馈 */
77
+ .wb-pressable:focus-visible {
78
+ outline: 2px solid var(--wb-primary);
79
+ outline-offset: 2px;
80
+ }
81
+
82
+ /* 可点击卡片右上角的「›」提示:悬停时淡入并右移 1px。
83
+ 它的作用是提前告诉用户"这张卡能点",而不是点完才知道。 */
84
+ .wb-pressable__cue {
85
+ position: absolute;
86
+ top: 12px;
87
+ right: 13px;
88
+ font-size: 13px;
89
+ line-height: 1;
90
+ color: var(--wb-text-3);
91
+ opacity: 0;
92
+ transform: translateX(-3px);
93
+ transition:
94
+ opacity var(--wb-dur) ease,
95
+ transform var(--wb-dur) ease,
96
+ color var(--wb-dur) ease;
97
+ pointer-events: none;
98
+ }
99
+
100
+ .wb-pressable:hover .wb-pressable__cue,
101
+ .wb-pressable:focus-visible .wb-pressable__cue {
102
+ opacity: 1;
103
+ transform: translateX(0);
104
+ color: var(--wb-primary);
105
+ }
106
+
107
+ /* ② 结果落地:目标行 / 卡片闪一层卡其色再褪回去
108
+ ---------------------------------------------------------------
109
+ toast 说的是「操作成功了」,这一下说的是「成功在这个位置」。
110
+ 改完一条记录找不到它在哪一行,是最常见的困惑。
111
+ --------------------------------------------------------------- */
112
+
113
+ @keyframes wb-flash-bg {
114
+ 0% {
115
+ background-color: transparent;
116
+ }
117
+ 20% {
118
+ background-color: var(--wb-flash);
119
+ }
120
+ 100% {
121
+ background-color: transparent;
122
+ }
123
+ }
124
+
125
+ /* 表格行:antd 把底色画在 td 上,动画必须打给 td,否则被单元格底色盖住 */
126
+ .wb-flash > td,
127
+ .wb-flash.ant-table-row > td {
128
+ animation: wb-flash-bg var(--wb-flash-dur) ease-out;
129
+ }
130
+
131
+ /* 非表格元素(卡片、列表项)直接用 */
132
+ .wb-flash {
133
+ animation: wb-flash-bg var(--wb-flash-dur) ease-out;
134
+ }
135
+
136
+ /* 同一行连续触发两次时,类名不变动画不会重播。
137
+ 给个同内容的新动画名,JS 按奇偶切换即可强制重启。 */
138
+ @keyframes wb-flash-bg-alt {
139
+ 0% {
140
+ background-color: transparent;
141
+ }
142
+ 20% {
143
+ background-color: var(--wb-flash);
144
+ }
145
+ 100% {
146
+ background-color: transparent;
147
+ }
148
+ }
149
+
150
+ .wb-flash-alt > td,
151
+ .wb-flash-alt.ant-table-row > td {
152
+ animation: wb-flash-bg-alt var(--wb-flash-dur) ease-out;
153
+ }
154
+
155
+ .wb-flash-alt {
156
+ animation: wb-flash-bg-alt var(--wb-flash-dur) ease-out;
157
+ }
158
+
159
+ /* ③ 表格行悬停:左侧浮出一条 3px 卡其定位条
160
+ ---------------------------------------------------------------
161
+ 和卡片标题的「左侧定位条」是同一套语言,不用新增类,
162
+ 所有走 antd Table 的列表页自动获得。 */
163
+ .ant-table-tbody > tr.ant-table-row > td:first-child {
164
+ transition: box-shadow var(--wb-dur) ease;
165
+ }
166
+
167
+ .ant-table-tbody > tr.ant-table-row:hover > td:first-child {
168
+ box-shadow: inset 3px 0 0 var(--wb-primary);
169
+ }
170
+
171
+ /* ④ 降级:系统开了「减少动态效果」时,功能一个不能少,只是不动
172
+ --------------------------------------------------------------- */
173
+ @media (prefers-reduced-motion: reduce) {
174
+ .wb-pressable,
175
+ .wb-pressable:hover,
176
+ .wb-pressable:active {
177
+ transition: none;
178
+ transform: none;
179
+ }
180
+
181
+ /* 抬起没了,但"能点"这件事还得靠阴影表达 */
182
+ .wb-pressable:hover {
183
+ box-shadow: 0 4px 12px var(--wb-shade-hover);
184
+ }
185
+
186
+ .wb-pressable__cue {
187
+ transition: none;
188
+ opacity: 1;
189
+ transform: none;
190
+ }
191
+
192
+ .wb-flash,
193
+ .wb-flash > td,
194
+ .wb-flash.ant-table-row > td,
195
+ .wb-flash-alt,
196
+ .wb-flash-alt > td,
197
+ .wb-flash-alt.ant-table-row > td {
198
+ animation: none;
199
+ }
200
+
201
+ .ant-table-tbody > tr.ant-table-row > td:first-child {
202
+ transition: none;
203
+ }
204
+ }
@@ -0,0 +1,63 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * 操作反馈的命令式工具。
5
+ *
6
+ * 为什么不全用 className 切换?
7
+ * 同一个元素连续触发两次时,classList.add 一个已经存在的类不会重播动画。
8
+ * 要么切奇偶两个动画名,要么强制 reflow —— 这里两样都做了,代价最小。
9
+ *
10
+ * 对应的视觉定义在 ./motion.css。
11
+ */
12
+
13
+ /**
14
+ * 与 motion.css 的 `--wb-flash-dur` 必须保持一致。
15
+ * 改这里就同时改那边,否则类会被提前摘掉、动画被截断。
16
+ */
17
+ export const FLASH_DURATION_MS = 500;
18
+
19
+ /** 多留一点余量再摘类:低于动画时长会看到"闪到一半突然没了" */
20
+ const FLASH_CLEANUP_MS = FLASH_DURATION_MS + 40;
21
+
22
+ function prefersReducedMotion(): boolean {
23
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
24
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
25
+ }
26
+
27
+ /**
28
+ * 让某个元素闪一下(表格传 `<tr>`,普通元素直接传自己)。
29
+ *
30
+ * @param el 目标元素;null 时静默返回,调用方不用判空
31
+ * @param tick 自增序号,用来在奇偶两个动画名之间切换,保证连续触发也能重播
32
+ */
33
+ export function flashElement(el: Element | null, tick = 0): void {
34
+ if (!el || prefersReducedMotion()) return;
35
+
36
+ const head = tick % 2 === 0 ? 'wb-flash' : 'wb-flash-alt';
37
+ const tail = tick % 2 === 0 ? 'wb-flash-alt' : 'wb-flash';
38
+
39
+ el.classList.remove(tail);
40
+ // 读一次布局属性,强制样式失效重算;不这么做,浏览器会把"删了又加"合并成没变
41
+ void (el as HTMLElement).offsetWidth;
42
+ el.classList.add(head);
43
+
44
+ window.setTimeout(() => el.classList.remove(head), FLASH_CLEANUP_MS);
45
+ }
46
+
47
+ /**
48
+ * 按行 key 找表格行并闪一下。
49
+ *
50
+ * antd 的 Table 会给每个 `<tr>` 打上 `data-row-key`,这是唯一稳定的定位依据
51
+ * (行下标会随排序翻页漂移,不能用)。
52
+ */
53
+ export function flashTableRow(rowKey: string | number, tick = 0): boolean {
54
+ if (typeof document === 'undefined') return false;
55
+
56
+ // 只转义引号和反斜杠就够了:rowKey 是 cuid / 数字,不会是奇怪的 CSS 选择器
57
+ const safe = String(rowKey).replace(/["\\]/g, '\\$&');
58
+ const tr = document.querySelector(`tr[data-row-key="${safe}"]`);
59
+ if (!tr) return false;
60
+
61
+ flashElement(tr, tick);
62
+ return true;
63
+ }
@@ -0,0 +1,110 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+
5
+ /**
6
+ * 数字滚动:让「值变了」被看见,而不是唰地跳过去。
7
+ *
8
+ * 看板上的统计卡有个通病:骨架屏一撤,数字直接出现,
9
+ * 用户分不清「刚加载完」和「本来就是 5」。滚一下,这两个状态就分开了。
10
+ *
11
+ * 用法:
12
+ * const n = useCountUp(data?.userCount, { enabled: !isLoading });
13
+ * <Statistic value={n} />
14
+ *
15
+ * ⚠️ 一定要放在**独立的子组件**里调用。
16
+ * 这个 hook 每帧都会 setState,如果直接写在页面组件里,
17
+ * 60fps 会把整页重渲染 20 次 —— 5 张卡就是 100 次。
18
+ */
19
+
20
+ /** cubic ease-out:起步快、收尾稳,像指针停下来的感觉 */
21
+ const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
22
+
23
+ function prefersReducedMotion(): boolean {
24
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
25
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
26
+ }
27
+
28
+ export type CountUpOptions = {
29
+ /**
30
+ * 动画时长(毫秒),默认 320。
31
+ * 超过 400 就开始像"系统在表演",统计数字不值得这么久。
32
+ */
33
+ duration?: number;
34
+ /**
35
+ * 是否启用动画。数据还在 loading 时传 false,
36
+ * 等真实值到了再滚 —— 否则会先滚一遍 0,再滚一遍真值。
37
+ */
38
+ enabled?: boolean;
39
+ };
40
+
41
+ /**
42
+ * @param target 目标值;undefined / null / NaN 一律当 0
43
+ * @returns 当前应显示的值(整数)
44
+ */
45
+ export function useCountUp(
46
+ target: number | null | undefined,
47
+ options: CountUpOptions = {}
48
+ ): number {
49
+ const { duration = 320, enabled = true } = options;
50
+
51
+ const to = typeof target === 'number' && Number.isFinite(target) ? target : 0;
52
+
53
+ const [display, setDisplay] = useState(0);
54
+ /** 和 display 同步的 ref:effect 里要读"现在停在哪",但不能把它写进依赖 */
55
+ const shownRef = useRef(0);
56
+ const frameRef = useRef<number | null>(null);
57
+
58
+ useEffect(() => {
59
+ // 卸载 / 重新触发前先撤掉上一帧,避免两条动画抢着写同一个 state
60
+ if (frameRef.current !== null) {
61
+ cancelAnimationFrame(frameRef.current);
62
+ frameRef.current = null;
63
+ }
64
+
65
+ if (!enabled) return;
66
+
67
+ const from = shownRef.current;
68
+ if (from === to) return;
69
+
70
+ // 用户系统开了「减少动态效果」→ 直接给终值,不装这个动效
71
+ if (prefersReducedMotion() || duration <= 0) {
72
+ shownRef.current = to;
73
+ setDisplay(to);
74
+ return;
75
+ }
76
+
77
+ const start = performance.now();
78
+
79
+ const run = (now: number) => {
80
+ const progress = Math.min(1, (now - start) / duration);
81
+ const value = from + (to - from) * easeOut(progress);
82
+ const next = Math.round(value);
83
+
84
+ // 只在整数真的变了才 setState:320ms 里最多 60 次渲染 → 通常只有几次
85
+ if (next !== shownRef.current) {
86
+ shownRef.current = next;
87
+ setDisplay(next);
88
+ }
89
+
90
+ if (progress < 1) {
91
+ frameRef.current = requestAnimationFrame(run);
92
+ } else {
93
+ shownRef.current = to;
94
+ setDisplay(to);
95
+ frameRef.current = null;
96
+ }
97
+ };
98
+
99
+ frameRef.current = requestAnimationFrame(run);
100
+
101
+ return () => {
102
+ if (frameRef.current !== null) {
103
+ cancelAnimationFrame(frameRef.current);
104
+ frameRef.current = null;
105
+ }
106
+ };
107
+ }, [to, duration, enabled]);
108
+
109
+ return display;
110
+ }
@@ -0,0 +1,209 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import { beginTask, endTask, WAIT } from '@/lib/pending';
5
+
6
+ /**
7
+ * 等待反馈的三个基础 hook。
8
+ *
9
+ * 统一解决三件容易被忽略、但直接决定"等起来尴尬不尴尬"的事:
10
+ * 1. 快请求不要闪 spinner(延迟出现)
11
+ * 2. 慢请求要让用户看到「过了多久」(而不是只有一个转圈)
12
+ * 3. 超长请求文案要升级,并且最好能取消
13
+ */
14
+
15
+ /** 一个任务阶段:`after` 毫秒之后,文案换成 `text` */
16
+ export type TaskStage = { after: number; text: string };
17
+
18
+ /**
19
+ * 默认三阶段文案。语义台阶是「在做 → 还没完 → 有点慢但没死」:
20
+ * 用户看到第二、第三句时,就不会去点第二次了。
21
+ */
22
+ export const DEFAULT_STAGES: TaskStage[] = [
23
+ { after: 0, text: '正在处理…' },
24
+ { after: WAIT.GLOBAL_APPEAR, text: '仍在处理,请稍候…' },
25
+ { after: WAIT.SLOW, text: '处理时间较长,仍在继续…' },
26
+ ];
27
+
28
+ /** 按已耗时挑当前阶段文案 */
29
+ export function stageAt(stages: TaskStage[], ms: number): string {
30
+ let text = stages[0]?.text ?? '正在处理…';
31
+ for (const s of stages) {
32
+ if (ms >= s.after) text = s.text;
33
+ }
34
+ return text;
35
+ }
36
+
37
+ /** 把毫秒说成「3 秒」这种能一眼扫过的量级 */
38
+ export function formatElapsed(ms: number): string {
39
+ const s = ms / 1000;
40
+ if (s < 10) return `${s.toFixed(1)} 秒`;
41
+ return `${Math.round(s)} 秒`;
42
+ }
43
+
44
+ /**
45
+ * 延迟显示。
46
+ *
47
+ * `pending` 变 true 后先等 `delay` 毫秒再返回 true;一结束立刻返回 false。
48
+ * 效果:200ms 内返回的请求,用户完全看不到 spinner 闪一下。
49
+ */
50
+ export function useDelayedLoading(pending: boolean, delay: number = WAIT.LOCAL_DELAY): boolean {
51
+ const [visible, setVisible] = useState(false);
52
+
53
+ useEffect(() => {
54
+ if (!pending) {
55
+ setVisible(false);
56
+ return;
57
+ }
58
+ if (delay <= 0) {
59
+ setVisible(true);
60
+ return;
61
+ }
62
+ const timer = setTimeout(() => setVisible(true), delay);
63
+ return () => clearTimeout(timer);
64
+ }, [pending, delay]);
65
+
66
+ return visible;
67
+ }
68
+
69
+ /**
70
+ * 已耗时(毫秒)。`active` 为 false 时恒为 0。
71
+ *
72
+ * 只在 active 期间开 interval,且默认 400ms 一跳 —— 秒数变化足够,
73
+ * 又不会因为每帧 setState 把长任务页面拖卡。
74
+ */
75
+ export function useElapsed(active: boolean, tick = 400): number {
76
+ const [ms, setMs] = useState(0);
77
+ const startRef = useRef(0);
78
+
79
+ useEffect(() => {
80
+ if (!active) {
81
+ setMs(0);
82
+ return;
83
+ }
84
+ startRef.current = Date.now();
85
+ setMs(0);
86
+ const timer = setInterval(() => setMs(Date.now() - startRef.current), tick);
87
+ return () => clearInterval(timer);
88
+ }, [active, tick]);
89
+
90
+ return ms;
91
+ }
92
+
93
+ export type UseAsyncTaskOptions<R> = {
94
+ /** 登记到全局浮层的文案;默认「正在处理…」 */
95
+ label?: string;
96
+ /** true 时只在组件内部显示 loading,不进全局浮层(例如已经有大遮罩的导入) */
97
+ silent?: boolean;
98
+ /** 分阶段文案,不传用 DEFAULT_STAGES */
99
+ stages?: TaskStage[];
100
+ onSuccess?: (result: R) => void;
101
+ onError?: (error: unknown) => void;
102
+ onCancel?: () => void;
103
+ };
104
+
105
+ export type AsyncTask<A extends unknown[], R> = {
106
+ /** 执行;重复调用会被忽略(防重复提交) */
107
+ run: (...args: A) => Promise<R | undefined>;
108
+ /** 立刻中止(底层要响应 signal 才有效,例如 fetch / axios) */
109
+ cancel: () => void;
110
+ /** 是否进行中(立即为 true,用于禁用按钮) */
111
+ pending: boolean;
112
+ /** 延迟后的展示态(用于 spinner,避免闪烁) */
113
+ visible: boolean;
114
+ /** 已耗时毫秒 */
115
+ elapsed: number;
116
+ /** 当前阶段文案 */
117
+ stageText: string;
118
+ };
119
+
120
+ /**
121
+ * 把一个 async 动作打包成「带等待反馈的任务」。
122
+ *
123
+ * 用它是为了不再在页面里手写这一坨:
124
+ * const [loading, setLoading] = useState(false)
125
+ * try { setLoading(true); await xxx() } finally { setLoading(false) }
126
+ * 而且天然自带你手写时最容易漏的三样:防重复提交、已耗时、阶段文案。
127
+ *
128
+ * 注意:默认**不向外抛异常**,错误交给 onError;需要自己 catch 时传 throwOnError。
129
+ */
130
+ export function useAsyncTask<A extends unknown[], R>(
131
+ fn: (signal: AbortSignal, ...args: A) => Promise<R>,
132
+ options: UseAsyncTaskOptions<R> & { throwOnError?: boolean } = {}
133
+ ): AsyncTask<A, R> {
134
+ const fnRef = useRef(fn);
135
+ fnRef.current = fn;
136
+ const optRef = useRef(options);
137
+ optRef.current = options;
138
+
139
+ const [pending, setPending] = useState(false);
140
+ const pendingRef = useRef(false);
141
+ const ctrlRef = useRef<AbortController | null>(null);
142
+ const taskIdRef = useRef<string | null>(null);
143
+
144
+ const elapsed = useElapsed(pending);
145
+ const visible = useDelayedLoading(pending);
146
+
147
+ const cleanup = useCallback(() => {
148
+ endTask(taskIdRef.current);
149
+ taskIdRef.current = null;
150
+ ctrlRef.current = null;
151
+ pendingRef.current = false;
152
+ setPending(false);
153
+ }, []);
154
+
155
+ // 卸载时兜底:任务还挂着就把全局那条撤掉,否则浮层会一直转到用户离开
156
+ useEffect(() => {
157
+ return () => {
158
+ endTask(taskIdRef.current);
159
+ taskIdRef.current = null;
160
+ if (ctrlRef.current) ctrlRef.current.abort();
161
+ };
162
+ }, []);
163
+
164
+ const run = useCallback(
165
+ async (...args: A): Promise<R | undefined> => {
166
+ if (pendingRef.current) return undefined;
167
+ const o = optRef.current;
168
+
169
+ pendingRef.current = true;
170
+ setPending(true);
171
+
172
+ const ctrl = new AbortController();
173
+ ctrlRef.current = ctrl;
174
+ taskIdRef.current = o.silent ? null : beginTask(o.label ?? '正在处理…', 'task');
175
+
176
+ try {
177
+ const result = await fnRef.current(ctrl.signal, ...args);
178
+ o.onSuccess?.(result);
179
+ return result;
180
+ } catch (e) {
181
+ if (ctrl.signal.aborted) {
182
+ o.onCancel?.();
183
+ return undefined;
184
+ }
185
+ o.onError?.(e);
186
+ if (o.throwOnError) throw e;
187
+ return undefined;
188
+ } finally {
189
+ cleanup();
190
+ }
191
+ },
192
+ [cleanup]
193
+ );
194
+
195
+ const cancel = useCallback(() => {
196
+ ctrlRef.current?.abort();
197
+ }, []);
198
+
199
+ const stages = options.stages ?? DEFAULT_STAGES;
200
+
201
+ return {
202
+ run,
203
+ cancel,
204
+ pending,
205
+ visible,
206
+ elapsed,
207
+ stageText: stageAt(stages, elapsed),
208
+ };
209
+ }
@@ -0,0 +1,40 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useState } from 'react';
4
+
5
+ /**
6
+ * 列表页「刚才改的是这一行」的状态容器。
7
+ *
8
+ * 样板是这样的,每个 CRUD 页面都要写一遍,所以抽出来:
9
+ *
10
+ * const { highlightRow, flashRow } = useRowFlash();
11
+ *
12
+ * // 保存/切换状态成功后
13
+ * flashRow(row.id);
14
+ *
15
+ * // 交给表格
16
+ * <DataTable highlightRow={highlightRow} … />
17
+ *
18
+ * 为什么必须带 tick:
19
+ * 同一个 id 连续触发(比如连拨两次开关),key 不变,
20
+ * DataTable 那边就认为"没变化"、不会重播动画。tick 自增解决这件事。
21
+ */
22
+
23
+ export type RowFlashTarget = { key: string | number; tick: number };
24
+
25
+ export type UseRowFlashResult = {
26
+ /** 直接传给 DataTable 的 highlightRow */
27
+ highlightRow: RowFlashTarget | null;
28
+ /** 操作成功后调它;同一行可以反复调 */
29
+ flashRow: (key: string | number) => void;
30
+ };
31
+
32
+ export function useRowFlash(): UseRowFlashResult {
33
+ const [highlightRow, setHighlightRow] = useState<RowFlashTarget | null>(null);
34
+
35
+ const flashRow = useCallback((key: string | number) => {
36
+ setHighlightRow((prev) => ({ key, tick: (prev?.tick ?? 0) + 1 }));
37
+ }, []);
38
+
39
+ return { highlightRow, flashRow };
40
+ }