@veltra/desktop 1.7.15 → 1.7.16
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/dist/components/button/button.js +2 -2
- package/dist/components/button/button.js.map +1 -1
- package/dist/components/form-item/style2.css +17 -2
- package/dist/components/table-editor/style2.css +27 -4
- package/dist/components/table-editor/table-editor.js +25 -10
- package/dist/components/table-editor/table-editor.js.map +1 -1
- package/package.json +1 -1
- package/src/components/button/button.vue +3 -2
- package/src/components/form-item/style.scss +11 -1
- package/src/components/table-editor/__test__/table-editor.test.ts +16 -8
- package/src/components/table-editor/style.scss +26 -12
- package/src/components/table-editor/table-editor.vue +30 -9
|
@@ -47,8 +47,8 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
|
|
|
47
47
|
];
|
|
48
48
|
});
|
|
49
49
|
const ripple = computed(() => {
|
|
50
|
-
if (props.disabled || props.loading
|
|
51
|
-
if (props.plain && props.type) return bem.is(`ripple-${props.type}`);
|
|
50
|
+
if (props.disabled || props.loading) return false;
|
|
51
|
+
if ((props.plain || props.text) && props.type) return bem.is(`ripple-${props.type}`);
|
|
52
52
|
return true;
|
|
53
53
|
});
|
|
54
54
|
const handleClick = (e) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"button.js","names":[],"sources":["../../../src/components/button/button.vue"],"sourcesContent":["<template>\n <button :class=\"classList\" type=\"button\" ref=\"buttonRef\" v-ripple=\"ripple\" @click=\"handleClick\">\n <!-- 加载图标 -->\n <u-icon v-if=\"loading\" :class=\"[bem.is('loading')]\" :size=\"iconSize\">\n <component :is=\"loadingIcon\" />\n </u-icon>\n\n <!-- 左侧图标 -->\n <u-icon v-if=\"!!icon && iconPosition === 'left' && !loading\" :size=\"iconSize\">\n <component :is=\"icon\" />\n </u-icon>\n\n <slot />\n\n <!-- 右侧图标 -->\n <u-icon v-if=\"!!icon && iconPosition === 'right'\" :size=\"iconSize\">\n <component :is=\"icon\" />\n </u-icon>\n </button>\n</template>\n\n<script lang=\"ts\" setup>\nimport { useFallbackProps } from '@veltra/compositions'\nimport { vRipple } from '@veltra/directives'\nimport { Loading } from '@veltra/icons'\nimport { bem } from '@veltra/utils'\nimport { computed, shallowRef } from 'vue'\n\nimport type { ButtonEmits, ButtonProps, _ButtonExposed, ComponentSize } from '../../types'\nimport { UIcon } from '../icon'\n\ndefineOptions({ name: 'UButton' })\n\nconst props = withDefaults(defineProps<ButtonProps>(), {\n iconPosition: 'left',\n loadingIcon: () => Loading,\n disabled: false,\n propagate: true\n})\n\nconst { size } = useFallbackProps([props], { size: 'default' as ComponentSize })\n\nconst emit = defineEmits<ButtonEmits>()\n\nconst cls = bem('button')\n\nconst classList = computed(() => {\n return [\n cls.b,\n cls.m(size.value),\n props.type && cls.m('color-' + props.type),\n bem.is('circle', props.circle),\n bem.is('disabled', props.disabled),\n bem.is('loading', props.loading),\n bem.is('plain', props.plain),\n bem.is('text', props.text)\n ]\n})\n\nconst ripple = computed(() => {\n if (props.disabled || props.loading
|
|
1
|
+
{"version":3,"file":"button.js","names":[],"sources":["../../../src/components/button/button.vue"],"sourcesContent":["<template>\n <button :class=\"classList\" type=\"button\" ref=\"buttonRef\" v-ripple=\"ripple\" @click=\"handleClick\">\n <!-- 加载图标 -->\n <u-icon v-if=\"loading\" :class=\"[bem.is('loading')]\" :size=\"iconSize\">\n <component :is=\"loadingIcon\" />\n </u-icon>\n\n <!-- 左侧图标 -->\n <u-icon v-if=\"!!icon && iconPosition === 'left' && !loading\" :size=\"iconSize\">\n <component :is=\"icon\" />\n </u-icon>\n\n <slot />\n\n <!-- 右侧图标 -->\n <u-icon v-if=\"!!icon && iconPosition === 'right'\" :size=\"iconSize\">\n <component :is=\"icon\" />\n </u-icon>\n </button>\n</template>\n\n<script lang=\"ts\" setup>\nimport { useFallbackProps } from '@veltra/compositions'\nimport { vRipple } from '@veltra/directives'\nimport { Loading } from '@veltra/icons'\nimport { bem } from '@veltra/utils'\nimport { computed, shallowRef } from 'vue'\n\nimport type { ButtonEmits, ButtonProps, _ButtonExposed, ComponentSize } from '../../types'\nimport { UIcon } from '../icon'\n\ndefineOptions({ name: 'UButton' })\n\nconst props = withDefaults(defineProps<ButtonProps>(), {\n iconPosition: 'left',\n loadingIcon: () => Loading,\n disabled: false,\n propagate: true\n})\n\nconst { size } = useFallbackProps([props], { size: 'default' as ComponentSize })\n\nconst emit = defineEmits<ButtonEmits>()\n\nconst cls = bem('button')\n\nconst classList = computed(() => {\n return [\n cls.b,\n cls.m(size.value),\n props.type && cls.m('color-' + props.type),\n bem.is('circle', props.circle),\n bem.is('disabled', props.disabled),\n bem.is('loading', props.loading),\n bem.is('plain', props.plain),\n bem.is('text', props.text)\n ]\n})\n\nconst ripple = computed(() => {\n if (props.disabled || props.loading) return false\n // plain / text 变体无底色,波纹用类型色才能看清\n if ((props.plain || props.text) && props.type) {\n return bem.is(`ripple-${props.type}`)\n }\n return true\n})\n\nconst handleClick = (e: MouseEvent) => {\n if (props.disabled || props.loading) {\n e.stopPropagation()\n return\n }\n\n !props.propagate && e.stopPropagation()\n\n emit('click', e)\n}\n\nconst buttonRef = shallowRef<HTMLButtonElement>()\n\nconst exposed: _ButtonExposed = { el: buttonRef }\n\ndefineExpose(exposed)\n</script>\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAiCA,MAAM,QAAQ;EAOd,MAAM,EAAE,SAAS,iBAAiB,CAAC,KAAK,GAAG,EAAE,MAAM,UAA2B,CAAC;EAE/E,MAAM,OAAO;EAEb,MAAM,MAAM,IAAI,QAAQ;EAExB,MAAM,YAAY,eAAe;GAC/B,OAAO;IACL,IAAI;IACJ,IAAI,EAAE,KAAK,KAAK;IAChB,MAAM,QAAQ,IAAI,EAAE,WAAW,MAAM,IAAI;IACzC,IAAI,GAAG,UAAU,MAAM,MAAM;IAC7B,IAAI,GAAG,YAAY,MAAM,QAAQ;IACjC,IAAI,GAAG,WAAW,MAAM,OAAO;IAC/B,IAAI,GAAG,SAAS,MAAM,KAAK;IAC3B,IAAI,GAAG,QAAQ,MAAM,IAAI;GAC3B;EACF,CAAC;EAED,MAAM,SAAS,eAAe;GAC5B,IAAI,MAAM,YAAY,MAAM,SAAS,OAAO;GAE5C,KAAK,MAAM,SAAS,MAAM,SAAS,MAAM,MACvC,OAAO,IAAI,GAAG,UAAU,MAAM,MAAM;GAEtC,OAAO;EACT,CAAC;EAED,MAAM,eAAe,MAAkB;GACrC,IAAI,MAAM,YAAY,MAAM,SAAS;IACnC,EAAE,gBAAgB;IAClB;GACF;GAEA,CAAC,MAAM,aAAa,EAAE,gBAAgB;GAEtC,KAAK,SAAS,CAAC;EACjB;EAEA,MAAM,YAAY,WAA8B;EAIhD,SAAa,EAFqB,IAAI,UAEzB,CAAO;;GAlFlB,OAAA,gBAAA,UAAA,GAAA,mBAiBS,UAAA;IAjBA,OAAK,eAAE,UAAA,KAAS;IAAE,MAAK;IAAa,SAAA;IAAJ,KAAI;IAA+B,SAAO;;IACjF,mBAAA,QAAA;IACc,QAAA,WAAd,UAAA,GAAA,YAES,MAAA,WAAA,GAAA;;KAFe,OAAK,eAAA,CAAG,MAAA,GAAA,CAAG,CAAC,GAAE,SAAA,CAAA,CAAA;KAAe,MAAM,QAAA;;KACzD,SAAA,cAA+B,EAA/B,UAAA,GAAA,YAA+B,wBAAf,QAAA,WAAW,CAAA,EAAA,CAAA;;;IAG7B,mBAAA,QAAA;IACgB,CAAA,CAAA,QAAA,QAAQ,QAAA,iBAAY,UAAA,CAAgB,QAAA,WAApD,UAAA,GAAA,YAES,MAAA,WAAA,GAAA;;KAFqD,MAAM,QAAA;;KAClE,SAAA,cAAwB,EAAxB,UAAA,GAAA,YAAwB,wBAAR,QAAA,IAAI,CAAA,EAAA,CAAA;;;IAGtB,WAAQ,KAAA,QAAA,SAAA;IAER,mBAAA,QAAA;IACgB,CAAA,CAAA,QAAA,QAAQ,QAAA,iBAAY,WAApC,UAAA,GAAA,YAES,MAAA,WAAA,GAAA;;KAF0C,MAAM,QAAA;;KACvD,SAAA,cAAwB,EAAxB,UAAA,GAAA,YAAwB,wBAAR,QAAA,IAAI,CAAA,EAAA,CAAA;;;GAf2C,GAAA,CAAA,IAAA,CAAA,CAAA,MAAA,OAAA,GAAA,OAAA,KAAM,CAAA,CAAA"}
|
|
@@ -69,13 +69,28 @@
|
|
|
69
69
|
flex-grow: 1;
|
|
70
70
|
min-width: 0;
|
|
71
71
|
}
|
|
72
|
-
.u-form-item.is-error .u-input,
|
|
72
|
+
.u-form-item.is-error .u-input, .u-form-item.is-error .u-input:hover, .u-form-item.is-error .u-input.is-focus,
|
|
73
73
|
.u-form-item.is-error .u-multi-select,
|
|
74
|
+
.u-form-item.is-error .u-multi-select:hover,
|
|
75
|
+
.u-form-item.is-error .u-multi-select.is-focus,
|
|
74
76
|
.u-form-item.is-error .u-multi-tree-select,
|
|
77
|
+
.u-form-item.is-error .u-multi-tree-select:hover,
|
|
78
|
+
.u-form-item.is-error .u-multi-tree-select.is-focus,
|
|
75
79
|
.u-form-item.is-error .u-auto-complete,
|
|
76
|
-
.u-form-item.is-error .u-
|
|
80
|
+
.u-form-item.is-error .u-auto-complete:hover,
|
|
81
|
+
.u-form-item.is-error .u-auto-complete.is-focus,
|
|
82
|
+
.u-form-item.is-error .u-textarea--more,
|
|
83
|
+
.u-form-item.is-error .u-textarea--more:hover,
|
|
84
|
+
.u-form-item.is-error .u-textarea--more.is-focus {
|
|
77
85
|
border-color: var(--u-color-danger);
|
|
78
86
|
}
|
|
87
|
+
.u-form-item.is-error .u-input.is-focus,
|
|
88
|
+
.u-form-item.is-error .u-multi-select.is-focus,
|
|
89
|
+
.u-form-item.is-error .u-multi-tree-select.is-focus,
|
|
90
|
+
.u-form-item.is-error .u-auto-complete.is-focus,
|
|
91
|
+
.u-form-item.is-error .u-textarea--more.is-focus {
|
|
92
|
+
box-shadow: var(--u-focus-ring-danger);
|
|
93
|
+
}
|
|
79
94
|
.u-form-item__content {
|
|
80
95
|
flex-grow: 1;
|
|
81
96
|
word-break: break-all;
|
|
@@ -16,13 +16,36 @@
|
|
|
16
16
|
*/
|
|
17
17
|
/** 暗色:`UITheme.render()` 按主题系列写入 html[data-theme] */
|
|
18
18
|
.u-table-editor .u-table__body .u-table__cell {
|
|
19
|
-
padding:
|
|
20
|
-
height: calc(var(--u-form-component-height-default) + 4px + 1px);
|
|
19
|
+
padding: 4px;
|
|
21
20
|
}
|
|
22
|
-
.u-table-editor .u-table__body .u-table__cell
|
|
23
|
-
.u-table-editor .u-table__body .u-table__cell [class^=u-input] {
|
|
21
|
+
.u-table-editor .u-table__body .u-table__cell [class^=u-] {
|
|
24
22
|
vertical-align: top;
|
|
25
23
|
}
|
|
24
|
+
.u-table-editor .is-error {
|
|
25
|
+
color: var(--u-color-danger);
|
|
26
|
+
}
|
|
27
|
+
.u-table-editor .is-error .u-input, .u-table-editor .is-error .u-input:hover, .u-table-editor .is-error .u-input.is-focus,
|
|
28
|
+
.u-table-editor .is-error .u-multi-select,
|
|
29
|
+
.u-table-editor .is-error .u-multi-select:hover,
|
|
30
|
+
.u-table-editor .is-error .u-multi-select.is-focus,
|
|
31
|
+
.u-table-editor .is-error .u-multi-tree-select,
|
|
32
|
+
.u-table-editor .is-error .u-multi-tree-select:hover,
|
|
33
|
+
.u-table-editor .is-error .u-multi-tree-select.is-focus,
|
|
34
|
+
.u-table-editor .is-error .u-auto-complete,
|
|
35
|
+
.u-table-editor .is-error .u-auto-complete:hover,
|
|
36
|
+
.u-table-editor .is-error .u-auto-complete.is-focus,
|
|
37
|
+
.u-table-editor .is-error .u-textarea--more,
|
|
38
|
+
.u-table-editor .is-error .u-textarea--more:hover,
|
|
39
|
+
.u-table-editor .is-error .u-textarea--more.is-focus {
|
|
40
|
+
border-color: var(--u-color-danger);
|
|
41
|
+
}
|
|
42
|
+
.u-table-editor .is-error .u-input.is-focus,
|
|
43
|
+
.u-table-editor .is-error .u-multi-select.is-focus,
|
|
44
|
+
.u-table-editor .is-error .u-multi-tree-select.is-focus,
|
|
45
|
+
.u-table-editor .is-error .u-auto-complete.is-focus,
|
|
46
|
+
.u-table-editor .is-error .u-textarea--more.is-focus {
|
|
47
|
+
box-shadow: var(--u-focus-ring-danger);
|
|
48
|
+
}
|
|
26
49
|
.u-table-editor .u-table-editor__operations {
|
|
27
50
|
display: flex;
|
|
28
51
|
justify-content: center;
|
|
@@ -111,6 +111,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
|
|
|
111
111
|
const rowErrors = cellErrors.value.get(row);
|
|
112
112
|
if (!message) {
|
|
113
113
|
if (!rowErrors?.delete(key)) return;
|
|
114
|
+
if (!rowErrors.size) cellErrors.value.delete(row);
|
|
114
115
|
} else {
|
|
115
116
|
if (rowErrors?.get(key) === message) return;
|
|
116
117
|
if (rowErrors) rowErrors.set(key, message);
|
|
@@ -137,16 +138,13 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
|
|
|
137
138
|
setCellError(row, key, message);
|
|
138
139
|
}
|
|
139
140
|
/**
|
|
140
|
-
*
|
|
141
|
+
* 整表校验:并行校验所有行的规则列(不懒校验),未通过的单元格全部标出。
|
|
141
142
|
* 全部通过 resolve true,否则 resolve false。
|
|
142
143
|
*/
|
|
143
144
|
async function validate() {
|
|
144
145
|
const ruleColumns = columns.value.filter((column) => column.rules);
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (cellErrors.value.get(errorRowKey(row))?.size) return false;
|
|
148
|
-
}
|
|
149
|
-
return true;
|
|
146
|
+
await Promise.all(modelValue.value.map((row) => Promise.all(ruleColumns.map((column) => validateCell(row, column.key, column.rules)))));
|
|
147
|
+
return cellErrors.value.size === 0;
|
|
150
148
|
}
|
|
151
149
|
watch(modelValue, (rows) => {
|
|
152
150
|
if (!cellErrors.value.size) return;
|
|
@@ -172,19 +170,36 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
|
|
|
172
170
|
}
|
|
173
171
|
return map;
|
|
174
172
|
});
|
|
175
|
-
/**
|
|
173
|
+
/**
|
|
174
|
+
* 声明了 `#column:key` 插槽的列调用编辑插槽(注入 model),未声明的渲染字段原始值。
|
|
175
|
+
* 配置了 rules 的列在渲染期间读取 cellErrors 建立依赖,错误出现/消失即时更新:
|
|
176
|
+
* 插槽内容常驻包一层容器、仅切换 is-error 类(与表单项错误态同类,不增删 DOM,
|
|
177
|
+
* 控件不因错误态变化重挂丢焦点);纯文本列仅在出错时包一层标红原始值。
|
|
178
|
+
*/
|
|
176
179
|
function editCell(column, ctx) {
|
|
177
180
|
const editSlot = slots[`column:${column.key}`];
|
|
178
181
|
const val = o(ctx.rowData).get(column.key);
|
|
179
|
-
if (!
|
|
182
|
+
if (!column.rules) {
|
|
183
|
+
if (!editSlot) return val;
|
|
184
|
+
const scope = {
|
|
185
|
+
...ctx,
|
|
186
|
+
val
|
|
187
|
+
};
|
|
188
|
+
return editSlot({
|
|
189
|
+
...scope,
|
|
190
|
+
model: createCellModel(column, scope)
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
const hasError = !!cellErrors.value.get(errorRowKey(ctx.rowData))?.has(column.key);
|
|
194
|
+
if (!editSlot) return hasError ? h("span", { class: bem.is("error") }, val) : val;
|
|
180
195
|
const scope = {
|
|
181
196
|
...ctx,
|
|
182
197
|
val
|
|
183
198
|
};
|
|
184
|
-
return editSlot({
|
|
199
|
+
return h("span", { class: bem.is("error", hasError) }, editSlot({
|
|
185
200
|
...scope,
|
|
186
201
|
model: createCellModel(column, scope)
|
|
187
|
-
});
|
|
202
|
+
}));
|
|
188
203
|
}
|
|
189
204
|
/**
|
|
190
205
|
* 单元格编辑 model:值先原地写回行数据(行节点与 DOM 得以复用,输入不丢焦点),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"table-editor.js","names":[],"sources":["../../../src/components/table-editor/table-editor.vue"],"sourcesContent":["<template>\n <u-table\n v-bind=\"tableProps\"\n :columns=\"internalColumns\"\n :data=\"modelValue\"\n :class=\"cls.b\"\n :stripe=\"false\"\n show-index\n >\n <!-- 其余插槽(header:* / row:expand / foot / append 等)透传给 u-table;column:* 与 empty 由组件接管 -->\n <template v-for=\"name in passSlotNames\" :key=\"name\" #[name]=\"scope\">\n <slot :name=\"name\" v-bind=\"scope\" />\n </template>\n\n <template #column:__operation=\"{ row }\">\n <div :class=\"cls.e('operations')\">\n <u-button\n text\n size=\"small\"\n type=\"danger\"\n :icon=\"Minus\"\n title=\"移除\"\n @click=\"handleDelete(row.index)\"\n />\n <u-button\n text\n size=\"small\"\n :icon=\"Plus\"\n title=\"新增到下一行\"\n @click=\"handleCreate(row.index)\"\n />\n <u-button text size=\"small\" :icon=\"Copy\" title=\"复制到下一行\" @click=\"handleCopy(row)\" />\n </div>\n </template>\n\n <template #empty>\n <div style=\"text-align: center; padding: 4px 0\">\n <u-button\n v-if=\"!props.readonly\"\n text\n size=\"small\"\n type=\"primary\"\n :icon=\"Plus\"\n @click=\"handleCreate()\"\n >\n 添加\n </u-button>\n </div>\n </template>\n </u-table>\n</template>\n\n<script lang=\"ts\" setup>\nimport { o } from '@cat-kit/core'\nimport { Copy, Minus, Plus, Warning } from '@veltra/icons/normal'\nimport { bem } from '@veltra/utils'\nimport { computed, h, shallowRef, toRaw, triggerRef, useSlots, watch } from 'vue'\n\nimport type {\n TableEditorProps,\n TableEditorEmits,\n TableEditorColumn,\n _TableEditorExposed,\n TableColumn,\n TableColumnNode,\n TableColumnRenderContext,\n TableColumnSlotsScope,\n RenderReturn\n} from '../../types'\nimport { UButton } from '../button'\nimport { validateField } from '../form-item/validate'\nimport { UIcon } from '../icon'\nimport { UTable } from '../table'\nimport type { TableRowNode } from '../table/node/row'\nimport { UTip } from '../tip'\n\ndefineOptions({ name: 'UTableEditor' })\n\ndefineSlots<{\n [key: `column:${string}`]: (props: TableColumnSlotsScope) => any\n [key: `header:${string}`]: (props: { column: TableColumnNode }) => any\n}>()\n\nconst props = defineProps<TableEditorProps>()\nconst emit = defineEmits<TableEditorEmits>()\n\nconst columns = computed(() => props.columns ?? [])\nconst modelValue = computed(() => props.modelValue ?? [])\n\n/** 转发给 u-table 的表格属性;columns / modelValue / readonly 为编辑器自用,stripe / showIndex 被覆盖 */\nconst tableProps = computed(() => {\n const rest: Record<string, any> = { ...props }\n delete rest.columns\n delete rest.modelValue\n delete rest.readonly\n return rest\n})\n\nconst cls = bem('table-editor')\n\nconst slots = useSlots()\n\nconst actionColumn: TableColumn = {\n key: '__operation',\n name: '操作',\n width: 104,\n align: 'center',\n fixed: 'right',\n resizable: false\n}\n\n/**\n * 内部列定义:单元格渲染挂在列的 render 上(用户列自带的 render 优先),\n * 配置了 rules 的列包装 nameRender 追加校验标识,非只读时末尾追加操作列。\n * 列 key 带上只读态后缀:u-table 行内单元格在 props 值相等时可能跳过子组件补丁,\n * 只读切换改用挂载键直接重挂单元格,确保控件在编辑/只读形态间可靠切换。\n */\nconst internalColumns = computed<TableColumn[]>(() => {\n const list = columns.value.map((column) => ({\n ...column,\n key: `${column.key}${props.readonly ? ':ro' : ''}`,\n nameRender: column.rules ? headerCell(column) : column.nameRender,\n render: column.render ?? ((ctx: TableColumnRenderContext) => editCell(column, ctx))\n }))\n return props.readonly ? list : [...list, actionColumn]\n})\n\n/** 透传给 u-table 的插槽名:column:* 经 editCell 注入 model、empty 由组件接管,其余透传 */\nconst passSlotNames = Object.keys(slots).filter(\n (name) => !name.startsWith('column:') && name !== 'empty'\n)\n\n/**\n * 带校验的列表头包装:required 时列名前渲染红星;该列存在未通过项时文字标红并追加\n * 感叹号图标,悬停经 UTip 气泡展示各行错误明细;`#header:key` 插槽 / nameRender 内容替代列名。\n * 渲染函数内读取 columnErrors 建立响应依赖,错误出现/消失即时更新表头。\n */\nfunction headerCell(column: TableEditorColumn) {\n return (ctx: { column: TableColumnNode }): RenderReturn => {\n const messages = columnErrors.value.get(column.key)\n const nodes = [\n h(\n 'span',\n { class: [cls.e('header-text'), bem.is('error', !!messages?.length)] },\n slots[`header:${column.key}`]?.(ctx) ?? column.nameRender?.(ctx) ?? column.name\n )\n ]\n\n if (column.rules?.required) {\n nodes.unshift(h('span', { class: cls.e('required-mark'), 'aria-hidden': 'true' }, '*'))\n }\n\n if (messages?.length) {\n nodes.push(\n h(\n UTip,\n { key: 'header-error-tip' },\n {\n default: () =>\n h(UIcon, { size: 14, class: cls.e('header-icon') }, { default: () => h(Warning) }),\n content: () => messages.map((message) => h('div', { key: message }, message))\n }\n )\n )\n }\n\n return nodes\n }\n}\n\n// --- 按列校验 ---\n\n/** 行数据(以原始对象为键)→ 列 key → 错误信息 */\nconst cellErrors = shallowRef(new Map<Record<string, any>, Map<string, string>>())\n\n/** 行节点 data 可能是响应式代理,统一取原始对象作为键 */\nconst errorRowKey = (rowData: Record<string, any>) => toRaw(rowData) as Record<string, any>\n\nfunction setCellError(rowData: Record<string, any>, key: string, message: string | undefined) {\n const row = errorRowKey(rowData)\n const rowErrors = cellErrors.value.get(row)\n\n if (!message) {\n if (!rowErrors?.delete(key)) return\n } else {\n if (rowErrors?.get(key) === message) return\n if (rowErrors) rowErrors.set(key, message)\n else cellErrors.value.set(row, new Map([[key, message]]))\n }\n\n triggerRef(cellErrors)\n}\n\n/** 丢弃同一单元格过期的异步校验结果 */\nconst validateSeqs = new WeakMap<Record<string, any>, Map<string, number>>()\n\n/** 单元格级校验:以行数据为 formData、列 key 为 field,结果同步到错误状态 */\nasync function validateCell(\n rowData: Record<string, any>,\n key: string,\n rules: TableEditorColumn['rules']\n) {\n if (!rules) return\n\n const row = errorRowKey(rowData)\n let seqs = validateSeqs.get(row)\n if (!seqs) {\n seqs = new Map()\n validateSeqs.set(row, seqs)\n }\n const seq = (seqs.get(key) ?? 0) + 1\n seqs.set(key, seq)\n\n const message = await validateField(rowData, key, rules)\n if (seqs.get(key) !== seq) return\n // 校验是异步的,期间行可能已被移出模型(整体替换/删除);\n // 把结果写回「死行」会绕过清理 watch 永久残留,直接丢弃\n if (!modelValue.value.some((item) => errorRowKey(item) === row)) return\n\n setCellError(row, key, message)\n}\n\n/**\n * 整表校验:自上而下逐行校验,某行存在未通过项即停止校验其后的行(懒校验)。\n * 全部通过 resolve true,否则 resolve false。\n */\nasync function validate() {\n const ruleColumns = columns.value.filter((column) => column.rules)\n\n for (const row of modelValue.value) {\n await Promise.all(ruleColumns.map((column) => validateCell(row, column.key, column.rules)))\n if (cellErrors.value.get(errorRowKey(row))?.size) return false\n }\n\n return true\n}\n\n// 行删除(或整体替换)后,同步清理已不存在的行数据上的错误\nwatch(modelValue, (rows) => {\n if (!cellErrors.value.size) return\n\n const aliveRows = new Set(rows.map(errorRowKey))\n const next = new Map<Record<string, any>, Map<string, string>>()\n let changed = false\n\n for (const [row, errors] of cellErrors.value) {\n if (aliveRows.has(row)) next.set(row, errors)\n else changed = true\n }\n\n if (changed) cellErrors.value = next\n})\n\n/** 列 key → 该列全部未通过信息(带行序号),驱动表头错误标识 */\nconst columnErrors = computed(() => {\n const map = new Map<string, string[]>()\n if (!cellErrors.value.size) return map\n\n for (const [row, errors] of cellErrors.value) {\n const index = modelValue.value.findIndex((item) => errorRowKey(item) === row)\n const label = index >= 0 ? `第 ${index + 1} 行:` : ''\n for (const [key, message] of errors) {\n const list = map.get(key) ?? []\n list.push(`${label}${message}`)\n map.set(key, list)\n }\n }\n return map\n})\n\n// --- 单元格渲染 ---\n\n/** 声明了 `#column:key` 插槽的列调用编辑插槽(注入 model),未声明的渲染字段原始值 */\nfunction editCell(column: TableEditorColumn, ctx: TableColumnRenderContext): RenderReturn {\n const editSlot = slots[`column:${column.key}`]\n // 内部列 key 带只读态后缀,u-table 按 key 取到的 val 失效,这里按原始列 key 重取\n const val = o(ctx.rowData).get(column.key)\n if (!editSlot) return val\n const scope = { ...ctx, val }\n return editSlot({ ...scope, model: createCellModel(column, scope) })\n}\n\n/**\n * 单元格编辑 model:值先原地写回行数据(行节点与 DOM 得以复用,输入不丢焦点),\n * 再以浅拷贝数组经 update:modelValue 通知宿主,维持 v-model 契约。\n * 只读下注入 readonly: true 且不提供写回通道,值不可修改;\n * 列配置了 rules 时,控件的 change 事件(如失焦提交)触发单元格级校验,输入过程不校验。\n */\nfunction createCellModel(column: TableEditorColumn, ctx: TableColumnRenderContext) {\n const { rowData, val } = ctx\n const key = column.key\n const rules = column.rules\n return {\n modelValue: val,\n ...(props.readonly\n ? { readonly: true }\n : {\n 'onUpdate:modelValue': (value: any) => {\n rowData[key] = value\n emit('update:modelValue', [...modelValue.value])\n }\n }),\n ...(rules ? { onChange: () => void validateCell(rowData, key, rules) } : {})\n }\n}\n\nfunction handleCreate(index?: number) {\n const at = index !== undefined ? index + 1 : modelValue.value.length\n emit('update:modelValue', [...modelValue.value.slice(0, at), {}, ...modelValue.value.slice(at)])\n}\n\nfunction handleCopy(row: TableRowNode) {\n emit('update:modelValue', [\n ...modelValue.value.slice(0, row.index + 1),\n JSON.parse(JSON.stringify(row.data)),\n ...modelValue.value.slice(row.index + 1)\n ])\n}\n\nfunction handleDelete(index: number) {\n emit(\n 'update:modelValue',\n modelValue.value.filter((_, i) => i !== index)\n )\n}\n\ndefineExpose<_TableEditorExposed>({ validate })\n</script>\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"table-editor.js","names":[],"sources":["../../../src/components/table-editor/table-editor.vue"],"sourcesContent":["<template>\n <u-table\n v-bind=\"tableProps\"\n :columns=\"internalColumns\"\n :data=\"modelValue\"\n :class=\"cls.b\"\n :stripe=\"false\"\n show-index\n >\n <!-- 其余插槽(header:* / row:expand / foot / append 等)透传给 u-table;column:* 与 empty 由组件接管 -->\n <template v-for=\"name in passSlotNames\" :key=\"name\" #[name]=\"scope\">\n <slot :name=\"name\" v-bind=\"scope\" />\n </template>\n\n <template #column:__operation=\"{ row }\">\n <div :class=\"cls.e('operations')\">\n <u-button\n text\n size=\"small\"\n type=\"danger\"\n :icon=\"Minus\"\n title=\"移除\"\n @click=\"handleDelete(row.index)\"\n />\n <u-button\n text\n size=\"small\"\n :icon=\"Plus\"\n title=\"新增到下一行\"\n @click=\"handleCreate(row.index)\"\n />\n <u-button text size=\"small\" :icon=\"Copy\" title=\"复制到下一行\" @click=\"handleCopy(row)\" />\n </div>\n </template>\n\n <template #empty>\n <div style=\"text-align: center; padding: 4px 0\">\n <u-button\n v-if=\"!props.readonly\"\n text\n size=\"small\"\n type=\"primary\"\n :icon=\"Plus\"\n @click=\"handleCreate()\"\n >\n 添加\n </u-button>\n </div>\n </template>\n </u-table>\n</template>\n\n<script lang=\"ts\" setup>\nimport { o } from '@cat-kit/core'\nimport { Copy, Minus, Plus, Warning } from '@veltra/icons/normal'\nimport { bem } from '@veltra/utils'\nimport { computed, h, shallowRef, toRaw, triggerRef, useSlots, watch } from 'vue'\n\nimport type {\n TableEditorProps,\n TableEditorEmits,\n TableEditorColumn,\n _TableEditorExposed,\n TableColumn,\n TableColumnNode,\n TableColumnRenderContext,\n TableColumnSlotsScope,\n RenderReturn\n} from '../../types'\nimport { UButton } from '../button'\nimport { validateField } from '../form-item/validate'\nimport { UIcon } from '../icon'\nimport { UTable } from '../table'\nimport type { TableRowNode } from '../table/node/row'\nimport { UTip } from '../tip'\n\ndefineOptions({ name: 'UTableEditor' })\n\ndefineSlots<{\n [key: `column:${string}`]: (props: TableColumnSlotsScope) => any\n [key: `header:${string}`]: (props: { column: TableColumnNode }) => any\n}>()\n\nconst props = defineProps<TableEditorProps>()\nconst emit = defineEmits<TableEditorEmits>()\n\nconst columns = computed(() => props.columns ?? [])\nconst modelValue = computed(() => props.modelValue ?? [])\n\n/** 转发给 u-table 的表格属性;columns / modelValue / readonly 为编辑器自用,stripe / showIndex 被覆盖 */\nconst tableProps = computed(() => {\n const rest: Record<string, any> = { ...props }\n delete rest.columns\n delete rest.modelValue\n delete rest.readonly\n return rest\n})\n\nconst cls = bem('table-editor')\n\nconst slots = useSlots()\n\nconst actionColumn: TableColumn = {\n key: '__operation',\n name: '操作',\n width: 104,\n align: 'center',\n fixed: 'right',\n resizable: false\n}\n\n/**\n * 内部列定义:单元格渲染挂在列的 render 上(用户列自带的 render 优先),\n * 配置了 rules 的列包装 nameRender 追加校验标识,非只读时末尾追加操作列。\n * 列 key 带上只读态后缀:u-table 行内单元格在 props 值相等时可能跳过子组件补丁,\n * 只读切换改用挂载键直接重挂单元格,确保控件在编辑/只读形态间可靠切换。\n */\nconst internalColumns = computed<TableColumn[]>(() => {\n const list = columns.value.map((column) => ({\n ...column,\n key: `${column.key}${props.readonly ? ':ro' : ''}`,\n nameRender: column.rules ? headerCell(column) : column.nameRender,\n render: column.render ?? ((ctx: TableColumnRenderContext) => editCell(column, ctx))\n }))\n return props.readonly ? list : [...list, actionColumn]\n})\n\n/** 透传给 u-table 的插槽名:column:* 经 editCell 注入 model、empty 由组件接管,其余透传 */\nconst passSlotNames = Object.keys(slots).filter(\n (name) => !name.startsWith('column:') && name !== 'empty'\n)\n\n/**\n * 带校验的列表头包装:required 时列名前渲染红星;该列存在未通过项时文字标红并追加\n * 感叹号图标,悬停经 UTip 气泡展示各行错误明细;`#header:key` 插槽 / nameRender 内容替代列名。\n * 渲染函数内读取 columnErrors 建立响应依赖,错误出现/消失即时更新表头。\n */\nfunction headerCell(column: TableEditorColumn) {\n return (ctx: { column: TableColumnNode }): RenderReturn => {\n const messages = columnErrors.value.get(column.key)\n const nodes = [\n h(\n 'span',\n { class: [cls.e('header-text'), bem.is('error', !!messages?.length)] },\n slots[`header:${column.key}`]?.(ctx) ?? column.nameRender?.(ctx) ?? column.name\n )\n ]\n\n if (column.rules?.required) {\n nodes.unshift(h('span', { class: cls.e('required-mark'), 'aria-hidden': 'true' }, '*'))\n }\n\n if (messages?.length) {\n nodes.push(\n h(\n UTip,\n { key: 'header-error-tip' },\n {\n default: () =>\n h(UIcon, { size: 14, class: cls.e('header-icon') }, { default: () => h(Warning) }),\n content: () => messages.map((message) => h('div', { key: message }, message))\n }\n )\n )\n }\n\n return nodes\n }\n}\n\n// --- 按列校验 ---\n\n/** 行数据(以原始对象为键)→ 列 key → 错误信息 */\nconst cellErrors = shallowRef(new Map<Record<string, any>, Map<string, string>>())\n\n/** 行节点 data 可能是响应式代理,统一取原始对象作为键 */\nconst errorRowKey = (rowData: Record<string, any>) => toRaw(rowData) as Record<string, any>\n\nfunction setCellError(rowData: Record<string, any>, key: string, message: string | undefined) {\n const row = errorRowKey(rowData)\n const rowErrors = cellErrors.value.get(row)\n\n if (!message) {\n if (!rowErrors?.delete(key)) return\n // 行错误清空后移除条目,保证「size > 0 即存在错误」可作整体判定\n if (!rowErrors.size) cellErrors.value.delete(row)\n } else {\n if (rowErrors?.get(key) === message) return\n if (rowErrors) rowErrors.set(key, message)\n else cellErrors.value.set(row, new Map([[key, message]]))\n }\n\n triggerRef(cellErrors)\n}\n\n/** 丢弃同一单元格过期的异步校验结果 */\nconst validateSeqs = new WeakMap<Record<string, any>, Map<string, number>>()\n\n/** 单元格级校验:以行数据为 formData、列 key 为 field,结果同步到错误状态 */\nasync function validateCell(\n rowData: Record<string, any>,\n key: string,\n rules: TableEditorColumn['rules']\n) {\n if (!rules) return\n\n const row = errorRowKey(rowData)\n let seqs = validateSeqs.get(row)\n if (!seqs) {\n seqs = new Map()\n validateSeqs.set(row, seqs)\n }\n const seq = (seqs.get(key) ?? 0) + 1\n seqs.set(key, seq)\n\n const message = await validateField(rowData, key, rules)\n if (seqs.get(key) !== seq) return\n // 校验是异步的,期间行可能已被移出模型(整体替换/删除);\n // 把结果写回「死行」会绕过清理 watch 永久残留,直接丢弃\n if (!modelValue.value.some((item) => errorRowKey(item) === row)) return\n\n setCellError(row, key, message)\n}\n\n/**\n * 整表校验:并行校验所有行的规则列(不懒校验),未通过的单元格全部标出。\n * 全部通过 resolve true,否则 resolve false。\n */\nasync function validate() {\n const ruleColumns = columns.value.filter((column) => column.rules)\n\n await Promise.all(\n modelValue.value.map((row) =>\n Promise.all(ruleColumns.map((column) => validateCell(row, column.key, column.rules)))\n )\n )\n\n return cellErrors.value.size === 0\n}\n\n// 行删除(或整体替换)后,同步清理已不存在的行数据上的错误\nwatch(modelValue, (rows) => {\n if (!cellErrors.value.size) return\n\n const aliveRows = new Set(rows.map(errorRowKey))\n const next = new Map<Record<string, any>, Map<string, string>>()\n let changed = false\n\n for (const [row, errors] of cellErrors.value) {\n if (aliveRows.has(row)) next.set(row, errors)\n else changed = true\n }\n\n if (changed) cellErrors.value = next\n})\n\n/** 列 key → 该列全部未通过信息(带行序号),驱动表头错误标识 */\nconst columnErrors = computed(() => {\n const map = new Map<string, string[]>()\n if (!cellErrors.value.size) return map\n\n for (const [row, errors] of cellErrors.value) {\n const index = modelValue.value.findIndex((item) => errorRowKey(item) === row)\n const label = index >= 0 ? `第 ${index + 1} 行:` : ''\n for (const [key, message] of errors) {\n const list = map.get(key) ?? []\n list.push(`${label}${message}`)\n map.set(key, list)\n }\n }\n return map\n})\n\n// --- 单元格渲染 ---\n\n/**\n * 声明了 `#column:key` 插槽的列调用编辑插槽(注入 model),未声明的渲染字段原始值。\n * 配置了 rules 的列在渲染期间读取 cellErrors 建立依赖,错误出现/消失即时更新:\n * 插槽内容常驻包一层容器、仅切换 is-error 类(与表单项错误态同类,不增删 DOM,\n * 控件不因错误态变化重挂丢焦点);纯文本列仅在出错时包一层标红原始值。\n */\nfunction editCell(column: TableEditorColumn, ctx: TableColumnRenderContext): RenderReturn {\n const editSlot = slots[`column:${column.key}`]\n // 内部列 key 带只读态后缀,u-table 按 key 取到的 val 失效,这里按原始列 key 重取\n const val = o(ctx.rowData).get(column.key)\n\n if (!column.rules) {\n if (!editSlot) return val\n const scope = { ...ctx, val }\n return editSlot({ ...scope, model: createCellModel(column, scope) })\n }\n\n const hasError = !!cellErrors.value.get(errorRowKey(ctx.rowData))?.has(column.key)\n if (!editSlot) return hasError ? h('span', { class: bem.is('error') }, val) : val\n\n const scope = { ...ctx, val }\n return h(\n 'span',\n { class: bem.is('error', hasError) },\n editSlot({ ...scope, model: createCellModel(column, scope) })\n )\n}\n\n/**\n * 单元格编辑 model:值先原地写回行数据(行节点与 DOM 得以复用,输入不丢焦点),\n * 再以浅拷贝数组经 update:modelValue 通知宿主,维持 v-model 契约。\n * 只读下注入 readonly: true 且不提供写回通道,值不可修改;\n * 列配置了 rules 时,控件的 change 事件(如失焦提交)触发单元格级校验,输入过程不校验。\n */\nfunction createCellModel(column: TableEditorColumn, ctx: TableColumnRenderContext) {\n const { rowData, val } = ctx\n const key = column.key\n const rules = column.rules\n return {\n modelValue: val,\n ...(props.readonly\n ? { readonly: true }\n : {\n 'onUpdate:modelValue': (value: any) => {\n rowData[key] = value\n emit('update:modelValue', [...modelValue.value])\n }\n }),\n ...(rules ? { onChange: () => void validateCell(rowData, key, rules) } : {})\n }\n}\n\nfunction handleCreate(index?: number) {\n const at = index !== undefined ? index + 1 : modelValue.value.length\n emit('update:modelValue', [...modelValue.value.slice(0, at), {}, ...modelValue.value.slice(at)])\n}\n\nfunction handleCopy(row: TableRowNode) {\n emit('update:modelValue', [\n ...modelValue.value.slice(0, row.index + 1),\n JSON.parse(JSON.stringify(row.data)),\n ...modelValue.value.slice(row.index + 1)\n ])\n}\n\nfunction handleDelete(index: number) {\n emit(\n 'update:modelValue',\n modelValue.value.filter((_, i) => i !== index)\n )\n}\n\ndefineExpose<_TableEditorExposed>({ validate })\n</script>\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -58,8 +58,9 @@ const classList = computed(() => {
|
|
|
58
58
|
})
|
|
59
59
|
|
|
60
60
|
const ripple = computed(() => {
|
|
61
|
-
if (props.disabled || props.loading
|
|
62
|
-
|
|
61
|
+
if (props.disabled || props.loading) return false
|
|
62
|
+
// plain / text 变体无底色,波纹用类型色才能看清
|
|
63
|
+
if ((props.plain || props.text) && props.type) {
|
|
63
64
|
return bem.is(`ripple-${props.type}`)
|
|
64
65
|
}
|
|
65
66
|
return true
|
|
@@ -52,7 +52,17 @@ $root-name: form-item;
|
|
|
52
52
|
#{fn.bem(multi-tree-select)},
|
|
53
53
|
#{fn.bem(auto-complete)},
|
|
54
54
|
#{fn.bem(textarea--more)} {
|
|
55
|
-
|
|
55
|
+
// hover 的 primary 描边与错误态同权重,按源序分胜负:错误态显式压住,避免悬浮时描边回蓝
|
|
56
|
+
&,
|
|
57
|
+
&:hover,
|
|
58
|
+
&.is-focus {
|
|
59
|
+
border-color: fn.use-var(color, danger);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 聚焦光晕与描边同色,错误态转红
|
|
63
|
+
&.is-focus {
|
|
64
|
+
box-shadow: fn.use-var(focus-ring, danger);
|
|
65
|
+
}
|
|
56
66
|
}
|
|
57
67
|
}
|
|
58
68
|
|
|
@@ -266,8 +266,10 @@ describe('UTableEditor 按列校验', () => {
|
|
|
266
266
|
const [row0] = getDataRows()
|
|
267
267
|
const headerText = () =>
|
|
268
268
|
findHeader(host, '姓名')?.querySelector('.u-table-editor__header-text')
|
|
269
|
+
const nameCellError = () =>
|
|
270
|
+
row0.querySelector('[data-test="name-input"]')?.parentElement?.className
|
|
269
271
|
|
|
270
|
-
// 输入过程(input
|
|
272
|
+
// 输入过程(input 事件)不触发校验:清空后表头无错误指示,单元格无错误标记
|
|
271
273
|
const input = row0.querySelector('input')!
|
|
272
274
|
input.value = ''
|
|
273
275
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
@@ -275,14 +277,15 @@ describe('UTableEditor 按列校验', () => {
|
|
|
275
277
|
await nextTick()
|
|
276
278
|
expect(headerText()?.className).not.toContain('is-error')
|
|
277
279
|
expect(findHeader(host, '姓名')?.querySelector('.u-table-editor__header-icon')).toBeFalsy()
|
|
280
|
+
expect(nameCellError()).not.toContain('is-error')
|
|
278
281
|
|
|
279
|
-
// change
|
|
282
|
+
// change 事件(如失焦提交)触发校验:该列表头出现错误指示,单元格挂 is-error
|
|
280
283
|
input.dispatchEvent(new Event('change', { bubbles: true }))
|
|
281
284
|
await nextTick()
|
|
282
285
|
await nextTick()
|
|
283
286
|
expect(headerText()?.className).toContain('is-error')
|
|
284
287
|
expect(findHeader(host, '姓名')?.querySelector('.u-table-editor__header-icon')).toBeTruthy()
|
|
285
|
-
expect(
|
|
288
|
+
expect(nameCellError()).toContain('is-error')
|
|
286
289
|
|
|
287
290
|
// 填回合法值并 change,错误消失
|
|
288
291
|
input.value = 'Alice'
|
|
@@ -291,12 +294,13 @@ describe('UTableEditor 按列校验', () => {
|
|
|
291
294
|
await nextTick()
|
|
292
295
|
await nextTick()
|
|
293
296
|
expect(headerText()?.className).not.toContain('is-error')
|
|
297
|
+
expect(nameCellError()).not.toContain('is-error')
|
|
294
298
|
} finally {
|
|
295
299
|
unmount()
|
|
296
300
|
}
|
|
297
301
|
})
|
|
298
302
|
|
|
299
|
-
it('validate
|
|
303
|
+
it('validate 全量校验:某行存在错误仍继续校验其后的行', async () => {
|
|
300
304
|
const { host, getEditor, unmount } = mountTableEditor(
|
|
301
305
|
{},
|
|
302
306
|
[
|
|
@@ -312,20 +316,21 @@ describe('UTableEditor 按列校验', () => {
|
|
|
312
316
|
await expect(getEditor().validate()).resolves.toBe(false)
|
|
313
317
|
await nextTick()
|
|
314
318
|
|
|
315
|
-
// 第 2 行 name
|
|
319
|
+
// 第 2、3 行 name 均校验失败:该列表头标红并出现感叹号图标,失败单元格全部挂 is-error
|
|
316
320
|
const nameHeader = findHeader(host, '姓名')!
|
|
317
321
|
expect(nameHeader.querySelector('.u-table-editor__header-text')?.className).toContain(
|
|
318
322
|
'is-error'
|
|
319
323
|
)
|
|
320
324
|
const icon = nameHeader.querySelector('.u-table-editor__header-icon')!
|
|
321
325
|
expect(icon).toBeTruthy()
|
|
326
|
+
expect(host.querySelectorAll('td .is-error')).toHaveLength(2)
|
|
322
327
|
|
|
323
|
-
//
|
|
328
|
+
// 悬停图标查看气泡:包含第 2、3 行明细
|
|
324
329
|
icon.dispatchEvent(new MouseEvent('mouseenter'))
|
|
325
330
|
await nextTick()
|
|
326
331
|
const bubble = document.querySelector('.u-tip__content')
|
|
327
332
|
expect(bubble?.textContent).toContain('第 2 行')
|
|
328
|
-
expect(bubble?.textContent).
|
|
333
|
+
expect(bubble?.textContent).toContain('第 3 行')
|
|
329
334
|
// 等弹层异步定位计算完成,避免与卸载竞争产生未处理拒绝
|
|
330
335
|
await new Promise((resolve) => setTimeout(resolve))
|
|
331
336
|
} finally {
|
|
@@ -423,14 +428,17 @@ describe('UTableEditor 按列校验', () => {
|
|
|
423
428
|
await nextTick()
|
|
424
429
|
await expect(getEditor().validate()).resolves.toBe(false)
|
|
425
430
|
await nextTick()
|
|
426
|
-
// validate
|
|
431
|
+
// validate 后失败列的表头直接出现错误指示,失败单元格挂 is-error
|
|
427
432
|
const headerText = () =>
|
|
428
433
|
findHeader(host, '姓名')?.querySelector('.u-table-editor__header-text')
|
|
429
434
|
expect(headerText()?.className).toContain('is-error')
|
|
435
|
+
expect(host.querySelector('td .is-error')).toBeTruthy()
|
|
430
436
|
|
|
431
437
|
setModel([{ name: 'Alice', age: 18, city: '杭州' }])
|
|
432
438
|
await nextTick()
|
|
433
439
|
await expect(getEditor().validate()).resolves.toBe(true)
|
|
440
|
+
await nextTick()
|
|
441
|
+
expect(host.querySelector('td .is-error')).toBeFalsy()
|
|
434
442
|
} finally {
|
|
435
443
|
unmount()
|
|
436
444
|
}
|
|
@@ -8,23 +8,37 @@ $root-name: table-editor;
|
|
|
8
8
|
@include m.b($root-name) {
|
|
9
9
|
@include m.bem(table, body) {
|
|
10
10
|
@include m.bem(table, cell) {
|
|
11
|
-
padding:
|
|
12
|
-
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
// 这里锚定 default 尺寸,与单元格内默认尺寸的输入控件对齐
|
|
17
|
-
height: calc(#{fn.use-var(form-component-height, default)} + 4px + 1px);
|
|
18
|
-
|
|
19
|
-
// 单元格内行内级控件(inline-flex 输入框)参与基线对齐
|
|
20
|
-
// 会在行盒下方留出 descender 空隙,顶对齐抵消,保证行高恰好为锚定值
|
|
21
|
-
& > [class^='u-'],
|
|
22
|
-
[class^='u-input'] {
|
|
11
|
+
padding: 4px;
|
|
12
|
+
|
|
13
|
+
// 单元格内行内级控件(inline-flex 输入框)参与基线对齐,
|
|
14
|
+
// 会在行盒下方留出 descender 空隙,顶对齐抵消
|
|
15
|
+
[class^='u-'] {
|
|
23
16
|
vertical-align: top;
|
|
24
17
|
}
|
|
25
18
|
}
|
|
26
19
|
}
|
|
27
20
|
|
|
21
|
+
// 校验未通过的单元格:与表单项错误态一致,内嵌控件描红边(含 hover)、聚焦光晕转红,纯文本标红
|
|
22
|
+
.is-error {
|
|
23
|
+
color: fn.use-var(color, danger);
|
|
24
|
+
|
|
25
|
+
#{fn.bem(input)},
|
|
26
|
+
#{fn.bem(multi-select)},
|
|
27
|
+
#{fn.bem(multi-tree-select)},
|
|
28
|
+
#{fn.bem(auto-complete)},
|
|
29
|
+
#{fn.bem(textarea--more)} {
|
|
30
|
+
&,
|
|
31
|
+
&:hover,
|
|
32
|
+
&.is-focus {
|
|
33
|
+
border-color: fn.use-var(color, danger);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
&.is-focus {
|
|
37
|
+
box-shadow: fn.use-var(focus-ring, danger);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
// 操作列按钮容器:fixed right 列由 u-table 独立分层渲染,此处只做按钮排布
|
|
29
43
|
@include m.bem($root-name, operations) {
|
|
30
44
|
display: flex;
|
|
@@ -182,6 +182,8 @@ function setCellError(rowData: Record<string, any>, key: string, message: string
|
|
|
182
182
|
|
|
183
183
|
if (!message) {
|
|
184
184
|
if (!rowErrors?.delete(key)) return
|
|
185
|
+
// 行错误清空后移除条目,保证「size > 0 即存在错误」可作整体判定
|
|
186
|
+
if (!rowErrors.size) cellErrors.value.delete(row)
|
|
185
187
|
} else {
|
|
186
188
|
if (rowErrors?.get(key) === message) return
|
|
187
189
|
if (rowErrors) rowErrors.set(key, message)
|
|
@@ -221,18 +223,19 @@ async function validateCell(
|
|
|
221
223
|
}
|
|
222
224
|
|
|
223
225
|
/**
|
|
224
|
-
*
|
|
226
|
+
* 整表校验:并行校验所有行的规则列(不懒校验),未通过的单元格全部标出。
|
|
225
227
|
* 全部通过 resolve true,否则 resolve false。
|
|
226
228
|
*/
|
|
227
229
|
async function validate() {
|
|
228
230
|
const ruleColumns = columns.value.filter((column) => column.rules)
|
|
229
231
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
232
|
+
await Promise.all(
|
|
233
|
+
modelValue.value.map((row) =>
|
|
234
|
+
Promise.all(ruleColumns.map((column) => validateCell(row, column.key, column.rules)))
|
|
235
|
+
)
|
|
236
|
+
)
|
|
234
237
|
|
|
235
|
-
return
|
|
238
|
+
return cellErrors.value.size === 0
|
|
236
239
|
}
|
|
237
240
|
|
|
238
241
|
// 行删除(或整体替换)后,同步清理已不存在的行数据上的错误
|
|
@@ -270,14 +273,32 @@ const columnErrors = computed(() => {
|
|
|
270
273
|
|
|
271
274
|
// --- 单元格渲染 ---
|
|
272
275
|
|
|
273
|
-
/**
|
|
276
|
+
/**
|
|
277
|
+
* 声明了 `#column:key` 插槽的列调用编辑插槽(注入 model),未声明的渲染字段原始值。
|
|
278
|
+
* 配置了 rules 的列在渲染期间读取 cellErrors 建立依赖,错误出现/消失即时更新:
|
|
279
|
+
* 插槽内容常驻包一层容器、仅切换 is-error 类(与表单项错误态同类,不增删 DOM,
|
|
280
|
+
* 控件不因错误态变化重挂丢焦点);纯文本列仅在出错时包一层标红原始值。
|
|
281
|
+
*/
|
|
274
282
|
function editCell(column: TableEditorColumn, ctx: TableColumnRenderContext): RenderReturn {
|
|
275
283
|
const editSlot = slots[`column:${column.key}`]
|
|
276
284
|
// 内部列 key 带只读态后缀,u-table 按 key 取到的 val 失效,这里按原始列 key 重取
|
|
277
285
|
const val = o(ctx.rowData).get(column.key)
|
|
278
|
-
|
|
286
|
+
|
|
287
|
+
if (!column.rules) {
|
|
288
|
+
if (!editSlot) return val
|
|
289
|
+
const scope = { ...ctx, val }
|
|
290
|
+
return editSlot({ ...scope, model: createCellModel(column, scope) })
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const hasError = !!cellErrors.value.get(errorRowKey(ctx.rowData))?.has(column.key)
|
|
294
|
+
if (!editSlot) return hasError ? h('span', { class: bem.is('error') }, val) : val
|
|
295
|
+
|
|
279
296
|
const scope = { ...ctx, val }
|
|
280
|
-
return
|
|
297
|
+
return h(
|
|
298
|
+
'span',
|
|
299
|
+
{ class: bem.is('error', hasError) },
|
|
300
|
+
editSlot({ ...scope, model: createCellModel(column, scope) })
|
|
301
|
+
)
|
|
281
302
|
}
|
|
282
303
|
|
|
283
304
|
/**
|