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,658 @@
1
+ /**
2
+ * 输入合理性校验底座 —— 前端表单、后端接口、AI 动态表单共用同一套规则。
3
+ *
4
+ * ★ 为什么单独抽一层,而不是在每个 Form.Item 里写正则:
5
+ * 比赛会换题/加字段,如果校验规则散落在页面和接口里,每加一个「身份证号」
6
+ * 就要前端写一遍 required、后端再写一遍 zod,提示语还对不上。
7
+ * 这里把「字段类型 → 规整方式 + 校验逻辑 + 友好提示」收敛成注册表:
8
+ * - 新题加字段:优先复用内置类型;不够用时 registerFieldType() 注册一个即可
9
+ * - 前端:formRules() / autoFormRules() 直接产出 antd 规则
10
+ * - 后端:zField() / zOptionalField() / zNullableField() 直接产出 zod schema
11
+ * - AI 表单:inferValidationType() 按字段名/中文标签自动识别,无需人工配置
12
+ *
13
+ * ★ 提示语原则:
14
+ * 1. 说清楚「字段叫什么、错在哪、正确格式长什么样」
15
+ * 2. 不把正则原文、英文报错、堆栈丢给用户
16
+ * 3. 校验前先规整(去空格、身份证 X 大写、手机号去 +86),避免「明明没错却报错」
17
+ */
18
+
19
+ export type ValidationType =
20
+ | 'text'
21
+ | 'name'
22
+ | 'username'
23
+ | 'password'
24
+ | 'idCard'
25
+ | 'phone'
26
+ | 'email'
27
+ | 'contact'
28
+ | 'code'
29
+ | 'bankCard'
30
+ | 'creditCode'
31
+ | 'postalCode'
32
+ | 'url'
33
+ | 'date'
34
+ | 'integer'
35
+ | 'number'
36
+ | 'amount';
37
+
38
+ /** 校验上下文。min/max 对文本是长度,对数字是数值范围 */
39
+ export interface ValidationOptions {
40
+ /** 字段中文名,用于拼出「请输入姓名」「手机号应为 11 位」 */
41
+ label?: string;
42
+ /** 必填。为空时返回「请输入 xxx」 */
43
+ required?: boolean;
44
+ /** 文本最小/最大长度;数字类型下表示数值下限/上限 */
45
+ min?: number;
46
+ max?: number;
47
+ /** 额外正则(在内置规则通过后执行) */
48
+ pattern?: RegExp;
49
+ patternMessage?: string;
50
+ /** 密码强度:basic = 6-64 位;strong = 8-64 位且同时含字母和数字 */
51
+ strength?: 'basic' | 'strong';
52
+ }
53
+
54
+ export type ValidationResult =
55
+ | {
56
+ ok: true;
57
+ /** 规整后的值(去首尾空白、身份证 X 大写、手机号去 +86 等) */
58
+ value: string;
59
+ message?: undefined;
60
+ }
61
+ | {
62
+ ok: false;
63
+ /** 规整后的值(即使不合法也保留,便于表单回填) */
64
+ value: string;
65
+ /** 就直接展示给用户的友好提示 */
66
+ message: string;
67
+ };
68
+
69
+ type CheckContext = ValidationOptions & { label: string };
70
+
71
+ export interface FieldTypeDefinition {
72
+ /** 默认中文名,options.label 未提供时使用 */
73
+ label: string;
74
+ /** 输入框建议 maxLength(仅 UI 用,不参与业务判断) */
75
+ suggestedMaxLength?: number;
76
+ /** 校验前规整。默认去首尾空白 */
77
+ normalize?: (value: string) => string;
78
+ /** 返回 null 表示通过;返回字符串表示友好提示 */
79
+ check: (value: string, ctx: CheckContext) => string | null;
80
+ /** min/max 是否按字符长度解释。数字类型设为 false,交给 check 按数值判断 */
81
+ lengthOptions?: boolean;
82
+ }
83
+
84
+ /* ------------------------------ 基础工具 ------------------------------ */
85
+
86
+ function asText(value: unknown): string {
87
+ if (value === undefined || value === null) return '';
88
+ return typeof value === 'string' ? value : String(value);
89
+ }
90
+
91
+ function trim(value: string): string {
92
+ return value.trim();
93
+ }
94
+
95
+ function charCount(value: string): number {
96
+ return Array.from(value).length;
97
+ }
98
+
99
+ /** 中文输入法下常见的全角数字/字母/符号 → 半角,避免「格式明明没错却报错」 */
100
+ function toHalfWidth(value: string): string {
101
+ return value
102
+ .replace(/[\uFF01-\uFF5E]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0))
103
+ .replace(/\u3000/g, ' ');
104
+ }
105
+
106
+ function normalizePhone(value: string): string {
107
+ let out = toHalfWidth(value).replace(/[\s\-()]/g, '');
108
+ // 允许 +86 / 86 / 0086 前缀;只在后面正好是 11 位时剥离,避免把 86 开头的号码误伤
109
+ out = out.replace(/^(\+?86|0086)(?=\d{11}$)/, '');
110
+ return out;
111
+ }
112
+
113
+ function normalizeDate(value: string): string {
114
+ const s = toHalfWidth(value).trim();
115
+ // 允许 Excel 导出的 `2024-01-31 00:00:00` / ISO 时间:日期字段只保留日期部分
116
+ const m = /^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})(?:[ T].*)?$/.exec(s);
117
+ if (!m) return s;
118
+ return `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}`;
119
+ }
120
+
121
+ function normalizeNumberText(value: string): string {
122
+ // 容忍用户从 Excel / 收据里复制的千分位、货币符号和空格
123
+ return toHalfWidth(value)
124
+ .replace(/[,,\s¥¥$]/g, '')
125
+ .trim();
126
+ }
127
+
128
+ /* ------------------------------ 身份证 ------------------------------ */
129
+
130
+ /** 合法省份前两位。能挡掉 123456… 这类明显编造的号码 */
131
+ const ID_REGION_PREFIXES = new Set([
132
+ '11',
133
+ '12',
134
+ '13',
135
+ '14',
136
+ '15',
137
+ '21',
138
+ '22',
139
+ '23',
140
+ '31',
141
+ '32',
142
+ '33',
143
+ '34',
144
+ '35',
145
+ '36',
146
+ '37',
147
+ '41',
148
+ '42',
149
+ '43',
150
+ '44',
151
+ '45',
152
+ '46',
153
+ '50',
154
+ '51',
155
+ '52',
156
+ '53',
157
+ '54',
158
+ '61',
159
+ '62',
160
+ '63',
161
+ '64',
162
+ '65',
163
+ '71',
164
+ '81',
165
+ '82',
166
+ ]);
167
+
168
+ const ID_WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
169
+ const ID_CHECK_CODES = '10X98765432';
170
+
171
+ function idCardCheckChar(body17: string): string {
172
+ let sum = 0;
173
+ for (let i = 0; i < 17; i++) sum += Number(body17[i]) * ID_WEIGHTS[i];
174
+ return ID_CHECK_CODES[sum % 11];
175
+ }
176
+
177
+ /** 校验身份证里的 YYYYMMDD;返回提示或 null */
178
+ function checkBirthday(birth: string, label: string): string | null {
179
+ if (!/^\d{8}$/.test(birth)) return `${label}中的出生日期不完整`;
180
+ const year = Number(birth.slice(0, 4));
181
+ const month = Number(birth.slice(4, 6));
182
+ const day = Number(birth.slice(6, 8));
183
+ if (year < 1900) return `${label}中的出生年份不合理`;
184
+ const date = new Date(Date.UTC(year, month - 1, day));
185
+ if (
186
+ date.getUTCFullYear() !== year ||
187
+ date.getUTCMonth() !== month - 1 ||
188
+ date.getUTCDate() !== day
189
+ ) {
190
+ return `${label}中的出生日期不存在,请核对`;
191
+ }
192
+ if (date.getTime() > Date.now()) return `${label}中的出生日期不能晚于今天`;
193
+ if (new Date().getUTCFullYear() - year > 150) return `${label}中的出生年份不合理`;
194
+ return null;
195
+ }
196
+
197
+ function checkIdCard(value: string, ctx: CheckContext): string | null {
198
+ const label = ctx.label;
199
+ if (value.length !== 15 && value.length !== 18) {
200
+ return `${label}应为 15 位或 18 位`;
201
+ }
202
+ if (!/^\d+$/.test(value.slice(0, 15))) {
203
+ return `${label}只能包含数字,18 位时最后一位可以是 X`;
204
+ }
205
+ if (value.length === 15) {
206
+ if (!/^\d{15}$/.test(value)) return `15 位${label}应全部为数字`;
207
+ if (!ID_REGION_PREFIXES.has(value.slice(0, 2))) return `${label}前两位地区码不正确,请核对`;
208
+ return checkBirthday(`19${value.slice(6, 12)}`, label);
209
+ }
210
+ if (!/^\d{17}[\dX]$/.test(value)) {
211
+ return `18 位${label}应由 17 位数字和 1 位校验码组成(校验码可为 X)`;
212
+ }
213
+ if (!ID_REGION_PREFIXES.has(value.slice(0, 2))) return `${label}前两位地区码不正确,请核对`;
214
+ const birthMsg = checkBirthday(value.slice(6, 14), label);
215
+ if (birthMsg) return birthMsg;
216
+ const expected = idCardCheckChar(value.slice(0, 17));
217
+ if (value[17] !== expected) return `${label}校验位不正确,请核对是否输错`;
218
+ return null;
219
+ }
220
+
221
+ /* ------------------------------ 各字段类型 ------------------------------ */
222
+
223
+ const CN_NAME_RE = /^[\u3400-\u4DBF\u4E00-\u9FFF·•]{2,32}$/;
224
+ const EN_NAME_RE = /^[A-Za-z][A-Za-z .'-]{1,31}$/;
225
+
226
+ function checkName(value: string, ctx: CheckContext): string | null {
227
+ const label = ctx.label;
228
+ if (CN_NAME_RE.test(value)) {
229
+ if (/^[·•]|[·•]$|[·•]{2}/.test(value)) return `${label}中的间隔号「·」位置不正确`;
230
+ return null;
231
+ }
232
+ if (EN_NAME_RE.test(value)) return null;
233
+ if (charCount(value) < 2) return `${label}至少需要 2 个字符`;
234
+ if (charCount(value) > 32) return `${label}不能超过 32 个字符`;
235
+ if (/\d/.test(value)) return `${label}不能包含数字`;
236
+ if (/\s/.test(value)) return `${label}不能包含空格`;
237
+ if (/[^\u3400-\u4DBF\u4E00-\u9FFFA-Za-z·• .'-]/.test(value)) {
238
+ return `${label}不能包含特殊符号`;
239
+ }
240
+ return `${label}格式不正确,请填写真实姓名`;
241
+ }
242
+
243
+ function checkUsername(value: string, ctx: CheckContext): string | null {
244
+ const label = ctx.label;
245
+ if (charCount(value) < 3) return `${label}至少需要 3 个字符`;
246
+ if (charCount(value) > 32) return `${label}不能超过 32 个字符`;
247
+ if (!/^[A-Za-z]/.test(value)) return `${label}需以字母开头`;
248
+ if (!/^[A-Za-z0-9_-]+$/.test(value)) return `${label}只能包含字母、数字、下划线或短横线`;
249
+ return null;
250
+ }
251
+
252
+ function checkPassword(value: string, ctx: CheckContext): string | null {
253
+ const label = ctx.label;
254
+ const strong = ctx.strength === 'strong';
255
+ const min = ctx.min ?? (strong ? 8 : 6);
256
+ const max = ctx.max ?? 64;
257
+ if (/\s/.test(value)) return `${label}不能包含空格`;
258
+ if (charCount(value) < min) return `${label}至少需要 ${min} 位`;
259
+ if (charCount(value) > max) return `${label}不能超过 ${max} 位`;
260
+ if (strong && (!/[A-Za-z]/.test(value) || !/\d/.test(value))) {
261
+ return `${label}需同时包含字母和数字`;
262
+ }
263
+ return null;
264
+ }
265
+
266
+ function checkPhone(value: string, ctx: CheckContext): string | null {
267
+ const label = ctx.label;
268
+ if (!/^\d+$/.test(value)) return `${label}只能包含数字`;
269
+ if (value.length !== 11) return `${label}应为 11 位数字`;
270
+ if (!/^1[3-9]\d{9}$/.test(value)) {
271
+ return `${label}应以 1 开头,第二位为 3-9,例如 13800138000`;
272
+ }
273
+ return null;
274
+ }
275
+
276
+ const EMAIL_RE =
277
+ /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/;
278
+
279
+ function checkEmail(value: string, ctx: CheckContext): string | null {
280
+ const label = ctx.label;
281
+ if (charCount(value) > 254) return `${label}不能超过 254 个字符`;
282
+ if (!EMAIL_RE.test(value)) return `${label}格式不正确,例如 name@example.com`;
283
+ return null;
284
+ }
285
+
286
+ function checkContact(value: string, ctx: CheckContext): string | null {
287
+ if (value.includes('@')) {
288
+ return checkEmail(value, ctx)
289
+ ? `${ctx.label}需填写正确的手机号或邮箱,例如 13800138000 / name@example.com`
290
+ : null;
291
+ }
292
+ return checkPhone(value, ctx)
293
+ ? `${ctx.label}需填写正确的手机号或邮箱,例如 13800138000 / name@example.com`
294
+ : null;
295
+ }
296
+
297
+ function checkCode(value: string, ctx: CheckContext): string | null {
298
+ const label = ctx.label;
299
+ if (charCount(value) > 32) return `${label}不能超过 32 个字符`;
300
+ if (!/^[A-Za-z0-9_-]+$/.test(value)) {
301
+ return `${label}只能包含字母、数字、下划线或短横线`;
302
+ }
303
+ return null;
304
+ }
305
+
306
+ function checkBankCard(value: string, ctx: CheckContext): string | null {
307
+ const label = ctx.label;
308
+ if (!/^\d+$/.test(value)) return `${label}只能包含数字`;
309
+ if (value.length < 16 || value.length > 19) return `${label}应为 16-19 位数字`;
310
+ let sum = 0;
311
+ let double = false;
312
+ for (let i = value.length - 1; i >= 0; i--) {
313
+ let n = Number(value[i]);
314
+ if (double) {
315
+ n *= 2;
316
+ if (n > 9) n -= 9;
317
+ }
318
+ sum += n;
319
+ double = !double;
320
+ }
321
+ if (sum % 10 !== 0) return `${label}校验失败,请核对卡号`;
322
+ return null;
323
+ }
324
+
325
+ const CREDIT_CODE_CHARS = '0123456789ABCDEFGHJKLMNPQRTUWXY';
326
+ const CREDIT_CODE_WEIGHTS = [1, 3, 9, 27, 19, 26, 16, 17, 20, 29, 25, 13, 8, 24, 10, 30, 28];
327
+
328
+ function checkCreditCode(value: string, ctx: CheckContext): string | null {
329
+ const label = ctx.label;
330
+ if (value.length !== 18) return `${label}应为 18 位`;
331
+ let sum = 0;
332
+ for (let i = 0; i < 17; i++) {
333
+ const index = CREDIT_CODE_CHARS.indexOf(value[i]);
334
+ if (index < 0) return `${label}包含不支持的字符`;
335
+ sum += index * CREDIT_CODE_WEIGHTS[i];
336
+ }
337
+ const expected = CREDIT_CODE_CHARS[(31 - (sum % 31)) % 31];
338
+ if (value[17] !== expected) return `${label}校验位不正确,请核对`;
339
+ return null;
340
+ }
341
+
342
+ function checkPostalCode(value: string, ctx: CheckContext): string | null {
343
+ if (!/^[1-9]\d{5}$/.test(value)) return `${ctx.label}应为 6 位数字,且不能以 0 开头`;
344
+ return null;
345
+ }
346
+
347
+ function checkUrl(value: string, ctx: CheckContext): string | null {
348
+ if (value.length > 2048) return `${ctx.label}不能超过 2048 个字符`;
349
+ try {
350
+ const url = new URL(value);
351
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
352
+ return `${ctx.label}需以 http:// 或 https:// 开头`;
353
+ }
354
+ return null;
355
+ } catch {
356
+ return `${ctx.label}格式不正确,例如 https://example.com`;
357
+ }
358
+ }
359
+
360
+ function checkDate(value: string, ctx: CheckContext): string | null {
361
+ const label = ctx.label;
362
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
363
+ if (!m) return `${label}格式不正确,请使用 YYYY-MM-DD(如 2024-01-31)`;
364
+ const year = Number(m[1]);
365
+ const month = Number(m[2]);
366
+ const day = Number(m[3]);
367
+ if (year < 1900 || year > 2999) return `${label}年份应在 1900-2999 之间`;
368
+ const date = new Date(Date.UTC(year, month - 1, day));
369
+ if (
370
+ date.getUTCFullYear() !== year ||
371
+ date.getUTCMonth() !== month - 1 ||
372
+ date.getUTCDate() !== day
373
+ ) {
374
+ return `${label}不存在,请检查年月日`;
375
+ }
376
+ return null;
377
+ }
378
+
379
+ function checkInteger(value: string, ctx: CheckContext): string | null {
380
+ const label = ctx.label;
381
+ if (!/^[+-]?\d+$/.test(value)) return `${label}必须是整数`;
382
+ const n = Number(value);
383
+ if (ctx.min !== undefined && n < ctx.min) return `${label}不能小于 ${ctx.min}`;
384
+ if (ctx.max !== undefined && n > ctx.max) return `${label}不能大于 ${ctx.max}`;
385
+ return null;
386
+ }
387
+
388
+ function checkNumber(value: string, ctx: CheckContext): string | null {
389
+ const label = ctx.label;
390
+ if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(value)) return `${label}必须是数字`;
391
+ const n = Number(value);
392
+ if (!Number.isFinite(n)) return `${label}必须是有效数字`;
393
+ if (ctx.min !== undefined && n < ctx.min) return `${label}不能小于 ${ctx.min}`;
394
+ if (ctx.max !== undefined && n > ctx.max) return `${label}不能大于 ${ctx.max}`;
395
+ return null;
396
+ }
397
+
398
+ function checkAmount(value: string, ctx: CheckContext): string | null {
399
+ const label = ctx.label;
400
+ if (!/^(0|[1-9]\d*)(\.\d{1,2})?$/.test(value)) {
401
+ return `${label}需为不小于 0 的金额,最多保留 2 位小数`;
402
+ }
403
+ const n = Number(value);
404
+ if (ctx.min !== undefined && n < ctx.min) return `${label}不能小于 ${ctx.min}`;
405
+ if (ctx.max !== undefined && n > ctx.max) return `${label}不能大于 ${ctx.max}`;
406
+ return null;
407
+ }
408
+
409
+ /* ------------------------------ 注册表 ------------------------------ */
410
+
411
+ /**
412
+ * 内置字段类型。新题需要更特殊的字段时:
413
+ * registerFieldType('licensePlate', { label: '车牌号', normalize, check })
414
+ * 前端 formRules / 后端 zField / AI 表单会自动认这个新类型。
415
+ */
416
+ export const FIELD_TYPES: Record<string, FieldTypeDefinition> = {
417
+ text: {
418
+ label: '内容',
419
+ check: () => null,
420
+ },
421
+ name: {
422
+ label: '姓名',
423
+ suggestedMaxLength: 32,
424
+ normalize: (v) => toHalfWidth(v).trim(),
425
+ check: checkName,
426
+ },
427
+ username: {
428
+ label: '用户名',
429
+ suggestedMaxLength: 32,
430
+ normalize: (v) => toHalfWidth(v).trim(),
431
+ check: checkUsername,
432
+ },
433
+ password: {
434
+ label: '密码',
435
+ suggestedMaxLength: 64,
436
+ // 密码不 trim:用户确实输入了空格时要明确提示,而不是偷偷删掉
437
+ normalize: (v) => v,
438
+ check: checkPassword,
439
+ },
440
+ idCard: {
441
+ label: '身份证号',
442
+ suggestedMaxLength: 18,
443
+ normalize: (v) => toHalfWidth(v).replace(/[\s-]/g, '').toUpperCase(),
444
+ check: checkIdCard,
445
+ },
446
+ phone: {
447
+ label: '手机号',
448
+ suggestedMaxLength: 20,
449
+ normalize: normalizePhone,
450
+ check: checkPhone,
451
+ },
452
+ email: {
453
+ label: '邮箱',
454
+ suggestedMaxLength: 254,
455
+ normalize: (v) => toHalfWidth(v).trim(),
456
+ check: checkEmail,
457
+ },
458
+ contact: {
459
+ label: '联系方式',
460
+ suggestedMaxLength: 254,
461
+ normalize: (v) => {
462
+ const text = toHalfWidth(v).trim();
463
+ return text.includes('@') ? text : normalizePhone(text);
464
+ },
465
+ check: checkContact,
466
+ },
467
+ code: {
468
+ label: '编码',
469
+ suggestedMaxLength: 32,
470
+ normalize: (v) => toHalfWidth(v).trim(),
471
+ check: checkCode,
472
+ },
473
+ bankCard: {
474
+ label: '银行卡号',
475
+ suggestedMaxLength: 23,
476
+ normalize: (v) => toHalfWidth(v).replace(/[\s-]/g, ''),
477
+ check: checkBankCard,
478
+ },
479
+ creditCode: {
480
+ label: '统一社会信用代码',
481
+ suggestedMaxLength: 18,
482
+ normalize: (v) => toHalfWidth(v).replace(/\s/g, '').toUpperCase(),
483
+ check: checkCreditCode,
484
+ },
485
+ postalCode: {
486
+ label: '邮政编码',
487
+ suggestedMaxLength: 6,
488
+ normalize: (v) => toHalfWidth(v).replace(/\s/g, ''),
489
+ check: checkPostalCode,
490
+ },
491
+ url: {
492
+ label: '网址',
493
+ suggestedMaxLength: 2048,
494
+ normalize: (v) => toHalfWidth(v).trim(),
495
+ check: checkUrl,
496
+ },
497
+ date: {
498
+ label: '日期',
499
+ suggestedMaxLength: 10,
500
+ normalize: normalizeDate,
501
+ check: checkDate,
502
+ },
503
+ integer: {
504
+ label: '整数',
505
+ normalize: normalizeNumberText,
506
+ check: checkInteger,
507
+ lengthOptions: false,
508
+ },
509
+ number: {
510
+ label: '数字',
511
+ normalize: normalizeNumberText,
512
+ check: checkNumber,
513
+ lengthOptions: false,
514
+ },
515
+ amount: {
516
+ label: '金额',
517
+ normalize: normalizeNumberText,
518
+ check: checkAmount,
519
+ lengthOptions: false,
520
+ },
521
+ };
522
+
523
+ /** 新题扩展入口:注册后即可被 validateField / 表单 / zod 适配器使用 */
524
+ export function registerFieldType(name: string, definition: FieldTypeDefinition): void {
525
+ FIELD_TYPES[name] = definition;
526
+ }
527
+
528
+ export function fieldTypeLabel(type: string): string {
529
+ return FIELD_TYPES[type]?.label ?? FIELD_TYPES.text.label;
530
+ }
531
+
532
+ export function suggestedMaxLength(type: string): number | undefined {
533
+ return FIELD_TYPES[type]?.suggestedMaxLength;
534
+ }
535
+
536
+ export function normalizeFieldValue(type: string, value: unknown): string {
537
+ const def = FIELD_TYPES[type] ?? FIELD_TYPES.text;
538
+ const text = asText(value);
539
+ return (def.normalize ?? trim)(text);
540
+ }
541
+
542
+ /**
543
+ * 校验任意值。
544
+ * @param type 字段类型(内置类型见 FIELD_TYPES,也可注册自定义类型)
545
+ * @param raw 原始值
546
+ * @param options 必填、标签、长度/数值范围、额外正则等
547
+ */
548
+ export function validateField(
549
+ type: string,
550
+ raw: unknown,
551
+ options: ValidationOptions = {}
552
+ ): ValidationResult {
553
+ const def = FIELD_TYPES[type] ?? FIELD_TYPES.text;
554
+ const label = options.label?.trim() || def.label;
555
+ const value = (def.normalize ?? trim)(asText(raw));
556
+
557
+ if (!value) {
558
+ return options.required ? { ok: false, value, message: `请输入${label}` } : { ok: true, value };
559
+ }
560
+
561
+ const ctx: CheckContext = { ...options, label };
562
+ let message = def.check(value, ctx);
563
+
564
+ if (!message && options.pattern) {
565
+ // 带 g/y 的正则 test() 会记住 lastIndex,复用同一个正则会时灵时不灵
566
+ options.pattern.lastIndex = 0;
567
+ if (!options.pattern.test(value)) {
568
+ message = options.patternMessage || `${label}格式不正确`;
569
+ }
570
+ }
571
+ if (!message && def.lengthOptions !== false && options.min !== undefined) {
572
+ if (charCount(value) < options.min) message = `${label}至少需要 ${options.min} 个字符`;
573
+ }
574
+ if (!message && def.lengthOptions !== false && options.max !== undefined) {
575
+ if (charCount(value) > options.max) message = `${label}不能超过 ${options.max} 个字符`;
576
+ }
577
+
578
+ return message ? { ok: false, value, message } : { ok: true, value };
579
+ }
580
+
581
+ /* ------------------------------ 自动识别 ------------------------------ */
582
+
583
+ /** 人员类模型里的 name 字段按“姓名”校验,其他模型(部门/菜单)的 name 不误判 */
584
+ const PERSON_MODEL_RE = /^(user|employee|staff|person|member|customer|student|teacher|contact)$/i;
585
+
586
+ /**
587
+ * 根据字段名 + 中文标签 + 说明推断字段类型。
588
+ *
589
+ * ★ 这是「通用底座」的关键:AI 动态表单、图谱详情面板、未来新模块的字段
590
+ * 都不需要手写规则 —— 字段叫 phone 就按手机号校验,叫 idCard 就按身份证校验。
591
+ * 推断不出来返回 undefined,表示只做必填/长度这类通用校验,不乱套格式。
592
+ */
593
+ export function inferValidationType(
594
+ name?: string,
595
+ label?: string,
596
+ hint?: string,
597
+ model?: string
598
+ ): ValidationType | undefined {
599
+ const key = (name ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
600
+ const text = `${label ?? ''} ${hint ?? ''}`;
601
+
602
+ // 中文标签最可靠,先判断。顺序上把「联系方式」放在「电话」之前,避免误判。
603
+ if (/身份证/.test(text)) return 'idCard';
604
+ if (/统一社会信用代码|信用代码/.test(text)) return 'creditCode';
605
+ if (/密码/.test(text)) return 'password';
606
+ if (/用户名|登录账号/.test(text)) return 'username';
607
+ if (/银行卡|银行账号/.test(text)) return 'bankCard';
608
+ if (/邮政编码|邮编/.test(text)) return 'postalCode';
609
+ // 组合字段要先于单独的邮箱/手机判断,否则「手机或邮箱」会被当成邮箱
610
+ if (/联系方式|手机或邮箱|电话或邮箱|手机号\/邮箱/.test(text)) return 'contact';
611
+ if (/邮箱|电子邮件/.test(text)) return 'email';
612
+ if (/手机|电话/.test(text)) return 'phone';
613
+ if (/真实姓名|姓名/.test(text)) return 'name';
614
+ if (/网址|主页|URL/i.test(text)) return 'url';
615
+ if (/工号|编号|编码/.test(text)) return 'code';
616
+ if (/金额|月薪|薪资|价格|费用|预算/.test(text)) return 'amount';
617
+ if (/年龄|数量|排序|得分|库存/.test(text)) return 'integer';
618
+ if (/日期/.test(text)) return 'date';
619
+
620
+ if (
621
+ /^(idcard|idcardno|idcardnumber|identitycard|identityno|idnumber|idno|certno|certificateno|sfzh|shenfenzheng)$/.test(
622
+ key
623
+ )
624
+ ) {
625
+ return 'idCard';
626
+ }
627
+ if (/(password|passwd|pwd)$/.test(key)) return 'password';
628
+ if (/^(username|loginname|loginaccount|useraccount)$/.test(key)) return 'username';
629
+ if (/creditcode|unifiedcode|socialcreditcode/.test(key)) return 'creditCode';
630
+ if (/^(bankcard|bankcardno|bankno|bankaccount|cardnumber)$/.test(key)) return 'bankCard';
631
+ if (/^(postalcode|zipcode|zip)$/.test(key)) return 'postalCode';
632
+ if (/^(email|emailaddress|mail|mailbox)$/.test(key)) return 'email';
633
+ if (/^(contact|contactinfo|contactway|contactdetail)$/.test(key)) return 'contact';
634
+ if (/phone|mobile|cellphone|telephone/.test(key) || key === 'tel') return 'phone';
635
+ if (/^(realname|fullname|personname|contactname|linkmanname)$/.test(key)) return 'name';
636
+ // 权限标识常见 system:user:add 这种带冒号的写法,不能套用工号/编码的字符集
637
+ if (/permcode|permissioncode/.test(key)) return undefined;
638
+ if (
639
+ /^(jobno|employeeno|empno|staffno|serialno)$/.test(key) ||
640
+ /(code|jobno|empno|staffno)$/.test(key)
641
+ ) {
642
+ return 'code';
643
+ }
644
+ // 只认明确的网址字段;link/photoUrl 这类可能存的是站内相对路径,不能强行要求 http(s)
645
+ if (/^(url|website|homepage|websiteurl|homepageurl)$/.test(key)) return 'url';
646
+ if (
647
+ /^(date|birthday|birthdate|hiredate|startdate|enddate|expiredate|duedate|applydate|createdate)$/.test(
648
+ key
649
+ )
650
+ ) {
651
+ return 'date';
652
+ }
653
+ if (/(amount|price|salary|money|cost|fee|budget|expense)$/.test(key)) return 'amount';
654
+ if (/^(age|quantity|qty|count|sort|score|stock|total|num)$/.test(key)) return 'integer';
655
+ if (key === 'name' && model && PERSON_MODEL_RE.test(model)) return 'name';
656
+
657
+ return undefined;
658
+ }