niuma-ui 1.0.2 → 1.1.0

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 (85) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/package.json +1 -1
  3. package/src/__tests__/RsDatePicker.spec.ts +21 -0
  4. package/src/__tests__/RsDialog.spec.ts +61 -0
  5. package/src/__tests__/RsForm.spec.ts +5 -5
  6. package/src/__tests__/RsFormItem.spec.ts +478 -0
  7. package/src/__tests__/RsFormList.spec.ts +109 -0
  8. package/src/__tests__/RsInput.spec.ts +93 -0
  9. package/src/__tests__/RsInputNumber.spec.ts +33 -0
  10. package/src/__tests__/RsSelect.spec.ts +423 -1
  11. package/src/__tests__/RsSwitch.spec.ts +30 -0
  12. package/src/__tests__/RsTimePicker.spec.ts +9 -0
  13. package/src/__tests__/RsTooltip.spec.ts +43 -0
  14. package/src/__tests__/css-token.spec.ts +51 -0
  15. package/src/__tests__/date-picker-utils.spec.ts +49 -0
  16. package/src/__tests__/dialog-utils.spec.ts +43 -0
  17. package/src/__tests__/form-path.spec.ts +46 -0
  18. package/src/__tests__/form-rules.spec.ts +63 -0
  19. package/src/__tests__/theme.spec.ts +2 -0
  20. package/src/components/RsAlert.vue +1 -1
  21. package/src/components/RsAvatar.vue +1 -1
  22. package/src/components/RsBadge.vue +1 -1
  23. package/src/components/RsBreadcrumb.vue +1 -1
  24. package/src/components/RsButton.vue +4 -4
  25. package/src/components/RsCalendarGrid.vue +3 -3
  26. package/src/components/RsCard.vue +4 -4
  27. package/src/components/RsCodeBlock.vue +4 -4
  28. package/src/components/RsCodeEditor.vue +8 -8
  29. package/src/components/RsConfirmDialog.vue +2 -2
  30. package/src/components/RsContextMenu.vue +6 -4
  31. package/src/components/RsDatePicker.vue +75 -31
  32. package/src/components/RsDateTimePicker.vue +9 -1
  33. package/src/components/RsDescriptions.vue +2 -2
  34. package/src/components/RsDialog.vue +26 -16
  35. package/src/components/RsDrawer.vue +1 -1
  36. package/src/components/RsDropdown.vue +1 -1
  37. package/src/components/RsDynamicTags.vue +18 -4
  38. package/src/components/RsEmpty.vue +1 -1
  39. package/src/components/RsForm.vue +68 -1
  40. package/src/components/RsFormItem.vue +423 -0
  41. package/src/components/RsFormItemControl.vue +21 -0
  42. package/src/components/RsFormList.vue +117 -0
  43. package/src/components/RsInput.vue +411 -18
  44. package/src/components/RsInputNumber.vue +13 -4
  45. package/src/components/RsLink.vue +1 -1
  46. package/src/components/RsMarkdown.vue +5 -5
  47. package/src/components/RsMenu.vue +2 -2
  48. package/src/components/RsMonacoEditor.vue +9 -3
  49. package/src/components/RsPagination.vue +1 -1
  50. package/src/components/RsProseEditor.vue +3 -3
  51. package/src/components/RsSelect.vue +332 -611
  52. package/src/components/RsSidebarGroup.vue +1 -1
  53. package/src/components/RsSplitPane.vue +22 -1
  54. package/src/components/RsStatCard.vue +2 -2
  55. package/src/components/RsSteps.vue +2 -2
  56. package/src/components/RsSwitch.vue +20 -7
  57. package/src/components/RsTabs.vue +9 -9
  58. package/src/components/RsTag.vue +1 -1
  59. package/src/components/RsTerminal.vue +58 -19
  60. package/src/components/RsTimePicker.vue +28 -4
  61. package/src/components/RsTimePickerColumns.vue +4 -4
  62. package/src/components/RsToaster.vue +7 -7
  63. package/src/components/RsTooltip.vue +10 -1
  64. package/src/components/RsTooltipProvider.vue +11 -1
  65. package/src/components/RsTree.vue +6 -6
  66. package/src/components/RsUpload.vue +2 -2
  67. package/src/components/date-picker-utils.ts +113 -2
  68. package/src/components/dialog-utils.ts +37 -2
  69. package/src/components/form-item-control.ts +55 -0
  70. package/src/components/form-path.ts +92 -0
  71. package/src/components/form-rules.ts +64 -10
  72. package/src/components/form-utils.ts +94 -2
  73. package/src/components/select-utils.ts +272 -12
  74. package/src/components/table/RsTableHeaderFilter.vue +1 -1
  75. package/src/components/table/rs-table.css +1 -1
  76. package/src/components/use-rs-select.ts +436 -0
  77. package/src/composables/useRsConfig.ts +10 -3
  78. package/src/composables/useRsI18n.ts +16 -5
  79. package/src/index.ts +69 -1
  80. package/src/locale/interpolate.ts +32 -0
  81. package/src/locale/messages.ts +10 -0
  82. package/src/styles.css +68 -12
  83. package/src/theme/brand.example.css +4 -1
  84. package/src/theme/css-token.ts +107 -0
  85. package/src/theme/types.ts +1 -0
@@ -25,8 +25,28 @@ export interface RsDateRangeValue {
25
25
  end?: string
26
26
  }
27
27
 
28
- /** v-model 取值格式:string 为默认字符串;timestamp 为毫秒时间戳 */
29
- export type RsDatePickerValueFormat = 'string' | 'timestamp'
28
+ /** valueFormat 预设:string 墙钟、timestamp 毫秒、iso 本地偏移 RFC3339 */
29
+ export const RS_DATE_PICKER_VALUE_FORMAT_PRESETS = ['string', 'timestamp', 'iso'] as const
30
+
31
+ /**
32
+ * valueFormat 预设名。
33
+ * string 为默认墙钟字符串;timestamp 为毫秒;iso 为带本地偏移的 RFC3339。
34
+ */
35
+ export type RsDatePickerValueFormatPreset = (typeof RS_DATE_PICKER_VALUE_FORMAT_PRESETS)[number]
36
+
37
+ /**
38
+ * v-model 取值格式,对齐 Element Plus `value-format` / Ant Design Vue `valueFormat`:
39
+ * 展示格式与绑定格式分离。预设之外可传入任意 dayjs 模板,如 `YYYY-MM-DDTHH:mm:ssZ`。
40
+ */
41
+ export type RsDatePickerValueFormat = RsDatePickerValueFormatPreset | (string & {})
42
+
43
+ /**
44
+ * 内部墙钟字符串与对外 valueFormat 互转时的选项。
45
+ */
46
+ export interface RsDatePickerValueConvertOptions {
47
+ valueFormat: string
48
+ withTime: boolean
49
+ }
30
50
 
31
51
  /** 时间戳范围(毫秒),用于 valueFormat=timestamp 的 range 模式 */
32
52
  export type RsDatePickerTimestampRange = [number, number]
@@ -65,6 +85,97 @@ export function parseTimestampValue(value?: string): number | null {
65
85
  return parsed ? parsed.valueOf() : null
66
86
  }
67
87
 
88
+ function parsePickerDayjs(value: string) {
89
+ return parseRsDateTimeDayjs(value) ?? parseRsDayjs(value)
90
+ }
91
+
92
+ function isTimestampFormat(valueFormat: string): boolean {
93
+ return valueFormat === 'timestamp'
94
+ }
95
+
96
+ function isIsoFormat(valueFormat: string): boolean {
97
+ return valueFormat === 'iso'
98
+ }
99
+
100
+ function isStringFormat(valueFormat: string): boolean {
101
+ return valueFormat === 'string' || valueFormat === ''
102
+ }
103
+
104
+ /**
105
+ * 将对外 v-model 规范为控件内部墙钟字符串(`YYYY-MM-DD` / `YYYY-MM-DD HH:mm:ss`)。
106
+ * 解析 ISO 时去掉 Z/偏移,按墙钟分量展示,不按浏览器时区换算。
107
+ * @param value - 对外绑定值(字符串、毫秒时间戳或空)
108
+ * @param options - valueFormat 与是否含时分秒
109
+ * @returns 内部墙钟字符串;无法解析时尽量原样返回
110
+ */
111
+ export function toInternalPickerValue(
112
+ value: unknown,
113
+ options: RsDatePickerValueConvertOptions,
114
+ ): string {
115
+ if (value == null || value === '') return ''
116
+ if (typeof value === 'number') {
117
+ return formatTimestampValue(value, options.withTime)
118
+ }
119
+ if (typeof value !== 'string') return ''
120
+
121
+ const format = options.valueFormat || 'string'
122
+ if (!isStringFormat(format) && !isTimestampFormat(format) && !isIsoFormat(format)) {
123
+ const strict = dayjs(value, format, true)
124
+ if (strict.isValid()) {
125
+ return options.withTime ? strict.format(RS_DATETIME_FORMAT) : strict.format(RS_DATE_FORMAT)
126
+ }
127
+ }
128
+
129
+ const parsed = parsePickerDayjs(value)
130
+ if (!parsed) return value
131
+ return options.withTime ? parsed.format(RS_DATETIME_FORMAT) : parsed.format(RS_DATE_FORMAT)
132
+ }
133
+
134
+ /**
135
+ * 将控件内部墙钟字符串转为对外 valueFormat。
136
+ * iso 空值返回 null,避免 JSON `""` 无法绑定到 Go `time.Time`。
137
+ * @param internal - 内部墙钟字符串
138
+ * @param options - valueFormat 与是否含时分秒
139
+ * @returns 字符串、毫秒时间戳或 null
140
+ */
141
+ export function fromInternalPickerValue(
142
+ internal: string,
143
+ options: RsDatePickerValueConvertOptions,
144
+ ): string | number | null {
145
+ const format = options.valueFormat || 'string'
146
+ if (!internal) {
147
+ return isTimestampFormat(format) || isIsoFormat(format) ? null : ''
148
+ }
149
+ if (isTimestampFormat(format)) {
150
+ return parseTimestampValue(internal)
151
+ }
152
+ if (isStringFormat(format)) {
153
+ return internal
154
+ }
155
+
156
+ const parsed = parsePickerDayjs(internal)
157
+ if (!parsed) return internal
158
+
159
+ if (isIsoFormat(format)) {
160
+ const body = options.withTime
161
+ ? parsed.format('YYYY-MM-DDTHH:mm:ss')
162
+ : parsed.format('YYYY-MM-DDT00:00:00')
163
+ return `${body}${parsed.format('Z')}`
164
+ }
165
+
166
+ return parsed.format(format)
167
+ }
168
+
169
+ /**
170
+ * 将 fromInternalPickerValue 的结果写入 range.start/end(范围端点始终为字符串)。
171
+ * @param value - 转换后的端点
172
+ * @returns 范围对象可用的字符串;空为 `''`
173
+ */
174
+ export function toRangeEndpointString(value: string | number | null): string {
175
+ if (value == null) return ''
176
+ return typeof value === 'number' ? String(value) : value
177
+ }
178
+
68
179
  /** 判断是否为时间戳范围元组 */
69
180
  export function isTimestampRange(value: unknown): value is RsDatePickerTimestampRange {
70
181
  return (
@@ -1,14 +1,16 @@
1
1
  import type { Component } from 'vue'
2
2
  import type { RsLocale } from '../locale/types'
3
3
  import type { RsThemeMode } from '../theme/types'
4
+ import { dialogViewportSize } from './dialog-viewport'
4
5
  import type { RsFeedbackTone } from './overlay-utils'
5
6
 
6
7
  /** 宽度预设(与历史 sm/md/lg 一致) */
7
8
  export type RsDialogWidthPreset = 'sm' | 'md' | 'lg'
8
9
 
9
10
  /**
10
- * 对话框宽度:预设或自定义(number 按 px;string 原样写入 CSS,如 `40rem` / `60%`)。
11
- * 仅传入预设时行为与旧版完全一致。
11
+ * 对话框宽度:预设或自定义(number 按 px;string 为 CSS 长度,如 `40rem` / `60%`)。
12
+ * window 布局会在打开时把 px / rem / % 折成像素再居中(% 相对视口扣除 inset);
13
+ * confirm 布局仍把自定义宽度当作 CSS 写入。
12
14
  */
13
15
  export type RsDialogWidth = RsDialogWidthPreset | number | string
14
16
 
@@ -46,6 +48,39 @@ export function resolveRsDialogCssWidth(width: RsDialogWidth): string | undefine
46
48
  return trimmed || undefined
47
49
  }
48
50
 
51
+ function rootFontSizePx(): number {
52
+ if (typeof document === 'undefined') return 16
53
+ return Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
54
+ }
55
+
56
+ /**
57
+ * 将自定义宽度折成 px,供 window 布局计算 bounds / 居中。
58
+ * 支持 number、`px`、`rem`、`%`(相对视口可用宽度);预设与无法解析的单位返回 undefined。
59
+ */
60
+ export function resolveRsDialogWidthPx(
61
+ width: RsDialogWidth,
62
+ viewportWidth = dialogViewportSize().width,
63
+ ): number | undefined {
64
+ if (isRsDialogWidthPreset(width)) return undefined
65
+ if (typeof width === 'number') {
66
+ if (!Number.isFinite(width) || width <= 0) return undefined
67
+ return Math.round(width)
68
+ }
69
+ const trimmed = String(width).trim()
70
+ if (!trimmed) return undefined
71
+ if (/^\d+(\.\d+)?px$/i.test(trimmed)) {
72
+ return Math.round(Number.parseFloat(trimmed))
73
+ }
74
+ if (/^\d+(\.\d+)?rem$/i.test(trimmed)) {
75
+ return Math.round(Number.parseFloat(trimmed) * rootFontSizePx())
76
+ }
77
+ if (/^\d+(\.\d+)?%$/.test(trimmed)) {
78
+ if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) return undefined
79
+ return Math.round((Number.parseFloat(trimmed) / 100) * viewportWidth)
80
+ }
81
+ return undefined
82
+ }
83
+
49
84
  export async function runRsDialogBeforeClose(
50
85
  beforeClose: RsDialogBeforeClose | undefined,
51
86
  reason: RsDialogCloseReason,
@@ -0,0 +1,55 @@
1
+ import { Comment, Fragment, Text, cloneVNode, isVNode, type VNode, type VNodeArrayChildren } from 'vue'
2
+
3
+ function flattenVNodes(nodes: VNodeArrayChildren, out: VNode[] = []): VNode[] {
4
+ for (const node of nodes) {
5
+ if (node == null || typeof node === 'boolean') continue
6
+ if (Array.isArray(node)) {
7
+ flattenVNodes(node, out)
8
+ continue
9
+ }
10
+ if (!isVNode(node)) continue
11
+ if (node.type === Fragment && Array.isArray(node.children)) {
12
+ flattenVNodes(node.children as VNodeArrayChildren, out)
13
+ continue
14
+ }
15
+ out.push(node)
16
+ }
17
+ return out
18
+ }
19
+
20
+ function isControlVNode(node: VNode): boolean {
21
+ if (node.type === Comment || node.type === Text) return false
22
+ return true
23
+ }
24
+
25
+ /**
26
+ * 把 Form.Item 的 id / aria / invalid / v-model 注入到默认插槽的第一个控件(对齐 Ant cloneElement)。
27
+ * 只有这个直接子节点跟 Item 的 error;子孙里的搜索框不吃 invalid。
28
+ * 子节点已绑定 v-model 时不覆盖(switch Y/N、select 类型映射等)。
29
+ */
30
+ export function bindFormItemControl(
31
+ children: VNode[] | undefined,
32
+ extra: Record<string, unknown>,
33
+ ): VNode[] | undefined {
34
+ if (!children?.length) return children
35
+ const flat = flattenVNodes(children)
36
+ const index = flat.findIndex(isControlVNode)
37
+ if (index < 0) return children
38
+ const target = flat[index]
39
+ if (!target) return children
40
+ const next = flat.slice()
41
+ next[index] = cloneVNode(target, pickControlInject(target, extra), true)
42
+ return next
43
+ }
44
+
45
+ function pickControlInject(
46
+ node: VNode,
47
+ extra: Record<string, unknown>,
48
+ ): Record<string, unknown> {
49
+ const props = node.props
50
+ if (!props || !('modelValue' in props || 'onUpdate:modelValue' in props)) {
51
+ return extra
52
+ }
53
+ const { modelValue: _model, 'onUpdate:modelValue': _update, ...rest } = extra
54
+ return rest
55
+ }
@@ -0,0 +1,92 @@
1
+ /** 字段路径:`'user.email'` 或 `['user', 'email']`(对齐 Ant Design NamePath) */
2
+ export type RsFormNamePath = string | Array<string | number>
3
+
4
+ /** 路径段是否为数组下标(Form.List 相对 name 的第一段)。 */
5
+ export function isIndexSegment(seg: string | number): boolean {
6
+ if (typeof seg === 'number') return Number.isInteger(seg) && seg >= 0
7
+ return /^\d+$/.test(seg)
8
+ }
9
+
10
+ /**
11
+ * 将 NamePath 规范为路径段。字符串按 `.` 拆分。
12
+ */
13
+ export function normalizeNamePath(name?: RsFormNamePath | null): Array<string | number> {
14
+ if (name == null || name === '') return []
15
+ if (Array.isArray(name)) return name.filter((seg) => seg !== '')
16
+ return String(name)
17
+ .split('.')
18
+ .filter((seg) => seg !== '')
19
+ }
20
+
21
+ /**
22
+ * 拼接多段 NamePath(List 前缀 + Item name)。
23
+ */
24
+ export function concatNamePath(
25
+ ...parts: Array<RsFormNamePath | undefined | null>
26
+ ): Array<string | number> {
27
+ return parts.flatMap((part) => normalizeNamePath(part))
28
+ }
29
+
30
+ /**
31
+ * 路径转 rules / DOM 使用的稳定键。
32
+ */
33
+ export function namePathKey(name?: RsFormNamePath | null): string {
34
+ return normalizeNamePath(name).map(String).join('.')
35
+ }
36
+
37
+ /**
38
+ * 路径是否已存在于对象树上(含值为 null / undefined 的叶子)。
39
+ */
40
+ export function hasByNamePath(source: unknown, name?: RsFormNamePath | null): boolean {
41
+ const keys = normalizeNamePath(name)
42
+ if (!keys.length) return false
43
+ let current: unknown = source
44
+ for (const key of keys) {
45
+ if (current == null || typeof current !== 'object') return false
46
+ if (!Object.prototype.hasOwnProperty.call(current, String(key))) return false
47
+ current = (current as Record<string, unknown>)[String(key)]
48
+ }
49
+ return true
50
+ }
51
+
52
+ /**
53
+ * 按 NamePath 读取对象字段。
54
+ */
55
+ export function getByNamePath(source: unknown, name?: RsFormNamePath | null): unknown {
56
+ const keys = normalizeNamePath(name)
57
+ if (!keys.length) return undefined
58
+ let current: unknown = source
59
+ for (const key of keys) {
60
+ if (current == null || typeof current !== 'object') return undefined
61
+ current = (current as Record<string, unknown>)[String(key)]
62
+ }
63
+ return current
64
+ }
65
+
66
+ /**
67
+ * 按 NamePath 写入对象字段。
68
+ * 中间缺失的容器按下一段是否为下标补 `[]` 或 `{}`,已有数组不会被覆盖成对象。
69
+ */
70
+ export function setByNamePath(
71
+ target: Record<string, unknown>,
72
+ name: RsFormNamePath,
73
+ value: unknown,
74
+ ): void {
75
+ const keys = normalizeNamePath(name)
76
+ if (!keys.length) return
77
+ let current: unknown = target
78
+ for (let i = 0; i < keys.length - 1; i += 1) {
79
+ if (current == null || typeof current !== 'object') return
80
+ const obj = current as Record<string, unknown>
81
+ const key = String(keys[i])
82
+ const nextKey = keys[i + 1]
83
+ let next = obj[key]
84
+ if (next == null || typeof next !== 'object') {
85
+ next = isIndexSegment(nextKey!) ? [] : {}
86
+ obj[key] = next
87
+ }
88
+ current = next
89
+ }
90
+ if (current == null || typeof current !== 'object') return
91
+ ;(current as Record<string, unknown>)[String(keys[keys.length - 1])] = value
92
+ }
@@ -1,6 +1,7 @@
1
- import type { RsTranslateFn } from '../composables/useRsI18n'
1
+ import { interpolateRsMessage, type RsTranslateFn } from '../locale/interpolate'
2
2
  import { createTranslator } from '../composables/useRsI18n'
3
3
  import { defaultLocale } from '../locale/types'
4
+ import type { RsFormNamePath } from './form-path'
4
5
  import {
5
6
  getInputRuleMessage,
6
7
  validateInputRule,
@@ -10,6 +11,15 @@ import {
10
11
  /** 校验触发时机(对齐 Ant Design Form) */
11
12
  export type RsFormRuleTrigger = 'blur' | 'change' | 'submit'
12
13
 
14
+ /**
15
+ * validator 第二参:读取整表数据,供跨字段校验(B 依赖 A)。
16
+ */
17
+ export interface RsFormValidatorContext {
18
+ name?: string
19
+ getFieldValue: (name: RsFormNamePath) => unknown
20
+ getFieldsValue: () => Record<string, unknown>
21
+ }
22
+
13
23
  /**
14
24
  * 单条表单规则(声明式 + 自定义校验)。
15
25
  * 对标 Ant Design / Element Plus Form rules 的常用子集。
@@ -37,9 +47,11 @@ export interface RsFormRuleItem {
37
47
  type?: RsInputRule | 'string' | 'number' | 'array'
38
48
  /**
39
49
  * 自定义校验。返回 true 通过;false / 字符串为失败;支持 Promise。
50
+ * 第二参可读取其它字段(`getFieldValue` / `getFieldsValue`)。
40
51
  */
41
52
  validator?: (
42
53
  value: unknown,
54
+ ctx: RsFormValidatorContext,
43
55
  ) => boolean | string | void | Promise<boolean | string | void>
44
56
  trigger?: RsFormRuleTrigger | RsFormRuleTrigger[]
45
57
  }
@@ -52,6 +64,24 @@ export interface RsFormRuleValidateResult {
52
64
  message?: string
53
65
  }
54
66
 
67
+ /**
68
+ * Form.validateMessages 模板(`{label}` 插值)。
69
+ * 对标 Ant Design Form.validateMessages。
70
+ */
71
+ export interface RsFormValidateMessages {
72
+ required?: string
73
+ }
74
+
75
+ export interface RsFormFieldRulesOptions {
76
+ trigger?: RsFormRuleTrigger
77
+ t?: RsTranslateFn
78
+ label?: string
79
+ name?: string
80
+ validateMessages?: RsFormValidateMessages
81
+ getFieldValue?: (name: RsFormNamePath) => unknown
82
+ getFieldsValue?: () => Record<string, unknown>
83
+ }
84
+
55
85
  export function normalizeFormRules(
56
86
  rules?: RsFormRuleItem | RsFormRuleItem[] | null,
57
87
  ): RsFormRuleItem[] {
@@ -179,13 +209,31 @@ async function checkValidatorRule(
179
209
  value: unknown,
180
210
  rule: RsFormRuleItem,
181
211
  tr: RsTranslateFn,
212
+ options?: RsFormFieldRulesOptions,
182
213
  ): Promise<RsFormRuleValidateResult | null> {
183
214
  if (!rule.validator) return null
184
215
 
185
- const result = await rule.validator(value)
216
+ const ctx: RsFormValidatorContext = {
217
+ name: options?.name,
218
+ getFieldValue: options?.getFieldValue ?? (() => undefined),
219
+ getFieldsValue: options?.getFieldsValue ?? (() => ({})),
220
+ }
221
+ let result: unknown
222
+ try {
223
+ result = await rule.validator(value, ctx)
224
+ } catch (err) {
225
+ const message =
226
+ err instanceof Error && err.message
227
+ ? err.message
228
+ : formatInvalid(rule, value, tr)
229
+ return fail(message)
230
+ }
186
231
  if (result === true || result === undefined || result === null) return null
187
232
  if (result === false) return fail(formatInvalid(rule, value, tr))
188
233
  if (typeof result === 'string') return fail(result)
234
+ if (result instanceof Error) {
235
+ return fail(result.message || formatInvalid(rule, value, tr))
236
+ }
189
237
  return null
190
238
  }
191
239
 
@@ -197,17 +245,26 @@ async function evaluateFormRule(
197
245
  value: unknown,
198
246
  rule: RsFormRuleItem,
199
247
  tr: RsTranslateFn,
248
+ options?: RsFormFieldRulesOptions,
200
249
  ): Promise<RsFormRuleValidateResult | null> {
201
250
  if (rule.required && isEmptyValue(value)) {
202
- return fail(resolveRuleMessage(rule, value, tr('input.required')))
251
+ const label = options?.label?.trim()
252
+ const template =
253
+ options?.validateMessages?.required ??
254
+ (label ? tr('form.validate.required') : tr('input.required'))
255
+ const fallback = interpolateRsMessage(template, { label: label || tr('form.field') })
256
+ return fail(resolveRuleMessage(rule, value, fallback))
257
+ }
258
+ // 空值跳过格式类规则;自定义 validator 仍执行,以便 B 依赖 A(如 A=email 则 B 必填)
259
+ if (isEmptyValue(value)) {
260
+ return checkValidatorRule(value, rule, tr, options)
203
261
  }
204
- if (isEmptyValue(value)) return null
205
262
 
206
263
  return (
207
264
  checkTypeRule(value, rule, tr) ??
208
265
  checkPatternRule(value, rule, tr) ??
209
266
  checkLengthRule(value, rule, tr) ??
210
- (await checkValidatorRule(value, rule, tr))
267
+ (await checkValidatorRule(value, rule, tr, options))
211
268
  )
212
269
  }
213
270
 
@@ -217,17 +274,14 @@ async function evaluateFormRule(
217
274
  export async function runFormFieldRules(
218
275
  value: unknown,
219
276
  rules: RsFormRuleItem[],
220
- options?: {
221
- trigger?: RsFormRuleTrigger
222
- t?: RsTranslateFn
223
- },
277
+ options?: RsFormFieldRulesOptions,
224
278
  ): Promise<RsFormRuleValidateResult> {
225
279
  const tr = options?.t ?? createTranslator(defaultLocale)
226
280
  const trigger = options?.trigger
227
281
 
228
282
  for (const rule of rules) {
229
283
  if (!matchFormRuleTrigger(rule, trigger)) continue
230
- const failed = await evaluateFormRule(value, rule, tr)
284
+ const failed = await evaluateFormRule(value, rule, tr, options)
231
285
  if (failed) return failed
232
286
  }
233
287
 
@@ -9,8 +9,9 @@ import {
9
9
  type Ref,
10
10
  type VNodeChild,
11
11
  } from 'vue'
12
- import type { RsFormRuleItem, RsFormRuleTrigger, RsFormRules } from './form-rules'
12
+ import type { RsFormRuleItem, RsFormRuleTrigger, RsFormRules, RsFormValidateMessages } from './form-rules'
13
13
  import { normalizeFormRules } from './form-rules'
14
+ import { namePathKey, type RsFormNamePath } from './form-path'
14
15
 
15
16
  /** 自定义错误渲染上下文(Form / 字段共用) */
16
17
  export interface RsFormErrorRenderContext {
@@ -33,6 +34,9 @@ export type RsFormSize = 'ssm' | 'sm' | 'md' | 'lg'
33
34
  export type RsFormGap = 'sm' | 'md' | 'lg'
34
35
  export type RsFormMaxWidth = 'sm' | 'md' | 'lg' | 'full' | 'none'
35
36
 
37
+ /** Form.Item 校验态(对齐 Ant Design validateStatus) */
38
+ export type RsFormValidateStatus = 'success' | 'warning' | 'error' | 'validating' | ''
39
+
36
40
  /** 整表校验结果(含按字段名聚合的错误) */
37
41
  export interface RsFormValidationResult {
38
42
  valid: boolean
@@ -72,6 +76,12 @@ export interface RsFormContext {
72
76
  renderError?: RsFormErrorRender
73
77
  registerField: (id: symbol, field: RsFormFieldExpose) => void
74
78
  unregisterField: (id: symbol) => void
79
+ /** 可选数据中枢(Form.model) */
80
+ model: Readonly<Ref<Record<string, unknown> | undefined>>
81
+ validateMessages: ComputedRef<RsFormValidateMessages | undefined>
82
+ getFieldValue: (name: RsFormNamePath) => unknown
83
+ setFieldValue: (name: RsFormNamePath, value: unknown) => void
84
+ getFieldsValue: () => Record<string, unknown>
75
85
  }
76
86
 
77
87
  export const RS_FORM_INJECTION_KEY: InjectionKey<RsFormContext> = Symbol('RsForm')
@@ -86,6 +96,68 @@ export function cloneFormFieldValue(value: unknown): unknown {
86
96
  }
87
97
  }
88
98
 
99
+ /**
100
+ * Form.Item 上下文(对齐 Element `elFormItem` / Ant Design Form.Item)。
101
+ * invalid 只属于绑定控件:Item 通过 cloneElement 打到直接子节点,
102
+ * 子孙里的搜索框等应先用 isRsFormItemBoundControl 判断,不要一律跟 Item 变红。
103
+ */
104
+ export interface RsFormItemContext {
105
+ name: ComputedRef<string | undefined>
106
+ /** 已声明 name 时 Item 拥有字段,内置控件不再向 Form 注册 */
107
+ ownsField: ComputedRef<boolean>
108
+ invalid: ComputedRef<boolean>
109
+ message: ComputedRef<string>
110
+ status: ComputedRef<RsFormValidateStatus>
111
+ fieldId: ComputedRef<string>
112
+ errorId: ComputedRef<string>
113
+ describedBy: ComputedRef<string | undefined>
114
+ }
115
+
116
+ export const RS_FORM_ITEM_INJECTION_KEY: InjectionKey<RsFormItemContext> =
117
+ Symbol('RsFormItem')
118
+
119
+ export function provideRsFormItemContext(context: RsFormItemContext): void {
120
+ provide(RS_FORM_ITEM_INJECTION_KEY, context)
121
+ }
122
+
123
+ export function useRsFormItemContext(): RsFormItemContext | null {
124
+ return inject(RS_FORM_ITEM_INJECTION_KEY, null)
125
+ }
126
+
127
+ /**
128
+ * Form.List 上下文:子 Item 的 name 会拼上 List 前缀(users + [0, 'name'] → users.0.name)。
129
+ */
130
+ export interface RsFormListContext {
131
+ prefix: ComputedRef<Array<string | number>>
132
+ }
133
+
134
+ /** Form.List 默认插槽中的一行 */
135
+ export interface RsFormListField {
136
+ /** 稳定行键,用于 v-for */
137
+ key: number
138
+ /** 当前下标,作为子 Item name 的第一段 */
139
+ name: number
140
+ index: number
141
+ }
142
+
143
+ /** Form.List 增删改操作 */
144
+ export interface RsFormListOperations {
145
+ add: (defaultValue?: unknown) => void
146
+ remove: (index: number) => void
147
+ move: (from: number, to: number) => void
148
+ }
149
+
150
+ export const RS_FORM_LIST_INJECTION_KEY: InjectionKey<RsFormListContext> =
151
+ Symbol('RsFormList')
152
+
153
+ export function provideRsFormListContext(context: RsFormListContext): void {
154
+ provide(RS_FORM_LIST_INJECTION_KEY, context)
155
+ }
156
+
157
+ export function useRsFormListContext(): RsFormListContext | null {
158
+ return inject(RS_FORM_LIST_INJECTION_KEY, null)
159
+ }
160
+
89
161
  export function provideRsFormContext(context: RsFormContext): void {
90
162
  provide(RS_FORM_INJECTION_KEY, context)
91
163
  }
@@ -94,15 +166,35 @@ export function useRsFormContext(): RsFormContext | null {
94
166
  return inject(RS_FORM_INJECTION_KEY, null)
95
167
  }
96
168
 
169
+ /**
170
+ * 是否为 Form.Item 的绑定控件(对齐 Ant Design cloneElement)。
171
+ * name 与 Item 相同,或 id 等于 Item 注入的 fieldId。
172
+ */
173
+ export function isRsFormItemBoundControl(
174
+ item: RsFormItemContext | null | undefined,
175
+ control: { id?: string; name?: string },
176
+ ): boolean {
177
+ if (!item?.ownsField.value) return false
178
+ const itemName = item.name.value
179
+ if (control.name && itemName && namePathKey(control.name) === itemName) return true
180
+ if (control.id && control.id === item.fieldId.value) return true
181
+ return false
182
+ }
183
+
97
184
  export function useRsFormField(getExpose: () => RsFormFieldExpose | undefined): void {
98
185
  const form = useRsFormContext()
186
+ const item = useRsFormItemContext()
99
187
  if (!form) return
100
188
 
101
189
  const fieldId = Symbol('rs-form-field')
102
190
 
103
191
  onMounted(() => {
192
+ // Item 已按 name 注册时,内置控件只负责录入,避免重复校验
193
+ if (item?.ownsField.value) return
104
194
  const expose = getExpose()
105
- if (expose) form.registerField(fieldId, expose)
195
+ // 对齐 Ant / RHF:无 name 不进字段表,弹层搜索框不参与外层校验
196
+ if (!expose?.name) return
197
+ form.registerField(fieldId, expose)
106
198
  })
107
199
 
108
200
  onUnmounted(() => {