@neatui/vue 5.0.0 → 5.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/MIGRATION-5.0.md +13 -6
- package/package.json +2 -6
- package/src/components/form/Select.vue +18 -24
- package/src/components/form/index.ts +0 -2
- package/src/components/loader/FormLoader/FormLoader.vue +238 -50
- package/src/components/loader/FormLoader/FormRender.vue +124 -30
- package/src/components/loader/index.ts +1 -1
- package/src/components/form/RichTextInput.vue +0 -99
package/MIGRATION-5.0.md
CHANGED
|
@@ -16,11 +16,9 @@ Vue 最低版本声明统一为 3.5.11,与已有 Pinia 3 的要求一致;同
|
|
|
16
16
|
|
|
17
17
|
| 旧配置 | 5.0 写法 |
|
|
18
18
|
| --- | --- |
|
|
19
|
-
| 字段的 `slot: 'custom'` | `model: 'Custom'` 配合 `#Custom`,或使用同名字段插槽 |
|
|
20
|
-
| 字段级 `colon` | 用 `label` 节点树精确输出文字;表单级 `colon` 继续支持 |
|
|
21
19
|
| 只读取 `col` 的自定义插槽 | `col` 继续提供;通过 `bind / update` 接入双向绑定、转换和只读状态 |
|
|
22
20
|
|
|
23
|
-
|
|
21
|
+
字段级 `slot` 和 `colon` 继续支持。新写法可以用 `model` 配合同名插槽,并通过 `bind` 接入双向绑定:
|
|
24
22
|
|
|
25
23
|
```vue
|
|
26
24
|
<FormLoader :form="form" :data="data">
|
|
@@ -32,6 +30,12 @@ Vue 最低版本声明统一为 3.5.11,与已有 Pinia 3 的要求一致;同
|
|
|
32
30
|
|
|
33
31
|
旧联动签名 `(data, item, route, router)` 继续可用。第三参数的 `path` 始终是网址路径,`formPath` 是表单数据路径;它还提供 `root / data / index / array / route / router`。事件、值转换和插槽里的 `ctx.path` 仍是表单数据路径。
|
|
34
32
|
|
|
33
|
+
`clear` 行为有变化:4.8.2 隐藏即丢弃原值,现在默认暂存、重新显示时恢复,避免控制字段与被控字段同时赋值(如加载记录)时联动尚未完成就清掉数据。需要旧行为时返回 `{ value, restore: false }`。
|
|
34
|
+
|
|
35
|
+
字段 id:不传 `idx` 时,4.8.2 生成 `form-field-<字段>`,同页多个表单会冲突;现在自动生成唯一前缀。依赖固定 id 的样式或测试请显式传 `idx`。
|
|
36
|
+
|
|
37
|
+
联动函数按实际读取的字段追踪,只有读到的字段变化才重新执行(4.8.2 为同层任意字段变化都执行);函数拿到的 data 可以改写,不影响表单数据。
|
|
38
|
+
|
|
35
39
|
`model[1].rules` 继续覆盖节点 `rules`,空数组表示关闭该节点校验。`verify: true` 显示统一校验图标;不会再同时触发控件和 FormLoader 两套校验。
|
|
36
40
|
|
|
37
41
|
## 从桌面旧组件或中间扩展配置迁移
|
|
@@ -48,7 +52,7 @@ Vue 最低版本声明统一为 3.5.11,与已有 Pinia 3 的要求一致;同
|
|
|
48
52
|
| `transverse` | `label` 属性中的 `style.width: 'auto'` |
|
|
49
53
|
| `before / prefix / suffix / extra / childAttrs` | 使用 `child` 内容节点与 `attrs` 容器 |
|
|
50
54
|
| 节点 `rows` | `attrs: { 'ui-row': '…' }` |
|
|
51
|
-
| `restore` | `clear: () => ({ value: 是否清空, restore:
|
|
55
|
+
| `restore` | 默认即暂存并在重新显示时恢复;需要丢弃时写 `clear: () => ({ value: 是否清空, restore: false })` |
|
|
52
56
|
| `array.media` | `array.attrs: { 'ui-col': '…' }` |
|
|
53
57
|
| `every-label` 插槽 | `label` 插槽,接收 `col / node / data / text` |
|
|
54
58
|
|
|
@@ -57,10 +61,13 @@ Vue 最低版本声明统一为 3.5.11,与已有 Pinia 3 的要求一致;同
|
|
|
57
61
|
## 新增与行为修复
|
|
58
62
|
|
|
59
63
|
- 标题与内容均可递归组合;数组支持默认值、数量限制、增删排序和行上下文。
|
|
64
|
+
- 新增 `setErrors`(回填服务端错误)、`isDirty / getChanges`(未保存改动)、`setBaseline`(设置重置基线)、`getData({ visibleOnly })`(去掉隐藏字段),`validate({ paths })` 支持局部校验。在同一对象上用 `Object.assign` 加载记录后调用 `setBaseline()`。
|
|
60
65
|
- `validate()` 等待异步联动并检查最新数据;`reset()` 恢复当前数据对象的初始状态;`clearValidate()` 取消未完成校验。
|
|
61
66
|
- 换一条记录时替换 `data` 对象,建立新的重置基线;对同一对象 `Object.assign` 视为编辑。
|
|
62
67
|
- `paths` 缺失时补全对象,已有非对象路径明确报错。
|
|
63
|
-
- 增加 Radio、Gallery(别名 SelectImg
|
|
64
|
-
-
|
|
68
|
+
- 增加 Radio、Gallery(别名 SelectImg),FormLoader 已内置。NeatUI 不提供富文本控件,业务编辑器用同名字段插槽接入 `<MyEditor v-bind="bind" />`。TimePicker 使用浏览器原生时间输入并校验范围与步长。
|
|
69
|
+
- Select 不再清空不在当前选项中的值:只有上级切换使原本有效的值失效(如省市级联)时才清空;加载记录、异步或分页选项尚未包含当前值时保留,并显示原始值。4.x 会直接清空为 null。
|
|
70
|
+
- 类型:`form` 标注为 `FormItem[]`;`FormItem.value` 的 `get / set` 参数可正确推断;新增 `FormSlotScope` 描述字段与控件插槽参数。
|
|
71
|
+
- Gallery 提供通用图片选择与搜索事件,不包含旧业务素材库的接口、分页和后台管理。
|
|
65
72
|
|
|
66
73
|
示例与完整 API 见仓库的 `web/app/pages/components/formloader.vue`。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neatui/vue",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"description": "basic component ui",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,13 +36,9 @@
|
|
|
36
36
|
"@fekit/scrollto": "^3.0.3",
|
|
37
37
|
"@fekit/toast": "^2.7.2",
|
|
38
38
|
"@fekit/utils": "^6.0.0",
|
|
39
|
-
"@neatui/css": "^5.
|
|
39
|
+
"@neatui/css": "^5.1.0",
|
|
40
40
|
"@rollup/plugin-commonjs": "^26.0.1",
|
|
41
41
|
"@rollup/plugin-node-resolve": "^15.2.3",
|
|
42
|
-
"@tiptap/core": "^3.31.3",
|
|
43
|
-
"@tiptap/pm": "^3.31.3",
|
|
44
|
-
"@tiptap/starter-kit": "^3.31.3",
|
|
45
|
-
"@tiptap/vue-3": "^3.31.3",
|
|
46
42
|
"@vue/compiler-sfc": "^3.4.30",
|
|
47
43
|
"compressorjs": "^1.2.1",
|
|
48
44
|
"echarts": "^5.5.0",
|
|
@@ -235,6 +235,11 @@
|
|
|
235
235
|
};
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
// 值不在当前选项中(选项未加载、分页或远程搜索)时显示原始值,避免看起来像未选择
|
|
239
|
+
if (props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== '') {
|
|
240
|
+
return { label: String(props.modelValue), value: props.modelValue };
|
|
241
|
+
}
|
|
242
|
+
|
|
238
243
|
return {};
|
|
239
244
|
}),
|
|
240
245
|
displayLabel: computed((): string => {
|
|
@@ -285,35 +290,24 @@
|
|
|
285
290
|
{ immediate: true },
|
|
286
291
|
);
|
|
287
292
|
|
|
288
|
-
//
|
|
293
|
+
// 值是否在选项中(含 all 选项)
|
|
294
|
+
const inOptions = (value: any, options: any[] | undefined, allOption: any) =>
|
|
295
|
+
(allOption && value === allOption.value) || !!options?.some((option: any) => `${option.value}` === `${value}`);
|
|
296
|
+
|
|
297
|
+
// 只在选项变化使原本有效的值失效时清空(如级联切换上级后)。
|
|
298
|
+
// 值由外部写入(加载记录)、选项异步未到、分页或远程搜索尚未包含当前值时保留,避免丢失数据。
|
|
289
299
|
watch(
|
|
290
300
|
[() => props.modelValue, () => props.options, () => props.all, () => props.other],
|
|
291
|
-
([value, options, allOption, otherConfig]) => {
|
|
301
|
+
([value, options, allOption, otherConfig], [previousValue, previousOptions]) => {
|
|
292
302
|
// 如果配置了 other,则允许任意值(可能是用户自定义输入)
|
|
293
|
-
if (otherConfig)
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
let isValid = false;
|
|
300
|
-
|
|
301
|
-
// 检查是否是 all 选项
|
|
302
|
-
if (allOption && value === allOption.value) {
|
|
303
|
-
isValid = true;
|
|
304
|
-
} else {
|
|
305
|
-
// 检查是否在普通选项中
|
|
306
|
-
isValid = options.some((option: any) => `${option.value}` === `${value}`);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
if (!isValid) {
|
|
310
|
-
nextTick(() => {
|
|
311
|
-
change(null);
|
|
312
|
-
});
|
|
313
|
-
}
|
|
303
|
+
if (otherConfig || value == null || value === '' || !options?.length) return;
|
|
304
|
+
const invalidated = value === previousValue && options !== previousOptions && inOptions(value, previousOptions, allOption) && !inOptions(value, options, allOption);
|
|
305
|
+
if (invalidated) {
|
|
306
|
+
nextTick(() => {
|
|
307
|
+
change(null);
|
|
308
|
+
});
|
|
314
309
|
}
|
|
315
310
|
},
|
|
316
|
-
{ immediate: true },
|
|
317
311
|
);
|
|
318
312
|
|
|
319
313
|
// 选择
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import Radio from './Radio.vue';
|
|
2
2
|
import Gallery from './Gallery.vue';
|
|
3
|
-
import RichTextInput from './RichTextInput.vue';
|
|
4
3
|
import Input from './Input.vue';
|
|
5
4
|
import InputRange from './InputRange.vue';
|
|
6
5
|
import Switch from './Switch.vue';
|
|
@@ -36,7 +35,6 @@ export {
|
|
|
36
35
|
Radio,
|
|
37
36
|
Gallery,
|
|
38
37
|
Gallery as SelectImg,
|
|
39
|
-
RichTextInput,
|
|
40
38
|
Input,
|
|
41
39
|
InputRange,
|
|
42
40
|
Switch,
|
|
@@ -144,7 +144,9 @@
|
|
|
144
144
|
<FormRender v-else :col="col" :mode="mode" :entry="resolveEntry(col)" :interacted="touched" :ctx="context()" :custom="(!editable && hasSlot(`${col.field}-value`)) || !resolveEntry(col)">
|
|
145
145
|
<slot v-if="!editable && hasSlot(`${col.field}-value`)" :name="`${col.field}-value`" v-bind="fieldScope(col)"></slot>
|
|
146
146
|
<template v-else>
|
|
147
|
-
|
|
147
|
+
<!-- 4.x 兼容:字段级 slot 优先于 model / field 同名插槽 -->
|
|
148
|
+
<slot v-if="col.slot" :name="col.slot" v-bind="fieldScope(col)"></slot>
|
|
149
|
+
<slot v-else-if="col.model[0]" :name="col.model[0]" v-bind="fieldScope(col)"></slot>
|
|
148
150
|
<slot v-else :name="col.field" v-bind="fieldScope(col)"></slot>
|
|
149
151
|
</template>
|
|
150
152
|
</FormRender>
|
|
@@ -162,12 +164,12 @@
|
|
|
162
164
|
</div>
|
|
163
165
|
</template>
|
|
164
166
|
<script lang="ts" setup>
|
|
165
|
-
import { watch, reactive, shallowRef, onMounted, onBeforeUnmount, useAttrs, useSlots, computed, provide, inject, toRaw, nextTick,
|
|
167
|
+
import { watch, reactive, shallowRef, onMounted, onBeforeUnmount, useAttrs, useSlots, computed, provide, inject, toRaw, nextTick, watchEffect, useId, isReactive, isReadonly } from 'vue';
|
|
166
168
|
import { useRoute, useRouter } from 'vue-router';
|
|
167
169
|
import { isString, isObject, deepcopy, isArray, isFunction } from '@fekit/utils';
|
|
168
170
|
import FormRender, { getField, emptyValueOf, clh, isEmpty, labelText, labelAttrs, rulesOf, displayOf, valueBinding, labelNodes, eventsOf, writeValue, FieldMessage } from './FormRender.vue';
|
|
169
|
-
import type {
|
|
170
|
-
|
|
171
|
+
import type { PropType, ShallowRef } from 'vue';
|
|
172
|
+
import type { FormItem, FormArrayContext, FormContext, FormError, FormValidateResult, LinkageTask, FormRule } from './FormRender.vue';
|
|
171
173
|
// 计算Label宽度
|
|
172
174
|
const clw = (label: any = '') => {
|
|
173
175
|
let emWidth = 0;
|
|
@@ -185,12 +187,12 @@
|
|
|
185
187
|
return emWidth;
|
|
186
188
|
};
|
|
187
189
|
|
|
188
|
-
//
|
|
189
|
-
const
|
|
190
|
+
// 路径缺失时补全对象,不把嵌套字段静默写到根层;onCreate 用于记录新建对象的初始值。
|
|
191
|
+
const ensurePath = (data: any, paths: string = '', onCreate?: (target: any, key: string) => void) => {
|
|
190
192
|
let target = data;
|
|
191
193
|
(paths?.split('.') || []).forEach((key: string) => {
|
|
192
194
|
if (!key) return;
|
|
193
|
-
if (target[key] == null) { target[key] = {};
|
|
195
|
+
if (target[key] == null) { target[key] = {}; onCreate?.(target, key); }
|
|
194
196
|
if (!isObject(target[key])) throw new TypeError(`[FormLoader] paths 的 ${key} 必须是对象`);
|
|
195
197
|
target = target[key];
|
|
196
198
|
});
|
|
@@ -266,7 +268,7 @@
|
|
|
266
268
|
const row: any = {};
|
|
267
269
|
list.forEach((item: any = {}) => {
|
|
268
270
|
if (!isObject(item)) return;
|
|
269
|
-
const target =
|
|
271
|
+
const target = ensurePath(row, item.paths);
|
|
270
272
|
if (isArray(item.child) && !item.array) {
|
|
271
273
|
if (item.field) {
|
|
272
274
|
target[item.field] = createRow(item.child, getEmpty);
|
|
@@ -314,18 +316,83 @@
|
|
|
314
316
|
});
|
|
315
317
|
};
|
|
316
318
|
|
|
319
|
+
// 可写的数据视图:未写入的字段直接读原数据(响应式读取,便于精确追踪依赖),写入与删除只进入覆盖层。
|
|
320
|
+
const DELETED = Symbol('deleted');
|
|
321
|
+
const plainObject = (value: any) => !!value && typeof value === 'object' && !(value instanceof Date);
|
|
322
|
+
const dataView = (source: any, onRead?: () => void): any => {
|
|
323
|
+
if (!plainObject(source)) return source;
|
|
324
|
+
const src = isReactive(source) || isReadonly(source) ? source : reactive(source);
|
|
325
|
+
const array = isArray(toRaw(src));
|
|
326
|
+
const overlay = new Map<PropertyKey, any>();
|
|
327
|
+
const children = new Map<PropertyKey, { raw: any; view: any }>();
|
|
328
|
+
const read = (key: PropertyKey) => {
|
|
329
|
+
// Vue 内部标记(__v_raw、__v_isReactive 等)按普通对象处理,视图本身不是响应式对象。
|
|
330
|
+
if (typeof key === 'string' && key.startsWith('__v_')) return undefined;
|
|
331
|
+
if (overlay.has(key)) {
|
|
332
|
+
const value = overlay.get(key);
|
|
333
|
+
return value === DELETED ? undefined : value;
|
|
334
|
+
}
|
|
335
|
+
// 方法取原生函数(不走 Vue 的数组方法包装),调用时通过视图逐项读取,依赖仍然精确。
|
|
336
|
+
const native = Reflect.get(toRaw(src), key);
|
|
337
|
+
if (typeof native === 'function') return native;
|
|
338
|
+
onRead?.();
|
|
339
|
+
const value = Reflect.get(src, key);
|
|
340
|
+
if (!plainObject(value) || typeof key === 'symbol') return value;
|
|
341
|
+
const cached = children.get(key);
|
|
342
|
+
if (cached && cached.raw === toRaw(value)) return cached.view;
|
|
343
|
+
const view = dataView(value, onRead);
|
|
344
|
+
children.set(key, { raw: toRaw(value), view });
|
|
345
|
+
return view;
|
|
346
|
+
};
|
|
347
|
+
const exists = (key: PropertyKey) => (typeof key === 'string' && key.startsWith('__v_') ? false : overlay.has(key) ? overlay.get(key) !== DELETED : (onRead?.(), Reflect.has(src, key)));
|
|
348
|
+
return new Proxy(array ? [] : {}, {
|
|
349
|
+
get: (_, key) => read(key),
|
|
350
|
+
set: (_, key, value) => { overlay.set(key, value); children.delete(key); return true; },
|
|
351
|
+
deleteProperty: (_, key) => { overlay.set(key, DELETED); children.delete(key); return true; },
|
|
352
|
+
defineProperty: (_, key, descriptor) => { overlay.set(key, descriptor.value); children.delete(key); return true; },
|
|
353
|
+
has: (_, key) => exists(key),
|
|
354
|
+
ownKeys: () => {
|
|
355
|
+
onRead?.();
|
|
356
|
+
const keys = new Set<string | symbol>(Reflect.ownKeys(src));
|
|
357
|
+
overlay.forEach((value, key) => (value === DELETED ? keys.delete(key as string | symbol) : keys.add(key as string | symbol)));
|
|
358
|
+
if (array) {
|
|
359
|
+
const length = read('length');
|
|
360
|
+
keys.forEach((key) => { if (typeof key === 'string' && /^\d+$/.test(key) && Number(key) >= length) keys.delete(key); });
|
|
361
|
+
}
|
|
362
|
+
return [...keys];
|
|
363
|
+
},
|
|
364
|
+
getOwnPropertyDescriptor: (_, key) => {
|
|
365
|
+
if (!exists(key)) return undefined;
|
|
366
|
+
if (array && key === 'length') return { value: read(key), writable: true, enumerable: false, configurable: false };
|
|
367
|
+
return { value: read(key), writable: true, enumerable: true, configurable: true };
|
|
368
|
+
},
|
|
369
|
+
getPrototypeOf: () => Reflect.getPrototypeOf(toRaw(src)),
|
|
370
|
+
});
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
// 动态属性联动:函数或 { value, setup, watch };按实际读取的字段追踪依赖,丢弃过期的异步结果
|
|
374
|
+
// 联动引擎需要的共享状态:数据版本(watch: true 与 await 后读取的回退)、结构版本、未完成的异步任务
|
|
375
|
+
interface LinkageShared {
|
|
376
|
+
dataVersion: ShallowRef<number>;
|
|
377
|
+
revision: number;
|
|
378
|
+
pending: Set<Promise<any>>;
|
|
379
|
+
}
|
|
380
|
+
|
|
317
381
|
interface LinkageOptions {
|
|
318
382
|
// 按下标取当前渲染的字段
|
|
319
383
|
target: (idx: number) => any;
|
|
320
|
-
//
|
|
384
|
+
// 联动上下文:根数据、当前层数据、行下标、路由等;属性按需读取,未用到的不产生依赖
|
|
321
385
|
context: () => FormContext;
|
|
386
|
+
shared: LinkageShared;
|
|
387
|
+
// 把联动结果写回字段(规范化 model / array、初始化子结构由表单负责)
|
|
388
|
+
apply: (target: any, key: string, result: any) => void;
|
|
322
389
|
}
|
|
323
390
|
|
|
324
391
|
// 带 setup 或 watch 的对象才是联动配置,attrs 等普通对象原样保留
|
|
325
392
|
const isLinkage = (val: any) => isObject(val) && (isFunction(val.setup) || isArray(val.watch) || val.watch === true);
|
|
326
393
|
|
|
327
394
|
// 动态属性:函数或 { value, setup, watch },监听字段变化时重新计算,支持 Promise
|
|
328
|
-
const useLinkage = ({ target: getTarget, context }: LinkageOptions) => {
|
|
395
|
+
const useLinkage = ({ target: getTarget, context, shared, apply: applyResult }: LinkageOptions) => {
|
|
329
396
|
// 结构变化时重建字段监听;普通字段变化只触发对应的联动任务
|
|
330
397
|
const unwatchs: Array<() => void> = [];
|
|
331
398
|
const pendingRuns = new Map<LinkageTask, () => void>();
|
|
@@ -339,14 +406,11 @@
|
|
|
339
406
|
});
|
|
340
407
|
};
|
|
341
408
|
|
|
342
|
-
//
|
|
343
|
-
|
|
409
|
+
// 联动函数拿到的是可写的数据视图:读取直接访问表单数据,Vue 只按实际读到的字段收集依赖;
|
|
410
|
+
// 写入只落在视图自己的覆盖层,不改动表单数据(与 4.x 传副本的语义一致)。
|
|
411
|
+
const call = (setup: any, item: any, onRead?: () => void) => {
|
|
344
412
|
const ctx = context();
|
|
345
|
-
const track = (data: any) =>
|
|
346
|
-
get(target, key, receiver) { used?.(); return Reflect.get(target, key, receiver); },
|
|
347
|
-
ownKeys(target) { used?.(); return Reflect.ownKeys(target); },
|
|
348
|
-
has(target, key) { used?.(); return Reflect.has(target, key); },
|
|
349
|
-
});
|
|
413
|
+
const track = (data: any) => dataView(data, onRead);
|
|
350
414
|
// 统一回调协议,不通过参数个数猜测用途。path 保持已发布版路由含义。
|
|
351
415
|
const scope = new Proxy(ctx, {
|
|
352
416
|
get: (target, key) => key === 'path' ? ctx.route?.path : key === 'formPath' ? ctx.path
|
|
@@ -370,15 +434,25 @@
|
|
|
370
434
|
// 处理函数
|
|
371
435
|
if (isFunction(val)) {
|
|
372
436
|
const fn = val;
|
|
373
|
-
|
|
437
|
+
// 同步读取由 computed 精确追踪;异步函数在 await 之后才读取的数据无法追踪,
|
|
438
|
+
// 一旦出现就退回到跟随根数据版本重算。
|
|
439
|
+
let lateReads = false;
|
|
374
440
|
const version = shallowRef(0);
|
|
375
|
-
unwatchs.push(watch(
|
|
376
|
-
if (
|
|
377
|
-
}
|
|
441
|
+
unwatchs.push(watch(shared.dataVersion, () => {
|
|
442
|
+
if (lateReads) version.value++;
|
|
443
|
+
}));
|
|
378
444
|
// 初次解析与监听共用计算结果,避免异步函数被重复调用。
|
|
379
445
|
const evaluated = computed(() => {
|
|
380
446
|
version.value;
|
|
381
|
-
|
|
447
|
+
let evaluating = true;
|
|
448
|
+
let settled = false;
|
|
449
|
+
const result = call(fn, reactive(item), () => {
|
|
450
|
+
if (!evaluating && !settled) lateReads = true;
|
|
451
|
+
});
|
|
452
|
+
evaluating = false;
|
|
453
|
+
if (result instanceof Promise) result.then(() => (settled = true), () => (settled = true));
|
|
454
|
+
else settled = true;
|
|
455
|
+
return result;
|
|
382
456
|
});
|
|
383
457
|
val = evaluated.value;
|
|
384
458
|
if (isLinkage(val)) {
|
|
@@ -396,12 +470,12 @@
|
|
|
396
470
|
} else {
|
|
397
471
|
hasInitialResult = true;
|
|
398
472
|
initialResult = val;
|
|
399
|
-
val = { value: val instanceof Promise ? undefined : val, setup: () => evaluated.value, watch: true };
|
|
473
|
+
val = { value: val instanceof Promise ? undefined : val, setup: () => evaluated.value, watch: true, source: () => evaluated.value };
|
|
400
474
|
}
|
|
401
475
|
}
|
|
402
476
|
if (isLinkage(val)) {
|
|
403
|
-
const { value = null, setup = () => {}, watch: _watch = [] } = val || {};
|
|
404
|
-
tasks.push({ idx, key, value, setup, watch: _watch, hasInitialResult, initialResult });
|
|
477
|
+
const { value = null, setup = () => {}, watch: _watch = [], source } = val || {};
|
|
478
|
+
tasks.push({ idx, key, value, setup, watch: _watch, hasInitialResult, initialResult, source });
|
|
405
479
|
item[key] = value;
|
|
406
480
|
} else {
|
|
407
481
|
item[key] = val;
|
|
@@ -415,7 +489,7 @@
|
|
|
415
489
|
const taskRuns = new Map<LinkageTask, number>();
|
|
416
490
|
const runsetup = (task: LinkageTask) => {
|
|
417
491
|
if (current !== generation) return;
|
|
418
|
-
const { idx, key,
|
|
492
|
+
const { idx, key, setup } = task;
|
|
419
493
|
const target = getTarget(idx);
|
|
420
494
|
if (target && isFunction(setup)) {
|
|
421
495
|
pendingRuns.get(task)?.();
|
|
@@ -426,12 +500,10 @@
|
|
|
426
500
|
const isCurrent = () => current === generation && getTarget(idx) === target && taskRuns.get(task) === runId;
|
|
427
501
|
const apply = (result: any) => {
|
|
428
502
|
shared.revision++;
|
|
429
|
-
target
|
|
430
|
-
if (key === 'array') target._array = result ? normalizeArray(result) : null;
|
|
431
|
-
if (key === 'child' || key === 'array') initializeField(target);
|
|
503
|
+
applyResult(target, key, result);
|
|
432
504
|
};
|
|
433
505
|
if (v instanceof Promise) {
|
|
434
|
-
|
|
506
|
+
// 等待期间保留上一次的结果,避免选项、标题等先变空再出现。
|
|
435
507
|
let release!: () => void;
|
|
436
508
|
const pending = new Promise<void>(resolve => { release = resolve; });
|
|
437
509
|
const finish = () => {
|
|
@@ -466,10 +538,10 @@
|
|
|
466
538
|
const { watch: keys } = task;
|
|
467
539
|
if (keys === true) {
|
|
468
540
|
task.hasInitialResult = false;
|
|
469
|
-
|
|
470
|
-
|
|
541
|
+
// 函数属性只在计算结果变化时应用;联动配置的 watch: true 跟随根数据版本。
|
|
542
|
+
unwatchs.push(watch(task.source || shared.dataVersion, () => {
|
|
471
543
|
runsetup(task);
|
|
472
|
-
}));
|
|
544
|
+
}, { immediate: true }));
|
|
473
545
|
return;
|
|
474
546
|
}
|
|
475
547
|
unwatchs.push(
|
|
@@ -487,6 +559,7 @@
|
|
|
487
559
|
return { stop, resolve, start };
|
|
488
560
|
};
|
|
489
561
|
|
|
562
|
+
|
|
490
563
|
const route = useRoute();
|
|
491
564
|
const router = useRouter();
|
|
492
565
|
const attrs = useAttrs();
|
|
@@ -503,12 +576,12 @@
|
|
|
503
576
|
},
|
|
504
577
|
// JSON规则
|
|
505
578
|
form: {
|
|
506
|
-
type: Array
|
|
579
|
+
type: Array as PropType<FormItem[]>,
|
|
507
580
|
default: () => [],
|
|
508
581
|
},
|
|
509
582
|
// 表单数据
|
|
510
583
|
data: {
|
|
511
|
-
type: Object as any
|
|
584
|
+
type: Object as PropType<Record<string, any>>,
|
|
512
585
|
default: () => ({}),
|
|
513
586
|
},
|
|
514
587
|
// 栅格系统
|
|
@@ -575,7 +648,7 @@
|
|
|
575
648
|
interface Shared {
|
|
576
649
|
state: { lw: number; validated: boolean };
|
|
577
650
|
cache: ClearCache;
|
|
578
|
-
instances: Set<{ collect: () => any[] }>;
|
|
651
|
+
instances: Set<{ collect: () => any[]; hidden: () => string[] }>;
|
|
579
652
|
snapshot: any;
|
|
580
653
|
initialObjects: WeakMap<object, any>;
|
|
581
654
|
pending: Set<Promise<any>>;
|
|
@@ -583,6 +656,10 @@
|
|
|
583
656
|
validationEpoch: number;
|
|
584
657
|
cancelChecks: Set<() => void>;
|
|
585
658
|
validations: WeakMap<object, { input: any; epoch: number; pending: boolean; result: Promise<any> }>;
|
|
659
|
+
// 根数据任意变化时递增,供 watch: true 的联动共用
|
|
660
|
+
dataVersion: ShallowRef<number>;
|
|
661
|
+
// 外部回填的错误(如服务端校验),按字段路径保存;字段值变化后自动失效
|
|
662
|
+
serverErrors: Map<string, { msg: string; type: string; input: any }>;
|
|
586
663
|
}
|
|
587
664
|
const FORM_SHARED = Symbol.for('neatui:form-loader');
|
|
588
665
|
const shared: Shared = (props.root && inject<Shared | null>(FORM_SHARED, null)) || {
|
|
@@ -596,10 +673,15 @@
|
|
|
596
673
|
validationEpoch: 0,
|
|
597
674
|
cancelChecks: new Set(),
|
|
598
675
|
validations: new WeakMap(),
|
|
676
|
+
dataVersion: shallowRef(0),
|
|
677
|
+
serverErrors: reactive(new Map()),
|
|
599
678
|
};
|
|
600
679
|
provide(FORM_SHARED, shared);
|
|
601
680
|
|
|
602
681
|
const rootData = computed(() => props.root || props.data);
|
|
682
|
+
// 根表单未传 idx 时用 useId 生成前缀,同页多个表单的同名字段 id 不再冲突;SSR 与客户端一致。
|
|
683
|
+
const autoIdx = useId().replace(/[^\w-]/g, '-');
|
|
684
|
+
const baseIdx = computed(() => (props.idx !== '' ? String(props.idx) : props.root ? '' : autoIdx));
|
|
603
685
|
const cancelValidation = () => {
|
|
604
686
|
shared.validationEpoch++;
|
|
605
687
|
shared.cancelChecks.forEach(cancel => cancel());
|
|
@@ -616,7 +698,7 @@
|
|
|
616
698
|
};
|
|
617
699
|
const captureInitial = () => {
|
|
618
700
|
shared.initialObjects = new WeakMap();
|
|
619
|
-
shared.snapshot = cloneInitial(
|
|
701
|
+
shared.snapshot = cloneInitial(rootData.value);
|
|
620
702
|
shared.cache = new WeakMap();
|
|
621
703
|
cancelValidation();
|
|
622
704
|
};
|
|
@@ -627,6 +709,7 @@
|
|
|
627
709
|
if (!props.root) captureInitial();
|
|
628
710
|
watch(() => props.data, () => {
|
|
629
711
|
shared.revision++;
|
|
712
|
+
if (!props.root) shared.dataVersion.value++;
|
|
630
713
|
}, { deep: true, flush: 'sync' });
|
|
631
714
|
const editable = computed(() => props.mode !== 'view');
|
|
632
715
|
// 调用过 validate 后所有字段展示校验状态
|
|
@@ -666,6 +749,10 @@
|
|
|
666
749
|
const label = labelText(col.label);
|
|
667
750
|
if (!label) return '';
|
|
668
751
|
if (/[::]\s*$/.test(label)) return label;
|
|
752
|
+
// 4.x 兼容:字段级 colon 优先,false 不显示,string 自定义字符,true 为 ':'
|
|
753
|
+
if (col.colon === false) return label;
|
|
754
|
+
if (isString(col.colon)) return `${label}${col.colon}`;
|
|
755
|
+
if (col.colon === true) return `${label}:`;
|
|
669
756
|
// 自定义节点树和标签属性可以直接决定呈现;默认文本标签跟随表单冒号。
|
|
670
757
|
if ((isGroup(col) || col._array || col.array) && isVertical(col)) return label;
|
|
671
758
|
const suffix = isString(props.colon) ? props.colon : props.colon ? ':' : '';
|
|
@@ -693,7 +780,7 @@
|
|
|
693
780
|
|
|
694
781
|
// 分组、数组的路径和 id 前缀
|
|
695
782
|
const fieldPath = (col: any = {}) => [props.path, col.paths, col.field].filter(Boolean).join('.');
|
|
696
|
-
const childIdx = (col: any = {}, idx: number) => [
|
|
783
|
+
const childIdx = (col: any = {}, idx: number) => [baseIdx.value, col.field || `g${idx}`].filter((v: any) => v !== '').join('-');
|
|
697
784
|
const childData = (col: any = {}) => (col.field ? col._data[col.field] : col._data);
|
|
698
785
|
const listOf = (col: any = {}) => (isArray(col._data?.[col.field]) ? col._data[col.field] : []);
|
|
699
786
|
|
|
@@ -703,6 +790,7 @@
|
|
|
703
790
|
// 控件:已注册的 model;没写 model 且没有对应插槽时默认 Input
|
|
704
791
|
const resolveEntry = (col: any = {}) => {
|
|
705
792
|
const model = col.model?.[0];
|
|
793
|
+
if (col.slot && !getField(model)) return undefined;
|
|
706
794
|
if (model) return getField(model);
|
|
707
795
|
if (col.field && hasSlot(col.field)) return undefined;
|
|
708
796
|
return getField(col.field ? 'Input' : 'Text');
|
|
@@ -776,7 +864,30 @@
|
|
|
776
864
|
form: [],
|
|
777
865
|
});
|
|
778
866
|
|
|
779
|
-
|
|
867
|
+
// 路径缺失时补全对象,并把新建的对象记入重置基线
|
|
868
|
+
const resolvePath = (data: any, paths: string = '') => ensurePath(data, paths, recordInitial);
|
|
869
|
+
|
|
870
|
+
// 按需读取的联动上下文:函数没用到的属性(如数组行上下文)变化时不触发重算。
|
|
871
|
+
const lazyContext = (): FormContext => ({
|
|
872
|
+
get root() { return rootData.value; },
|
|
873
|
+
get data() { return props.data; },
|
|
874
|
+
get index() { return props.index; },
|
|
875
|
+
get array() { return props.arrayContext; },
|
|
876
|
+
get path() { return props.path; },
|
|
877
|
+
get mode() { return props.mode; },
|
|
878
|
+
route,
|
|
879
|
+
router,
|
|
880
|
+
});
|
|
881
|
+
const linkage = useLinkage({
|
|
882
|
+
target: (idx) => state.form[idx],
|
|
883
|
+
context: lazyContext,
|
|
884
|
+
shared,
|
|
885
|
+
apply: (target, key, result) => {
|
|
886
|
+
target[key] = key === 'model' ? normalizeModel(result) : result;
|
|
887
|
+
if (key === 'array') target._array = result ? normalizeArray(result) : null;
|
|
888
|
+
if (key === 'child' || key === 'array') initializeField(target);
|
|
889
|
+
},
|
|
890
|
+
});
|
|
780
891
|
|
|
781
892
|
// 初始结构和异步生成的子树都经过相同的数据初始化。
|
|
782
893
|
const initializeField = (item: any) => {
|
|
@@ -830,7 +941,7 @@
|
|
|
830
941
|
}
|
|
831
942
|
|
|
832
943
|
// 添加ID
|
|
833
|
-
item._fid = `form-${
|
|
944
|
+
item._fid = `form-${baseIdx.value !== '' ? baseIdx.value + '-' : ''}${!item.field && isGroup(item) ? `group-${idx}` : `field-${item.field || `node-${idx}`}`}`;
|
|
834
945
|
|
|
835
946
|
// 数据路径处理
|
|
836
947
|
item._data = resolvePath(props.data, item.paths);
|
|
@@ -895,6 +1006,8 @@
|
|
|
895
1006
|
state.form
|
|
896
1007
|
.filter((col: any) => !isCleared(col) && col.field && !isGroup(col))
|
|
897
1008
|
.map((col: any) => ({ col, path: fieldPath(col), ctx: context() })),
|
|
1009
|
+
// 当前层被隐藏(clear)的字段路径,供 getData({ visibleOnly }) 剔除
|
|
1010
|
+
hidden: (): string[] => state.form.filter((col: any) => col.field && isCleared(col)).map((col: any) => fieldPath(col)),
|
|
898
1011
|
};
|
|
899
1012
|
onMounted(() => {
|
|
900
1013
|
shared.instances.add(instance);
|
|
@@ -917,7 +1030,10 @@
|
|
|
917
1030
|
const keys = Object.keys(a);
|
|
918
1031
|
return keys.length === Object.keys(b).length && keys.every(key => Object.prototype.hasOwnProperty.call(b, key) && sameInput(a[key], b[key]));
|
|
919
1032
|
};
|
|
920
|
-
|
|
1033
|
+
// path 由收集字段的那一层计算(validate 在根表单调用时,嵌套字段的路径不能用根表单拼)
|
|
1034
|
+
const validateField = (col: any, ctx: FormContext, path: string, force = false): Promise<any> => {
|
|
1035
|
+
const server = shared.serverErrors.get(path);
|
|
1036
|
+
if (server) return Promise.resolve({ msg: server.msg, type: server.type });
|
|
921
1037
|
const input = deepcopy([displayOf(col, ctx), rulesOf(col), col.model]);
|
|
922
1038
|
const previous = shared.validations.get(col);
|
|
923
1039
|
if (previous && previous.epoch === shared.validationEpoch && (!force || previous.pending) && sameInput(previous.input, input)) return previous.result;
|
|
@@ -930,7 +1046,7 @@
|
|
|
930
1046
|
error = message ? { msg: message, type: 'risk' } : await checkRules(input[0], input[1]);
|
|
931
1047
|
} catch { error = { msg: '校验失败,请重试', type: 'risk' }; }
|
|
932
1048
|
job.pending = false;
|
|
933
|
-
if (shared.validations.get(col) === job && shared.validationEpoch === job.epoch) { col._error = error; col._checked = true; }
|
|
1049
|
+
if (shared.validations.get(col) === job && shared.validationEpoch === job.epoch && !shared.serverErrors.has(path)) { col._error = error; col._checked = true; }
|
|
934
1050
|
return error;
|
|
935
1051
|
})();
|
|
936
1052
|
return job.result;
|
|
@@ -938,11 +1054,22 @@
|
|
|
938
1054
|
watchEffect(() => {
|
|
939
1055
|
for (const col of state.form) {
|
|
940
1056
|
// 只读取校验输入,不监听内部错误结果;异步结果按执行顺序丢弃过期响应。
|
|
941
|
-
deepcopy(displayOf(col, context()));
|
|
1057
|
+
const value = deepcopy(displayOf(col, context()));
|
|
942
1058
|
deepcopy(rulesOf(col));
|
|
943
1059
|
deepcopy(col.model);
|
|
1060
|
+
// 回填的错误在字段值变化前一直显示,值一变就交回正常校验
|
|
1061
|
+
const path = col.field ? fieldPath(col) : '';
|
|
1062
|
+
const server = path ? shared.serverErrors.get(path) : undefined;
|
|
1063
|
+
if (server && !isCleared(col)) {
|
|
1064
|
+
if (sameInput(server.input, value)) {
|
|
1065
|
+
col._error = { msg: server.msg, type: server.type };
|
|
1066
|
+
col._checked = true;
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
shared.serverErrors.delete(path);
|
|
1070
|
+
}
|
|
944
1071
|
if (col.field && !isGroup(col) && !isCleared(col) && (touched.value || col._touched)) {
|
|
945
|
-
void validateField(col, context());
|
|
1072
|
+
void validateField(col, context(), path);
|
|
946
1073
|
} else {
|
|
947
1074
|
shared.validations.delete(col);
|
|
948
1075
|
col._error = null;
|
|
@@ -970,15 +1097,19 @@
|
|
|
970
1097
|
nextTick(() => el.scrollIntoView({ behavior: 'smooth', block: 'center' }));
|
|
971
1098
|
};
|
|
972
1099
|
|
|
973
|
-
//
|
|
974
|
-
const
|
|
1100
|
+
// 路径匹配:'contact' 包含 'contact.phone','members.0' 包含该行全部字段
|
|
1101
|
+
const withinPaths = (path: string, paths?: string[]) => !paths || paths.some((p) => path === p || path.startsWith(`${p}.`));
|
|
1102
|
+
|
|
1103
|
+
// 校验已显示的字段(包含分组和数组),warn 类型只作为提醒,不影响 valid;paths 限定只校验这些路径
|
|
1104
|
+
const validate = async ({ scroll = false, paths }: { scroll?: boolean; paths?: string[] } = {}): Promise<FormValidateResult> => {
|
|
975
1105
|
const epoch = shared.validationEpoch;
|
|
976
1106
|
const cancelledResult = (): FormValidateResult => ({ valid: false, errors: [{ field: '', path: '', label: '', msg: '表单已重置或切换,请重新校验', type: 'risk' }], warnings: [] });
|
|
977
1107
|
let cancel!: () => void;
|
|
978
1108
|
const cancelled = new Promise<FormValidateResult>(resolve => { cancel = () => resolve(cancelledResult()); });
|
|
979
1109
|
shared.cancelChecks.add(cancel);
|
|
980
1110
|
const run = async (): Promise<FormValidateResult> => {
|
|
981
|
-
|
|
1111
|
+
// 局部校验只让这些字段显示提示,其他字段保持原状
|
|
1112
|
+
if (!paths) shared.state.validated = true;
|
|
982
1113
|
let results: any[];
|
|
983
1114
|
let revision: number;
|
|
984
1115
|
do {
|
|
@@ -990,8 +1121,9 @@
|
|
|
990
1121
|
if (shared.pending.size) await Promise.all([...shared.pending]);
|
|
991
1122
|
} while (revision !== shared.revision || shared.pending.size);
|
|
992
1123
|
revision = shared.revision;
|
|
993
|
-
results = await Promise.all(fieldsOf().map(async ({ col, path, ctx }) => {
|
|
994
|
-
|
|
1124
|
+
results = await Promise.all(fieldsOf().filter(({ path }) => withinPaths(path, paths)).map(async ({ col, path, ctx }) => {
|
|
1125
|
+
if (paths) col._touched = true;
|
|
1126
|
+
const res = await validateField(col, ctx, path, true);
|
|
995
1127
|
return res ? { field: col.field, path, label: labelText(col.label), msg: res.msg, type: res.type, fid: col._fid } : null;
|
|
996
1128
|
}));
|
|
997
1129
|
if (epoch !== shared.validationEpoch) return cancelledResult();
|
|
@@ -1021,10 +1153,66 @@
|
|
|
1021
1153
|
|
|
1022
1154
|
const clearValidate = () => {
|
|
1023
1155
|
cancelValidation();
|
|
1156
|
+
shared.serverErrors.clear();
|
|
1024
1157
|
shared.validations = new WeakMap();
|
|
1025
1158
|
shared.state.validated = false;
|
|
1026
1159
|
fieldsOf().forEach(({ col }) => { col._touched = false; col._error = null; col._checked = false; });
|
|
1027
1160
|
};
|
|
1028
1161
|
|
|
1029
|
-
|
|
1162
|
+
// 回填外部错误(如服务端校验结果):{ 'contact.phone': '号码已被占用' } 或 [{ path, msg, type }]。
|
|
1163
|
+
// 字段值变化后错误自动消失;返回没有匹配到已显示字段的路径,便于改用全局提示。
|
|
1164
|
+
const setErrors = (errors: Record<string, string | { msg: string; type?: string }> | Array<{ path: string; msg: string; type?: string }>, { scroll = false }: { scroll?: boolean } = {}): string[] => {
|
|
1165
|
+
const list: Array<{ path: string; msg: string; type?: string }> = Array.isArray(errors)
|
|
1166
|
+
? errors
|
|
1167
|
+
: Object.entries(errors || {}).map(([path, error]) => (isString(error) ? { path, msg: error } : { path, ...(error as any) }));
|
|
1168
|
+
const fields = fieldsOf();
|
|
1169
|
+
const missing: string[] = [];
|
|
1170
|
+
list.forEach(({ path, msg, type = 'risk' }) => {
|
|
1171
|
+
const found = fields.find((item) => item.path === path);
|
|
1172
|
+
if (!found) return missing.push(path);
|
|
1173
|
+
shared.serverErrors.set(path, { msg, type, input: deepcopy(displayOf(found.col, found.ctx)) });
|
|
1174
|
+
});
|
|
1175
|
+
const first = list.find((item) => !missing.includes(item.path));
|
|
1176
|
+
if (scroll && first) scrollToField(first.path);
|
|
1177
|
+
return missing;
|
|
1178
|
+
};
|
|
1179
|
+
|
|
1180
|
+
// 与初始值(挂载时的数据及自动补全的默认值,或 setBaseline 之后的数据)比较,返回变化的字段路径
|
|
1181
|
+
const getChanges = (): string[] => {
|
|
1182
|
+
const changes: string[] = [];
|
|
1183
|
+
const diff = (current: any, initial: any, path: string) => {
|
|
1184
|
+
if (sameInput(current, initial)) return;
|
|
1185
|
+
const bothArrays = isArray(current) && isArray(initial);
|
|
1186
|
+
const bothObjects = isPlain(current) && isPlain(initial);
|
|
1187
|
+
if ((bothArrays && current.length === initial.length) || bothObjects) {
|
|
1188
|
+
const keys = new Set([...Object.keys(current), ...Object.keys(initial)]);
|
|
1189
|
+
keys.forEach((key) => diff(current[key], initial[key], path ? `${path}.${key}` : key));
|
|
1190
|
+
} else changes.push(path);
|
|
1191
|
+
};
|
|
1192
|
+
diff(toRaw(rootData.value), shared.snapshot, '');
|
|
1193
|
+
return changes;
|
|
1194
|
+
};
|
|
1195
|
+
const isDirty = (path?: string) => getChanges().some((item) => !path || withinPaths(item, [path]) || withinPaths(path, [item]));
|
|
1196
|
+
|
|
1197
|
+
// 把当前数据设为新的初始值:同一对象上加载记录(Object.assign)或保存成功后调用,reset() 与 isDirty() 以它为准
|
|
1198
|
+
const setBaseline = () => captureInitial();
|
|
1199
|
+
|
|
1200
|
+
// 取数据副本;visibleOnly 时去掉被 clear 隐藏的字段
|
|
1201
|
+
const getData = ({ visibleOnly = false }: { visibleOnly?: boolean } = {}) => {
|
|
1202
|
+
const data = deepcopy(toRaw(rootData.value));
|
|
1203
|
+
if (visibleOnly) {
|
|
1204
|
+
[...shared.instances].flatMap((item) => item.hidden()).forEach((path) => {
|
|
1205
|
+
const keys = path.split('.');
|
|
1206
|
+
const last = keys.pop() as string;
|
|
1207
|
+
const parent = keys.reduce((target: any, key) => (target == null ? undefined : target[key]), data);
|
|
1208
|
+
if (parent && typeof parent === 'object') delete parent[last];
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
return data;
|
|
1212
|
+
};
|
|
1213
|
+
|
|
1214
|
+
// 插槽名由字段名、model 名动态生成,参数随插槽种类不同;字段与控件插槽的参数见 FormSlotScope
|
|
1215
|
+
defineSlots<Record<string, (scope: any) => any>>();
|
|
1216
|
+
|
|
1217
|
+
defineExpose({ validate, reset, clearValidate, scrollToField, setErrors, getChanges, isDirty, setBaseline, getData });
|
|
1030
1218
|
</script>
|
|
@@ -1,24 +1,17 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="flex-block pr" @focusout="col._touched = true"
|
|
2
|
+
<div ref="root" class="flex-block pr" @focusout="col._touched = true">
|
|
3
3
|
<slot v-if="custom" />
|
|
4
|
-
<button v-else-if="col.model[0] === 'Button'" type="button" ui-btn="@a s" v-bind="
|
|
5
|
-
<div v-else-if="col.model[0] === 'Html'" v-bind="
|
|
6
|
-
<div v-else-if="col.model[0] === 'Text'" v-bind="
|
|
4
|
+
<button v-else-if="col.model[0] === 'Button'" type="button" ui-btn="@a s" v-bind="contentAttrs" v-on="events"><FieldExtra :value="displayOf(col, ctx)" :trusted="trusted" /></button>
|
|
5
|
+
<div v-else-if="col.model[0] === 'Html' && trusted" v-bind="contentAttrs" v-on="events" v-html="displayOf(col, ctx)"></div>
|
|
6
|
+
<div v-else-if="col.model[0] === 'Html' || col.model[0] === 'Text'" v-bind="contentAttrs" v-on="events"><FieldExtra :value="displayOf(col, ctx)" :trusted="trusted" /></div>
|
|
7
7
|
<div v-else-if="(mode === 'view' && !entry?.view) || entry?.component === FieldView" class="flex-block o-ls" :class="clh(col.model?.[1] || {})" v-on="events">{{ text }}</div>
|
|
8
8
|
<component
|
|
9
9
|
v-else-if="entry"
|
|
10
10
|
:is="entry.component"
|
|
11
11
|
:modelValue="displayOf(col, ctx)"
|
|
12
|
-
:interacted="interacted"
|
|
13
|
-
:options="entry.options ? col.enums : undefined"
|
|
14
12
|
:class="entry.class"
|
|
15
|
-
v-bind="
|
|
16
|
-
|
|
17
|
-
:verify="false"
|
|
18
|
-
:co="col._error?.type || col.model[1]?.co"
|
|
19
|
-
:readonly="mode === 'view' ? true : col.model[1]?.readonly"
|
|
20
|
-
:disabled="mode === 'view' ? true : col.model[1]?.disabled"
|
|
21
|
-
v-on="events"
|
|
13
|
+
v-bind="controlProps"
|
|
14
|
+
v-on="controlEvents"
|
|
22
15
|
@update:modelValue="update"
|
|
23
16
|
/>
|
|
24
17
|
<FieldMessage :col="col" :mode="mode" />
|
|
@@ -26,7 +19,7 @@
|
|
|
26
19
|
</template>
|
|
27
20
|
<script lang="ts">
|
|
28
21
|
import { defineComponent, h, isVNode, markRaw } from 'vue';
|
|
29
|
-
import type { Component } from 'vue';
|
|
22
|
+
import type { Component, VNode } from 'vue';
|
|
30
23
|
import { isArray, isObject, isString, isFunction } from '@fekit/utils';
|
|
31
24
|
|
|
32
25
|
/** 最近一层数组的操作上下文;行引用在排序后仍指向原行。 */
|
|
@@ -91,6 +84,9 @@
|
|
|
91
84
|
set?: (value: any, ctx: FormContext) => any;
|
|
92
85
|
}
|
|
93
86
|
|
|
87
|
+
/** 内容节点可以渲染的值 */
|
|
88
|
+
export type FormContent = string | number | boolean | null | VNode | { text?: string; html?: boolean; attrs?: Record<string, any> } | FormContent[];
|
|
89
|
+
|
|
94
90
|
/** 数组配置 */
|
|
95
91
|
export interface FormArray {
|
|
96
92
|
// 每一行的字段,兼容旧版 lists
|
|
@@ -116,7 +112,8 @@
|
|
|
116
112
|
export interface FormItem {
|
|
117
113
|
label?: Dynamic<string | [string, Record<string, any>] | FormItem[]>;
|
|
118
114
|
field?: string;
|
|
119
|
-
|
|
115
|
+
// 固定内容、{ text, html, attrs } 内容对象、VNode、数组;绑定字段时用 { default, get, set };也可以是函数或联动配置
|
|
116
|
+
value?: FormValue | FormContent | FormSetup | FormLinkage<FormContent>;
|
|
120
117
|
child?: Dynamic<FormItem[]>;
|
|
121
118
|
attrs?: Dynamic<Record<string, any>>;
|
|
122
119
|
clear?: Dynamic<boolean | { value: boolean; restore?: boolean }>;
|
|
@@ -129,6 +126,25 @@
|
|
|
129
126
|
media?: Dynamic<string>;
|
|
130
127
|
vertical?: Dynamic<boolean>;
|
|
131
128
|
paths?: string;
|
|
129
|
+
// 4.x 兼容:自定义插槽名;字段级冒号(false 不显示,string 自定义字符)
|
|
130
|
+
slot?: string;
|
|
131
|
+
colon?: boolean | string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 字段与控件插槽(${field}、model 名、${field}-value、${field}-row)的参数 */
|
|
135
|
+
export interface FormSlotScope {
|
|
136
|
+
col: any;
|
|
137
|
+
node: any;
|
|
138
|
+
// 当前层数据
|
|
139
|
+
data: any;
|
|
140
|
+
mode: string;
|
|
141
|
+
// 原始值(未经 value.get)
|
|
142
|
+
value: any;
|
|
143
|
+
ctx: FormContext;
|
|
144
|
+
// 写回:经过 value.set,查看模式下不写入
|
|
145
|
+
update: (value: any) => void;
|
|
146
|
+
// Vue 控件直接 v-bind:modelValue 已经过 value.get
|
|
147
|
+
bind: { modelValue: any; 'onUpdate:modelValue': (value: any) => void; disabled: boolean };
|
|
132
148
|
}
|
|
133
149
|
|
|
134
150
|
/** 校验结果 */
|
|
@@ -147,10 +163,20 @@
|
|
|
147
163
|
|
|
148
164
|
/** FormLoader 对外方法 */
|
|
149
165
|
export interface FormLoaderExpose {
|
|
150
|
-
|
|
166
|
+
/** 校验已显示字段;paths 只校验这些路径(含子路径) */
|
|
167
|
+
validate: (options?: { scroll?: boolean; paths?: string[] }) => Promise<FormValidateResult>;
|
|
151
168
|
reset: () => void;
|
|
152
169
|
clearValidate: () => void;
|
|
153
170
|
scrollToField: (path: string) => void;
|
|
171
|
+
/** 回填外部错误,返回没有匹配到字段的路径 */
|
|
172
|
+
setErrors: (errors: Record<string, string | { msg: string; type?: string }> | Array<{ path: string; msg: string; type?: string }>, options?: { scroll?: boolean }) => string[];
|
|
173
|
+
/** 与初始值相比发生变化的字段路径 */
|
|
174
|
+
getChanges: () => string[];
|
|
175
|
+
isDirty: (path?: string) => boolean;
|
|
176
|
+
/** 把当前数据设为新的初始值 */
|
|
177
|
+
setBaseline: () => void;
|
|
178
|
+
/** 数据副本;visibleOnly 去掉被 clear 隐藏的字段 */
|
|
179
|
+
getData: (options?: { visibleOnly?: boolean }) => any;
|
|
154
180
|
}
|
|
155
181
|
|
|
156
182
|
/** 动态属性任务 */
|
|
@@ -163,12 +189,14 @@
|
|
|
163
189
|
watch: string[] | true;
|
|
164
190
|
hasInitialResult: boolean;
|
|
165
191
|
initialResult: any;
|
|
192
|
+
// 函数属性的计算结果;变化时重新应用,替代整表深拷贝监听
|
|
193
|
+
source?: () => any;
|
|
166
194
|
}
|
|
167
195
|
|
|
168
196
|
/** 控件注册信息 */
|
|
169
197
|
export interface FieldEntry {
|
|
170
198
|
component: Component;
|
|
171
|
-
// 查看模式使用控件自身的 readonly
|
|
199
|
+
// 查看模式使用控件自身的 readonly 展示(如图片库)。
|
|
172
200
|
view?: boolean;
|
|
173
201
|
validate?: (value: any, props: any) => string | null | Promise<string | null>;
|
|
174
202
|
// 是否把 enums 作为 options 传入
|
|
@@ -225,14 +253,14 @@
|
|
|
225
253
|
return handlers;
|
|
226
254
|
};
|
|
227
255
|
|
|
228
|
-
// 内容渲染:字符串按文本渲染;VNode 原样渲染;{ text, html, attrs } 只有 html 为 true
|
|
229
|
-
const render = (value: any): any => {
|
|
256
|
+
// 内容渲染:字符串按文本渲染;VNode 原样渲染;{ text, html, attrs } 只有 html 为 true 且内容来自配置时才按 HTML 渲染
|
|
257
|
+
const render = (value: any, trusted: boolean): any => {
|
|
230
258
|
if (value === null || value === undefined || value === '') return null;
|
|
231
259
|
if (isVNode(value)) return value;
|
|
232
|
-
if (isArray(value)) return value.map(render);
|
|
260
|
+
if (isArray(value)) return value.map((item: any) => render(item, trusted));
|
|
233
261
|
if (isObject(value)) {
|
|
234
262
|
const { text = '', html = false, attrs = {} } = value;
|
|
235
|
-
return html ? h('span', { ...attrs, innerHTML: text }) : h('span', attrs, text);
|
|
263
|
+
return html && trusted ? h('span', { ...attrs, innerHTML: text }) : h('span', trusted ? attrs : {}, String(text));
|
|
236
264
|
}
|
|
237
265
|
return h('span', String(value));
|
|
238
266
|
};
|
|
@@ -252,16 +280,16 @@
|
|
|
252
280
|
name: 'FieldExtra',
|
|
253
281
|
props: {
|
|
254
282
|
value: { type: null, default: null },
|
|
283
|
+
trusted: { type: Boolean, default: false },
|
|
255
284
|
},
|
|
256
285
|
setup(props) {
|
|
257
|
-
return () => render(props.value);
|
|
286
|
+
return () => render(props.value, props.trusted);
|
|
258
287
|
},
|
|
259
288
|
});
|
|
260
289
|
|
|
261
290
|
import {
|
|
262
291
|
Radio,
|
|
263
292
|
Gallery,
|
|
264
|
-
RichTextInput,
|
|
265
293
|
Input,
|
|
266
294
|
InputRange,
|
|
267
295
|
Switch,
|
|
@@ -295,7 +323,7 @@
|
|
|
295
323
|
const multiple = { emptyValue: () => [] };
|
|
296
324
|
|
|
297
325
|
// View 由当前渲染器直接展示;注册表保留标记以兼容自定义注册。
|
|
298
|
-
const FieldView = defineComponent({ name: 'FormView', render: () => null });
|
|
326
|
+
export const FieldView = defineComponent({ name: 'FormView', render: () => null });
|
|
299
327
|
|
|
300
328
|
// 占位:只占栅格,不渲染内容
|
|
301
329
|
const Blank = defineComponent({ name: 'FormBlank', render: () => null });
|
|
@@ -304,7 +332,6 @@
|
|
|
304
332
|
const registry: Record<string, FieldEntry> = {
|
|
305
333
|
Radio: option(Radio),
|
|
306
334
|
Gallery: option(Gallery, { view: true, emptyValue: (props: any) => props?.multiple ? [] : '' }),
|
|
307
|
-
RichTextInput: { component: markRaw(RichTextInput), view: true, class: 'w-full' },
|
|
308
335
|
Input: { component: markRaw(Input), class: 'w-full' },
|
|
309
336
|
InputRange: { component: markRaw(InputRange), class: 'w-full', emptyValue: () => [] },
|
|
310
337
|
Select: option(Select),
|
|
@@ -344,9 +371,9 @@
|
|
|
344
371
|
|
|
345
372
|
// 仅补名称别名;显式注册同名控件时仍优先使用注册项。
|
|
346
373
|
const fieldAliases: Record<string, string> = { TextArea: 'Textarea', view: 'View', SelectImg: 'Gallery' };
|
|
347
|
-
export const getField = (model: string = ''): FieldEntry | undefined => registry[model] || registry[fieldAliases[model]];
|
|
374
|
+
export const getField = (model: string = ''): FieldEntry | undefined => registry[model] || registry[fieldAliases[model] ?? ''];
|
|
348
375
|
|
|
349
|
-
//
|
|
376
|
+
// 注册自定义控件,比如业务里的图片库;注册后 model 直接写名字即可。富文本等重控件建议用插槽接入,不进注册表
|
|
350
377
|
export const registerField = (name: string, entry: FieldEntry) => {
|
|
351
378
|
registry[name] = { ...entry, component: markRaw(entry.component as any) };
|
|
352
379
|
};
|
|
@@ -358,10 +385,10 @@
|
|
|
358
385
|
if (typeof value === 'function') return value(model[1]);
|
|
359
386
|
return value === undefined ? '' : value;
|
|
360
387
|
};
|
|
361
|
-
|
|
362
388
|
</script>
|
|
363
389
|
<script lang="ts" setup>
|
|
364
|
-
|
|
390
|
+
// 单个字段的渲染:内置内容节点、注册控件或插槽,以及统一的错误提示
|
|
391
|
+
import { computed, ref, watch, nextTick, onMounted, onUpdated } from 'vue';
|
|
365
392
|
const props = defineProps<{
|
|
366
393
|
col: any;
|
|
367
394
|
entry?: FieldEntry;
|
|
@@ -404,6 +431,73 @@
|
|
|
404
431
|
}
|
|
405
432
|
return String(one(value));
|
|
406
433
|
});
|
|
407
|
-
|
|
434
|
+
// 配置里写死、由配置函数生成或经 value.get 转换的内容视为可信;直接显示 field 数据时按文本渲染,避免 XSS。
|
|
435
|
+
const trusted = computed(() => {
|
|
436
|
+
const { value } = props.col;
|
|
437
|
+
return value !== undefined && (!valueBinding(value) || isFunction(value.get));
|
|
438
|
+
});
|
|
408
439
|
const events = computed(() => eventsOf(props.col, props.ctx));
|
|
440
|
+
// 先写回数据再通知配置里的 update:modelValue,与插槽的 update 顺序一致。
|
|
441
|
+
const controlEvents = computed(() => {
|
|
442
|
+
const { 'update:modelValue': _, ...rest } = events.value;
|
|
443
|
+
return rest;
|
|
444
|
+
});
|
|
445
|
+
const update = (value: any) => {
|
|
446
|
+
props.col._touched = true;
|
|
447
|
+
writeValue(props.col, value, props.ctx);
|
|
448
|
+
events.value['update:modelValue']?.(value);
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
// FormLoader 自己的参数只传给声明了它们的控件,避免落到 Radio、Gallery 等控件的 DOM 上。
|
|
452
|
+
const formOnly = ['interacted', 'rules', 'verify', 'co', 'format', 'empty'];
|
|
453
|
+
const controlProps = computed(() => {
|
|
454
|
+
const { col, entry, mode } = props;
|
|
455
|
+
const options = col.model?.[1] || {};
|
|
456
|
+
const merged: Record<string, any> = {
|
|
457
|
+
interacted: props.interacted,
|
|
458
|
+
options: entry?.options ? col.enums : undefined,
|
|
459
|
+
...options,
|
|
460
|
+
rules: [],
|
|
461
|
+
verify: false,
|
|
462
|
+
co: col._error?.type || options.co,
|
|
463
|
+
readonly: mode === 'view' ? true : options.readonly,
|
|
464
|
+
disabled: mode === 'view' ? true : options.disabled,
|
|
465
|
+
};
|
|
466
|
+
const declared: any = (entry?.component as any)?.props;
|
|
467
|
+
// 没有声明 props 的自定义注册控件可能通过 $attrs 读取这些参数,保持透传。
|
|
468
|
+
const declares = (key: string) => !declared || (isArray(declared) ? declared.includes(key) : Object.prototype.hasOwnProperty.call(declared, key));
|
|
469
|
+
formOnly.forEach((key) => { if (!declares(key)) delete merged[key]; });
|
|
470
|
+
if (merged.options === undefined) delete merged.options;
|
|
471
|
+
return merged;
|
|
472
|
+
});
|
|
473
|
+
// 按钮和内容节点只接收普通属性
|
|
474
|
+
const contentAttrs = computed(() => {
|
|
475
|
+
const attrs = { ...(props.col.model?.[1] || {}) };
|
|
476
|
+
formOnly.forEach((key) => delete attrs[key]);
|
|
477
|
+
return attrs;
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// 错误提示关联到实际输入元素,读屏软件才能读出;只移除自己加上的属性。
|
|
481
|
+
const root = ref<HTMLElement>();
|
|
482
|
+
const inputs = 'input:not([type=hidden]):not([type=search]), textarea, select, [contenteditable="true"], [role="radiogroup"]';
|
|
483
|
+
const describe = () => {
|
|
484
|
+
const el = root.value;
|
|
485
|
+
// 非 DOM 渲染环境(SSR、自定义渲染器)没有查询接口
|
|
486
|
+
if (!el || typeof el.querySelectorAll !== 'function') return;
|
|
487
|
+
const errorId = props.col._error && props.mode !== 'view' ? `${props.col._fid}-error` : '';
|
|
488
|
+
el.querySelectorAll<HTMLElement>(inputs).forEach((node) => {
|
|
489
|
+
if (errorId) {
|
|
490
|
+
node.setAttribute('aria-invalid', 'true');
|
|
491
|
+
node.setAttribute('aria-describedby', errorId);
|
|
492
|
+
node.dataset.formAria = '';
|
|
493
|
+
} else if (node.dataset.formAria !== undefined) {
|
|
494
|
+
node.removeAttribute('aria-invalid');
|
|
495
|
+
node.removeAttribute('aria-describedby');
|
|
496
|
+
delete node.dataset.formAria;
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
};
|
|
500
|
+
onMounted(describe);
|
|
501
|
+
onUpdated(describe);
|
|
502
|
+
watch(() => [props.col._error, props.mode], () => nextTick(describe));
|
|
409
503
|
</script>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import FormLoader from './FormLoader/FormLoader.vue';
|
|
2
2
|
export { registerField } from './FormLoader/FormRender.vue';
|
|
3
|
-
export type { FormValue, FormItem, FormArray, FormRule, FormContext, FormArrayContext, FormLinkage, FormError, FormValidateResult, FormLoaderExpose, FieldEntry } from './FormLoader/FormRender.vue';
|
|
3
|
+
export type { FormValue, FormItem, FormArray, FormRule, FormContext, FormArrayContext, FormLinkage, FormError, FormValidateResult, FormLoaderExpose, FormSlotScope, FieldEntry } from './FormLoader/FormRender.vue';
|
|
4
4
|
import LimitLoader from './LimitLoader';
|
|
5
5
|
export * from './TableLoader/index';
|
|
6
6
|
import TableLoader from './TableLoader/TableLoader.vue';
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<div class="neat-rich-text b-solid b-xs b-line r-sm" :aria-busy="!editor">
|
|
3
|
-
<template v-if="editor">
|
|
4
|
-
<div v-if="!readonly" role="toolbar" aria-label="文本格式" class="n-sm" ui-flex="row lm">
|
|
5
|
-
<button v-for="tool in tools" :key="tool.label" type="button" ui-btn="@a s" :disabled="disabled"
|
|
6
|
-
:aria-pressed="tool.active ? editor.isActive(tool.active) : undefined" @mousedown.prevent @click="tool.run()">{{ tool.label }}</button>
|
|
7
|
-
<button type="button" ui-btn="@a s" :disabled="disabled" @mousedown.prevent @click="openLink">链接</button>
|
|
8
|
-
<button type="button" ui-btn="@a s" :disabled="disabled || !editor.can().undo()" @mousedown.prevent @click="editor.chain().focus().undo().run()">撤销</button>
|
|
9
|
-
<button type="button" ui-btn="@a s" :disabled="disabled || !editor.can().redo()" @mousedown.prevent @click="editor.chain().focus().redo().run()">重做</button>
|
|
10
|
-
</div>
|
|
11
|
-
<div v-if="linkOpen && !disabled && !readonly" class="n-sm" ui-flex="row lm">
|
|
12
|
-
<input v-model="link" aria-label="链接地址" placeholder="https://" @keydown.enter.prevent="applyLink" />
|
|
13
|
-
<button type="button" @click="applyLink">应用链接</button>
|
|
14
|
-
<button type="button" @click="linkOpen = false">取消</button>
|
|
15
|
-
<span v-if="linkError" role="alert">请输入 http、https 或 mailto 地址</span>
|
|
16
|
-
</div>
|
|
17
|
-
<EditorContent :editor="editor" />
|
|
18
|
-
</template>
|
|
19
|
-
</div>
|
|
20
|
-
</template>
|
|
21
|
-
<script setup lang="ts">
|
|
22
|
-
import { onMounted, onBeforeUnmount, shallowRef, watch, ref } from 'vue';
|
|
23
|
-
import { Editor, EditorContent } from '@tiptap/vue-3';
|
|
24
|
-
import StarterKit from '@tiptap/starter-kit';
|
|
25
|
-
const props = withDefaults(defineProps<{
|
|
26
|
-
modelValue?: string; disabled?: boolean; readonly?: boolean; label?: string;
|
|
27
|
-
}>(), { modelValue: '', disabled: false, readonly: false, label: '富文本内容' });
|
|
28
|
-
const emit = defineEmits(['update:modelValue', 'change', 'focus', 'blur']);
|
|
29
|
-
const editor = shallowRef<Editor>();
|
|
30
|
-
const linkOpen = ref(false);
|
|
31
|
-
const link = ref('');
|
|
32
|
-
const linkError = ref(false);
|
|
33
|
-
let linkSelection = { from: 1, to: 1 };
|
|
34
|
-
onMounted(() => {
|
|
35
|
-
editor.value = new Editor({
|
|
36
|
-
extensions: [StarterKit.configure({ link: { openOnClick: false } })],
|
|
37
|
-
content: props.modelValue,
|
|
38
|
-
editable: !props.disabled && !props.readonly,
|
|
39
|
-
editorProps: { attributes: { role: 'textbox', 'aria-multiline': 'true', 'aria-label': props.label } },
|
|
40
|
-
onUpdate: ({ editor }) => {
|
|
41
|
-
const value = editor.isEmpty ? '' : editor.getHTML();
|
|
42
|
-
emit('update:modelValue', value);
|
|
43
|
-
emit('change', value);
|
|
44
|
-
},
|
|
45
|
-
onFocus: ({ event }) => emit('focus', event),
|
|
46
|
-
onBlur: ({ event }) => emit('blur', event),
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
onBeforeUnmount(() => editor.value?.destroy());
|
|
50
|
-
watch(() => props.modelValue, value => {
|
|
51
|
-
const instance = editor.value;
|
|
52
|
-
if (instance && value !== (instance.isEmpty ? '' : instance.getHTML())) instance.commands.setContent(value || '', { emitUpdate: false });
|
|
53
|
-
});
|
|
54
|
-
watch(() => [props.disabled, props.readonly], () => {
|
|
55
|
-
editor.value?.setEditable(!props.disabled && !props.readonly, false);
|
|
56
|
-
linkOpen.value = false;
|
|
57
|
-
});
|
|
58
|
-
const tools = [
|
|
59
|
-
{ label: '加粗', active: 'bold', run: () => editor.value?.chain().focus().toggleBold().run() },
|
|
60
|
-
{ label: '斜体', active: 'italic', run: () => editor.value?.chain().focus().toggleItalic().run() },
|
|
61
|
-
{ label: '下划线', active: 'underline', run: () => editor.value?.chain().focus().toggleUnderline().run() },
|
|
62
|
-
{ label: '标题', active: 'heading', run: () => editor.value?.chain().focus().toggleHeading({ level: 2 }).run() },
|
|
63
|
-
{ label: '无序列表', active: 'bulletList', run: () => editor.value?.chain().focus().toggleBulletList().run() },
|
|
64
|
-
{ label: '有序列表', active: 'orderedList', run: () => editor.value?.chain().focus().toggleOrderedList().run() },
|
|
65
|
-
{ label: '引用', active: 'blockquote', run: () => editor.value?.chain().focus().toggleBlockquote().run() },
|
|
66
|
-
{ label: '清除格式', run: () => editor.value?.chain().focus().unsetAllMarks().clearNodes().run() },
|
|
67
|
-
];
|
|
68
|
-
const openLink = () => {
|
|
69
|
-
if (!editor.value) return;
|
|
70
|
-
const instance = editor.value;
|
|
71
|
-
const selection = instance.view.dom.ownerDocument.getSelection();
|
|
72
|
-
const { from, to } = instance.state.selection;
|
|
73
|
-
linkSelection = { from, to };
|
|
74
|
-
// DOM 选区更新可能先于编辑器的 selectionchange 事务。
|
|
75
|
-
if (selection?.anchorNode && selection.focusNode && instance.view.dom.contains(selection.anchorNode) && instance.view.dom.contains(selection.focusNode)) {
|
|
76
|
-
const anchor = instance.view.posAtDOM(selection.anchorNode, selection.anchorOffset);
|
|
77
|
-
const focus = instance.view.posAtDOM(selection.focusNode, selection.focusOffset);
|
|
78
|
-
linkSelection = { from: Math.min(anchor, focus), to: Math.max(anchor, focus) };
|
|
79
|
-
}
|
|
80
|
-
link.value = editor.value?.getAttributes('link').href || '';
|
|
81
|
-
linkError.value = false;
|
|
82
|
-
linkOpen.value = true;
|
|
83
|
-
};
|
|
84
|
-
const applyLink = () => {
|
|
85
|
-
if (props.disabled || props.readonly) return;
|
|
86
|
-
const href = link.value.trim();
|
|
87
|
-
if (href && !/^(https?:\/\/|mailto:)/i.test(href)) { linkError.value = true; return; }
|
|
88
|
-
const chain = editor.value?.chain().focus().setTextSelection(linkSelection).extendMarkRange('link');
|
|
89
|
-
if (href) chain?.setLink({ href }).run(); else chain?.unsetLink().run();
|
|
90
|
-
linkOpen.value = false;
|
|
91
|
-
};
|
|
92
|
-
</script>
|
|
93
|
-
<style scoped>
|
|
94
|
-
.neat-rich-text :deep(.tiptap) { min-height: 10rem; padding: .75rem; outline-offset: -2px; overflow-wrap: anywhere; }
|
|
95
|
-
.neat-rich-text :deep(ul) { list-style: disc; padding-left: 1.5rem; }
|
|
96
|
-
.neat-rich-text :deep(ol) { list-style: decimal; padding-left: 1.5rem; }
|
|
97
|
-
.neat-rich-text :deep(blockquote) { border-left: 3px solid currentColor; padding-left: .75rem; }
|
|
98
|
-
.neat-rich-text :deep(a) { text-decoration: underline; }
|
|
99
|
-
</style>
|