@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 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
- 例如将 `{ field: 'name', slot: 'custom' }` 改为 `{ field: 'name', model: 'Custom' }`:
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: true })` |
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)、RichTextInput;TimePicker 使用浏览器原生时间输入并校验范围与步长。
64
- - Gallery 提供通用图片选择与搜索事件,不包含旧业务素材库的接口、分页和后台管理。RichTextInput 默认支持文本格式、列表、链接和撤销重做,未配置图片上传或表格扩展。
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.0.0",
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.0.0",
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
- // 监听并验证modelValue的有效性
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
- return;
295
- }
296
-
297
- // 如果有值但不在有效选项中,清空该值
298
- if (value != null && value !== '' && options?.length > 0) {
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
- <slot v-if="col.model[0]" :name="col.model[0]" v-bind="fieldScope(col)"></slot>
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, readonly, watchEffect } from 'vue';
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 { FormArrayContext, FormContext, FormError, FormValidateResult, LinkageTask, FormRule } from './FormRender.vue';
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 resolvePath = (data: any, paths: string = '') => {
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] = {}; recordInitial(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 = resolvePath(row, item.paths);
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
- const call = (setup: any, item: any, used?: () => void) => {
409
+ // 联动函数拿到的是可写的数据视图:读取直接访问表单数据,Vue 只按实际读到的字段收集依赖;
410
+ // 写入只落在视图自己的覆盖层,不改动表单数据(与 4.x 传副本的语义一致)。
411
+ const call = (setup: any, item: any, onRead?: () => void) => {
344
412
  const ctx = context();
345
- const track = (data: any) => new Proxy(readonly(data), {
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
- let usesData = false;
437
+ // 同步读取由 computed 精确追踪;异步函数在 await 之后才读取的数据无法追踪,
438
+ // 一旦出现就退回到跟随根数据版本重算。
439
+ let lateReads = false;
374
440
  const version = shallowRef(0);
375
- unwatchs.push(watch(() => context().root, () => {
376
- if (usesData) version.value++;
377
- }, { deep: true, flush: 'sync' }));
441
+ unwatchs.push(watch(shared.dataVersion, () => {
442
+ if (lateReads) version.value++;
443
+ }));
378
444
  // 初次解析与监听共用计算结果,避免异步函数被重复调用。
379
445
  const evaluated = computed(() => {
380
446
  version.value;
381
- return call(fn, reactive(item), () => { usesData = true; });
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, value, setup } = task;
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[key] = key === 'model' ? normalizeModel(result) : result;
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
- nextTick(() => { if (isCurrent()) apply(value); });
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
- unwatchs.push(watchEffect(() => {
470
- deepcopy(context().root);
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(props.data);
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) => [props.idx, col.field || `g${idx}`].filter((v: any) => v !== '').join('-');
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
- const linkage = useLinkage({ target: (idx) => state.form[idx], context });
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-${props.idx !== '' ? props.idx + '-' : ''}${!item.field && isGroup(item) ? `group-${idx}` : `field-${item.field || `node-${idx}`}`}`;
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
- const validateField = (col: any, ctx: FormContext, force = false): Promise<any> => {
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
- // 校验所有已显示的字段(包含分组和数组),warn 类型只作为提醒,不影响 valid
974
- const validate = async ({ scroll = false }: { scroll?: boolean } = {}): Promise<FormValidateResult> => {
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
- shared.state.validated = true;
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
- const res = await validateField(col, ctx, true);
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
- defineExpose({ validate, reset, clearValidate, scrollToField });
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" :aria-describedby="col._error && mode !== 'view' ? `${col._fid}-error` : undefined">
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="col.model[1]" v-on="events"><FieldExtra :value="displayOf(col, ctx)" /></button>
5
- <div v-else-if="col.model[0] === 'Html'" v-bind="col.model[1]" v-on="events" v-html="displayOf(col, ctx)"></div>
6
- <div v-else-if="col.model[0] === 'Text'" v-bind="col.model[1]" v-on="events"><FieldExtra :value="displayOf(col, ctx)" /></div>
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="col.model[1] || {}"
16
- :rules="[]"
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
- value?: any | FormValue;
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
- validate: (options?: { scroll?: boolean }) => Promise<FormValidateResult>;
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 时才按 HTML 渲染
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
- // 注册自定义控件,比如业务里的图片库、富文本;注册后 model 直接写名字即可
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
- import { computed } from 'vue';
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
- const update = (value: any) => { props.col._touched = true; writeValue(props.col, value, props.ctx); };
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>