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.
- package/CHANGELOG.md +28 -1
- package/package.json +1 -1
- package/src/__tests__/RsDatePicker.spec.ts +21 -0
- package/src/__tests__/RsDialog.spec.ts +61 -0
- package/src/__tests__/RsForm.spec.ts +5 -5
- package/src/__tests__/RsFormItem.spec.ts +478 -0
- package/src/__tests__/RsFormList.spec.ts +109 -0
- package/src/__tests__/RsInput.spec.ts +93 -0
- package/src/__tests__/RsInputNumber.spec.ts +33 -0
- package/src/__tests__/RsSelect.spec.ts +423 -1
- package/src/__tests__/RsSwitch.spec.ts +30 -0
- package/src/__tests__/RsTimePicker.spec.ts +9 -0
- package/src/__tests__/RsTooltip.spec.ts +43 -0
- package/src/__tests__/css-token.spec.ts +51 -0
- package/src/__tests__/date-picker-utils.spec.ts +49 -0
- package/src/__tests__/dialog-utils.spec.ts +43 -0
- package/src/__tests__/form-path.spec.ts +46 -0
- package/src/__tests__/form-rules.spec.ts +63 -0
- package/src/__tests__/theme.spec.ts +2 -0
- package/src/components/RsAlert.vue +1 -1
- package/src/components/RsAvatar.vue +1 -1
- package/src/components/RsBadge.vue +1 -1
- package/src/components/RsBreadcrumb.vue +1 -1
- package/src/components/RsButton.vue +4 -4
- package/src/components/RsCalendarGrid.vue +3 -3
- package/src/components/RsCard.vue +4 -4
- package/src/components/RsCodeBlock.vue +4 -4
- package/src/components/RsCodeEditor.vue +8 -8
- package/src/components/RsConfirmDialog.vue +2 -2
- package/src/components/RsContextMenu.vue +6 -4
- package/src/components/RsDatePicker.vue +75 -31
- package/src/components/RsDateTimePicker.vue +9 -1
- package/src/components/RsDescriptions.vue +2 -2
- package/src/components/RsDialog.vue +26 -16
- package/src/components/RsDrawer.vue +1 -1
- package/src/components/RsDropdown.vue +1 -1
- package/src/components/RsDynamicTags.vue +18 -4
- package/src/components/RsEmpty.vue +1 -1
- package/src/components/RsForm.vue +68 -1
- package/src/components/RsFormItem.vue +423 -0
- package/src/components/RsFormItemControl.vue +21 -0
- package/src/components/RsFormList.vue +117 -0
- package/src/components/RsInput.vue +411 -18
- package/src/components/RsInputNumber.vue +13 -4
- package/src/components/RsLink.vue +1 -1
- package/src/components/RsMarkdown.vue +5 -5
- package/src/components/RsMenu.vue +2 -2
- package/src/components/RsMonacoEditor.vue +9 -3
- package/src/components/RsPagination.vue +1 -1
- package/src/components/RsProseEditor.vue +3 -3
- package/src/components/RsSelect.vue +332 -611
- package/src/components/RsSidebarGroup.vue +1 -1
- package/src/components/RsSplitPane.vue +22 -1
- package/src/components/RsStatCard.vue +2 -2
- package/src/components/RsSteps.vue +2 -2
- package/src/components/RsSwitch.vue +20 -7
- package/src/components/RsTabs.vue +9 -9
- package/src/components/RsTag.vue +1 -1
- package/src/components/RsTerminal.vue +58 -19
- package/src/components/RsTimePicker.vue +28 -4
- package/src/components/RsTimePickerColumns.vue +4 -4
- package/src/components/RsToaster.vue +7 -7
- package/src/components/RsTooltip.vue +10 -1
- package/src/components/RsTooltipProvider.vue +11 -1
- package/src/components/RsTree.vue +6 -6
- package/src/components/RsUpload.vue +2 -2
- package/src/components/date-picker-utils.ts +113 -2
- package/src/components/dialog-utils.ts +37 -2
- package/src/components/form-item-control.ts +55 -0
- package/src/components/form-path.ts +92 -0
- package/src/components/form-rules.ts +64 -10
- package/src/components/form-utils.ts +94 -2
- package/src/components/select-utils.ts +272 -12
- package/src/components/table/RsTableHeaderFilter.vue +1 -1
- package/src/components/table/rs-table.css +1 -1
- package/src/components/use-rs-select.ts +436 -0
- package/src/composables/useRsConfig.ts +10 -3
- package/src/composables/useRsI18n.ts +16 -5
- package/src/index.ts +69 -1
- package/src/locale/interpolate.ts +32 -0
- package/src/locale/messages.ts +10 -0
- package/src/styles.css +68 -12
- package/src/theme/brand.example.css +4 -1
- package/src/theme/css-token.ts +107 -0
- package/src/theme/types.ts +1 -0
|
@@ -25,8 +25,28 @@ export interface RsDateRangeValue {
|
|
|
25
25
|
end?: string
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
export
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
195
|
+
// 对齐 Ant / RHF:无 name 不进字段表,弹层搜索框不参与外层校验
|
|
196
|
+
if (!expose?.name) return
|
|
197
|
+
form.registerField(fieldId, expose)
|
|
106
198
|
})
|
|
107
199
|
|
|
108
200
|
onUnmounted(() => {
|