@skopon-cool/form-sdk 0.4.9 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +26 -1
  2. package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
  3. package/dist/adapter/formOptions.d.ts +6 -0
  4. package/dist/adapter/formOptions.d.ts.map +1 -0
  5. package/dist/adapter/formSchema.d.ts.map +1 -1
  6. package/dist/adapter/formWeb.d.ts +14 -0
  7. package/dist/adapter/formWeb.d.ts.map +1 -0
  8. package/dist/blocks/registry.d.ts.map +1 -1
  9. package/dist/blocks/web/adapter.d.ts +8 -0
  10. package/dist/blocks/web/adapter.d.ts.map +1 -0
  11. package/dist/blocks/web/catalog.d.ts +9 -0
  12. package/dist/blocks/web/catalog.d.ts.map +1 -0
  13. package/dist/blocks/web/index.d.ts +3 -0
  14. package/dist/blocks/web/index.d.ts.map +1 -0
  15. package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
  16. package/dist/client/authStorage.d.ts +1 -1
  17. package/dist/client/authStorage.d.ts.map +1 -1
  18. package/dist/form-sdk.css +1 -1
  19. package/dist/index.d.ts +2 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1408 -1220
  22. package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -1
  23. package/dist/types/index.d.ts +7 -2
  24. package/dist/types/index.d.ts.map +1 -1
  25. package/package.json +1 -1
  26. package/src/adapter/a2uiAdapter.test.ts +96 -0
  27. package/src/adapter/a2uiAdapter.ts +3 -12
  28. package/src/adapter/formOptions.test.ts +60 -0
  29. package/src/adapter/formOptions.ts +36 -0
  30. package/src/adapter/formSchema.test.ts +68 -0
  31. package/src/adapter/formSchema.ts +19 -6
  32. package/src/adapter/formWeb.test.ts +38 -0
  33. package/src/adapter/formWeb.ts +41 -0
  34. package/src/blocks/registry.ts +6 -1
  35. package/src/blocks/web/adapter.ts +36 -0
  36. package/src/blocks/web/catalog.test.tsx +95 -0
  37. package/src/blocks/web/catalog.tsx +158 -0
  38. package/src/blocks/web/index.ts +11 -0
  39. package/src/catalog/a2uiCustomCatalog.tsx +6 -10
  40. package/src/catalog/invalidStaticOptions.test.tsx +62 -0
  41. package/src/client/authStorage.ts +1 -1
  42. package/src/index.ts +9 -0
  43. package/src/styles/index.css +87 -0
  44. package/src/submit/intersectPayloadBlocksWithForm.ts +14 -4
  45. package/src/submit/submit.test.ts +54 -2
  46. package/src/submit/validateFormSubmit.ts +2 -2
  47. package/src/types/index.ts +12 -2
@@ -1 +1 @@
1
- {"version":3,"file":"intersectPayloadBlocksWithForm.d.ts","sourceRoot":"","sources":["../../src/submit/intersectPayloadBlocksWithForm.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAG3D,MAAM,MAAM,uBAAuB,GAAG,IAAI,CAAC,UAAU,EAAE,OAAO,GAAG,aAAa,GAAG,QAAQ,CAAC,GAAG;IAC3F;;;OAGG;IACH,SAAS,CAAC,EAAE,SAAS,EAAE,CAAA;CACxB,CAAA;AAED,MAAM,WAAW,yBAAyB;IACxC,aAAa,EAAE,SAAS,EAAE,CAAA;IAC1B,WAAW,EAAE,SAAS,EAAE,CAAA;IACxB,gEAAgE;IAChE,YAAY,EAAE,SAAS,EAAE,CAAA;IACzB,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;CACpB;AA+ED,uEAAuE;AACvE,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,GAAG,uBAAuB,GAAG,IAAI,CAmBvF;AAED;;;GAGG;AACH,wBAAgB,8BAA8B,CAC5C,UAAU,EAAE,uBAAuB,EACnC,cAAc,EAAE,UAAU,GAAG,SAAS,GACrC,yBAAyB,CA+B3B;AA+BD,2CAA2C;AAC3C,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,SAAS,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASzF;AAED,sDAAsD;AACtD,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,uBAAuB,GAAG,OAAO,CAIlF;AAED,yEAAyE;AACzE,wBAAgB,0BAA0B,CAAC,UAAU,EAAE,uBAAuB,GAAG,SAAS,EAAE,CAO3F;AAED,4DAA4D;AAC5D,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,uBAAuB,GAAG,MAAM,EAAE,CAIvF;AAED,6DAA6D;AAC7D,wBAAgB,2BAA2B,CACzC,UAAU,EAAE,uBAAuB,GAClC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASzB"}
1
+ {"version":3,"file":"intersectPayloadBlocksWithForm.d.ts","sourceRoot":"","sources":["../../src/submit/intersectPayloadBlocksWithForm.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAG3D,MAAM,MAAM,uBAAuB,GAAG,IAAI,CAAC,UAAU,EAAE,OAAO,GAAG,aAAa,GAAG,QAAQ,CAAC,GAAG;IAC3F;;;OAGG;IACH,SAAS,CAAC,EAAE,SAAS,EAAE,CAAA;CACxB,CAAA;AAED,MAAM,WAAW,yBAAyB;IACxC,aAAa,EAAE,SAAS,EAAE,CAAA;IAC1B,WAAW,EAAE,SAAS,EAAE,CAAA;IACxB,gEAAgE;IAChE,YAAY,EAAE,SAAS,EAAE,CAAA;IACzB,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;CACpB;AA+ED,uEAAuE;AACvE,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,GAAG,uBAAuB,GAAG,IAAI,CAmBvF;AAED;;;GAGG;AACH,wBAAgB,8BAA8B,CAC5C,UAAU,EAAE,uBAAuB,EACnC,cAAc,EAAE,UAAU,GAAG,SAAS,GACrC,yBAAyB,CA+B3B;AA+BD,2CAA2C;AAC3C,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,SAAS,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASzF;AAED,sDAAsD;AACtD,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,uBAAuB,GAAG,OAAO,CAOlF;AAED,yEAAyE;AACzE,wBAAgB,0BAA0B,CAAC,UAAU,EAAE,uBAAuB,GAAG,SAAS,EAAE,CAQ3F;AAED,4DAA4D;AAC5D,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,uBAAuB,GAAG,MAAM,EAAE,CASvF;AAED,6DAA6D;AAC7D,wBAAgB,2BAA2B,CACzC,UAAU,EAAE,uBAAuB,GAClC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASzB"}
@@ -4,7 +4,7 @@
4
4
  export type A2uiBinding<T> = T | {
5
5
  path: string;
6
6
  };
7
- export type A2uiComponentName = 'Column' | 'Row' | 'Text' | 'Image' | 'Video' | 'AudioPlayer' | 'TextField' | 'CheckBox' | 'ChoicePicker' | 'DateTimeInput' | 'FileUpload' | 'SkoponMedia' | 'SkoponSelect' | 'SkoponResumeSelect' | 'SkoponCaseSelect';
7
+ export type A2uiComponentName = 'Column' | 'Row' | 'Text' | 'Image' | 'Video' | 'AudioPlayer' | 'TextField' | 'CheckBox' | 'ChoicePicker' | 'DateTimeInput' | 'FileUpload' | 'SkoponMedia' | 'SkoponWeb' | 'SkoponSelect' | 'SkoponResumeSelect' | 'SkoponCaseSelect';
8
8
  export interface A2uiComponentNode {
9
9
  id: string;
10
10
  component: A2uiComponentName | string;
@@ -23,7 +23,7 @@ export declare const A2UI_PROTOCOL_VERSION: "v0.9";
23
23
  export type FormJsonSchema = Record<string, unknown>;
24
24
  export type FormBlockType = 'heading' | 'paragraph' | 'text' | 'textarea' | 'email' | 'number' | 'select' | 'multiselect' | 'radio' | 'checkbox' | 'toggle' | 'tel' | 'url' | 'datetime' | 'time' | 'file' | 'resume_multiselect' | 'case_multiselect'
25
25
  /** @deprecated 加载时自动迁移为 case_multiselect */
26
- | 'case_singleselect' | 'image' | 'video' | 'audio';
26
+ | 'case_singleselect' | 'image' | 'video' | 'audio' | 'web';
27
27
  export type FormMediaSize = 'huge' | 'large' | 'medium' | 'small' | 'icon';
28
28
  export type FormFilePlaceholderIcon = 'video' | 'audio' | 'image' | 'file' | 'spreadsheet' | 'document';
29
29
  export declare const FORM_MEDIA_SIZES: FormMediaSize[];
@@ -72,6 +72,10 @@ export interface FormBlock {
72
72
  mediaUrl?: string;
73
73
  mediaUrls?: string[];
74
74
  mediaSize?: FormMediaSize;
75
+ /** 网页展示块:iframe 地址,仅支持 HTTP(S) 或同源相对地址 */
76
+ webUrl?: string;
77
+ /** 网页展示块:行内 iframe 高度(px) */
78
+ webHeight?: number;
75
79
  fileAcceptTypes?: string[];
76
80
  fileAcceptExtensions?: string[];
77
81
  filePlaceholderIcon?: FormFilePlaceholderIcon;
@@ -109,6 +113,7 @@ export interface FormSchema {
109
113
  jsonSchema: FormJsonSchema;
110
114
  }
111
115
  export declare function isMediaBlockType(type: FormBlockType): boolean;
116
+ export declare function isWebBlockType(type: FormBlockType): boolean;
112
117
  export declare function isLayoutBlockType(type: FormBlockType): boolean;
113
118
  export declare function isInputBlockType(type: FormBlockType): boolean;
114
119
  export declare function isDisplayBlockType(type: FormBlockType): boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.4.9",
6
+ "version": "0.5.1",
7
7
  "description": "Skopon form rendering SDK (A2UI + form_definition) with submit helpers",
8
8
  "type": "module",
9
9
  "sideEffects": [
@@ -80,6 +80,63 @@ describe('a2uiAdapter blocks <-> surface', () => {
80
80
  expect(img?.mediaSize).toBe('large')
81
81
  })
82
82
 
83
+ it('omits empty static option blocks from nodes and dataModel', () => {
84
+ const doc = blocksToA2ui(
85
+ {
86
+ title: '',
87
+ description: '',
88
+ blocks: [
89
+ { id: 'empty', type: 'select', name: 'empty_choice', label: '空问题', options: [] },
90
+ { id: 'title', type: 'text', name: 'title', label: '标题' },
91
+ ],
92
+ },
93
+ { includeHeader: false },
94
+ )
95
+
96
+ expect(doc.components.find((component) => component.id === 'empty')).toBeUndefined()
97
+ expect(doc.dataModel).not.toHaveProperty('empty_choice')
98
+ expect(doc.components.find((component) => component.id === 'title')).toBeDefined()
99
+ })
100
+
101
+ it('keeps dynamic option blocks when their initial options are empty', () => {
102
+ const doc = blocksToA2ui(
103
+ {
104
+ title: '',
105
+ description: '',
106
+ blocks: [
107
+ { id: 'resume', type: 'resume_multiselect', name: 'resumes', options: [] },
108
+ { id: 'case', type: 'case_multiselect', name: 'cases', options: [] },
109
+ ],
110
+ },
111
+ { includeHeader: false },
112
+ )
113
+
114
+ expect(doc.components.find((component) => component.id === 'resume')).toBeDefined()
115
+ expect(doc.components.find((component) => component.id === 'case')).toBeDefined()
116
+ })
117
+
118
+ it('sanitizes options while converting a direct A2UI document to blocks', () => {
119
+ const back = a2uiToBlocks({
120
+ root: 'root',
121
+ components: [
122
+ { id: 'root', component: 'Column', children: ['choice'] },
123
+ {
124
+ id: 'choice',
125
+ component: 'SkoponSelect',
126
+ label: '选择',
127
+ options: [
128
+ { value: 0, label: 123 },
129
+ { value: {}, label: '异常' },
130
+ { value: 'null', label: '异常' },
131
+ ],
132
+ value: { path: '/choice' },
133
+ },
134
+ ],
135
+ })
136
+
137
+ expect(back.blocks[0]?.options).toEqual([{ value: '0', label: '123' }])
138
+ })
139
+
83
140
  it('blocksToA2ui emits SkoponMedia for image blocks', () => {
84
141
  const doc = blocksToA2ui(makeDefinition(), { includeHeader: false })
85
142
  const imgNode = doc.components.find((c) => c.id === 'b-img')
@@ -88,6 +145,45 @@ describe('a2uiAdapter blocks <-> surface', () => {
88
145
  expect(imgNode?.mediaSize).toBe('large')
89
146
  })
90
147
 
148
+ it('round-trips web blocks through SkoponWeb', () => {
149
+ const doc = blocksToA2ui(
150
+ {
151
+ title: '',
152
+ description: '',
153
+ blocks: [
154
+ {
155
+ id: 'b-web',
156
+ type: 'web',
157
+ label: '官网',
158
+ help: '可扩展查看',
159
+ webUrl: 'https://example.com/page',
160
+ webHeight: 480,
161
+ },
162
+ ],
163
+ },
164
+ { includeHeader: false },
165
+ )
166
+
167
+ const node = doc.components.find((component) => component.id === 'b-web')
168
+ expect(node).toMatchObject({
169
+ component: 'SkoponWeb',
170
+ url: 'https://example.com/page',
171
+ height: 480,
172
+ label: '官网',
173
+ help: '可扩展查看',
174
+ })
175
+
176
+ const back = a2uiToBlocks(doc)
177
+ expect(back.blocks[0]).toMatchObject({
178
+ id: 'b-web',
179
+ type: 'web',
180
+ label: '官网',
181
+ help: '可扩展查看',
182
+ webUrl: 'https://example.com/page',
183
+ webHeight: 480,
184
+ })
185
+ })
186
+
91
187
  it('blocksToA2ui omits hidden text controls but keeps dataModel defaults', () => {
92
188
  const doc = blocksToA2ui(
93
189
  {
@@ -10,6 +10,7 @@ import type {
10
10
  import { generateId } from './id'
11
11
  import { coerceToggleValue, syncFormDefinition } from './formSchema'
12
12
  import { getMediaUrls, normalizeMediaSize } from './formMedia'
13
+ import { hasRenderableFormOptions, normalizeFormOptions } from './formOptions'
13
14
  import { getBlockTypeAdapter, getComponentAdapter } from '../blocks/registry'
14
15
 
15
16
  /**
@@ -325,6 +326,7 @@ export function blocksToA2ui(
325
326
  }
326
327
 
327
328
  for (const block of definition.blocks ?? []) {
329
+ if (!hasRenderableFormOptions(block)) continue
328
330
  const { node, dataKey, dataValue } = blockToComponent(block)
329
331
  // hidden 等无节点块仍可写入 dataModel,保证「不展示仍提交」
330
332
  if (dataKey) dataModel[dataKey] = dataValue
@@ -352,18 +354,7 @@ function indexComponents(doc: A2uiSurfaceDoc): Map<string, A2uiComponentNode> {
352
354
  }
353
355
 
354
356
  function readOptions(node: A2uiComponentNode): FormBlockOption[] {
355
- const raw = node.options
356
- if (!Array.isArray(raw)) return []
357
- return raw
358
- .map((o) => {
359
- if (!o || typeof o !== 'object') return null
360
- const rec = o as Record<string, unknown>
361
- return {
362
- value: String(rec.value ?? rec.label ?? ''),
363
- label: String(rec.label ?? rec.value ?? ''),
364
- } as FormBlockOption
365
- })
366
- .filter((o): o is FormBlockOption => !!o)
357
+ return normalizeFormOptions(node.options)
367
358
  }
368
359
 
369
360
  /** 单个 A2UI 组件节点 -> FormBlock(无法识别返回 null) */
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+ import { describe, expect, it } from 'vitest'
5
+ import { hasRenderableFormOptions, normalizeFormOptions } from './formOptions'
6
+
7
+ describe('normalizeFormOptions', () => {
8
+ it('keeps trimmed strings and finite numbers in source order', () => {
9
+ const options = normalizeFormOptions([
10
+ { value: ' first ', label: ' 第一项 ' },
11
+ { value: 0, label: 123 },
12
+ { value: -1.5, label: '负数' },
13
+ { value: 'undefined', label: '普通文本' },
14
+ { value: 'first', label: '重复值仍保留' },
15
+ ])
16
+
17
+ expect(options).toEqual([
18
+ { value: 'first', label: '第一项' },
19
+ { value: '0', label: '123' },
20
+ { value: '-1.5', label: '负数' },
21
+ { value: 'undefined', label: '普通文本' },
22
+ { value: 'first', label: '重复值仍保留' },
23
+ ])
24
+ })
25
+
26
+ it('drops malformed options and unsupported value or label types without throwing', () => {
27
+ const malformed = [
28
+ null,
29
+ [],
30
+ 'not-an-option',
31
+ { value: '', label: '空值' },
32
+ { value: ' ', label: '空白值' },
33
+ { value: 'null', label: 'null 文本' },
34
+ { value: ' NULL ', label: '大小写 null 文本' },
35
+ { value: null, label: '真正 null' },
36
+ { value: 'ok', label: null },
37
+ { value: true, label: '布尔值' },
38
+ { value: 'ok', label: false },
39
+ { value: {}, label: '对象' },
40
+ { value: 'ok', label: [] },
41
+ { value: Number.NaN, label: 'NaN' },
42
+ { value: Number.POSITIVE_INFINITY, label: 'Infinity' },
43
+ { label: '缺少 value' },
44
+ { value: '缺少 label' },
45
+ ]
46
+
47
+ expect(() => normalizeFormOptions(malformed)).not.toThrow()
48
+ expect(normalizeFormOptions(malformed)).toEqual([])
49
+ })
50
+ })
51
+
52
+ describe('hasRenderableFormOptions', () => {
53
+ it('hides empty static option blocks but keeps dynamic option blocks', () => {
54
+ expect(hasRenderableFormOptions({ type: 'select', options: [] })).toBe(false)
55
+ expect(hasRenderableFormOptions({ type: 'radio', options: undefined })).toBe(false)
56
+ expect(hasRenderableFormOptions({ type: 'resume_multiselect', options: [] })).toBe(true)
57
+ expect(hasRenderableFormOptions({ type: 'case_multiselect', options: [] })).toBe(true)
58
+ expect(hasRenderableFormOptions({ type: 'text', options: undefined })).toBe(true)
59
+ })
60
+ })
@@ -0,0 +1,36 @@
1
+ import type { FormBlock, FormBlockOption } from '../types/index'
2
+ import { isOptionsBlockType } from '../types/index'
3
+
4
+ function normalizeOptionText(raw: unknown): string | undefined {
5
+ if (typeof raw === 'number') {
6
+ return Number.isFinite(raw) ? String(raw) : undefined
7
+ }
8
+ if (typeof raw !== 'string') return undefined
9
+
10
+ const trimmed = raw.trim()
11
+ if (!trimmed || trimmed.toLowerCase() === 'null') return undefined
12
+ return trimmed
13
+ }
14
+
15
+ /** 将不可信 JSON 中的静态选项收敛为 SDK 的字符串选项契约。 */
16
+ export function normalizeFormOptions(raw: unknown): FormBlockOption[] {
17
+ if (!Array.isArray(raw)) return []
18
+
19
+ const options: FormBlockOption[] = []
20
+ for (const item of raw) {
21
+ if (!item || typeof item !== 'object' || Array.isArray(item)) continue
22
+ const option = item as Record<string, unknown>
23
+ const value = normalizeOptionText(option.value)
24
+ const label = normalizeOptionText(option.label)
25
+ if (value === undefined || label === undefined) continue
26
+ options.push({ value, label })
27
+ }
28
+ return options
29
+ }
30
+
31
+ /** 动态简历/案例选择不属于静态 options 块,允许初始选项为空。 */
32
+ export function hasRenderableFormOptions(
33
+ block: Pick<FormBlock, 'type' | 'options'>,
34
+ ): boolean {
35
+ return !isOptionsBlockType(block.type) || normalizeFormOptions(block.options).length > 0
36
+ }
@@ -2,12 +2,20 @@
2
2
  * @vitest-environment node
3
3
  */
4
4
  import { describe, it, expect } from 'vitest'
5
+ import type { FormBlock } from '../types/index'
5
6
  import {
6
7
  coerceToggleValue,
8
+ createEmptyBlock,
7
9
  normalizeFormDefinition,
8
10
  blocksToJsonSchema,
9
11
  } from './formSchema'
10
12
  import { mapFormDefinitionFromDto } from './resolveSurface'
13
+ import {
14
+ isDisplayBlockType,
15
+ isInputBlockType,
16
+ isMediaBlockType,
17
+ isWebBlockType,
18
+ } from '../types/index'
11
19
 
12
20
  describe('coerceToggleValue', () => {
13
21
  it('accepts boolean, string, and numeric truthy/falsy values', () => {
@@ -68,6 +76,35 @@ describe('normalizeFormDefinition toggle default', () => {
68
76
  })
69
77
  })
70
78
 
79
+ describe('normalizeFormDefinition static options', () => {
80
+ it('keeps an empty static question for editing but omits it from jsonSchema', () => {
81
+ const def = normalizeFormDefinition({
82
+ title: '',
83
+ description: '',
84
+ blocks: [
85
+ {
86
+ id: 'empty-choice',
87
+ type: 'select',
88
+ name: 'empty_choice',
89
+ label: '无有效选项',
90
+ required: true,
91
+ options: [
92
+ { value: null, label: '空' },
93
+ { value: 'null', label: 'null' },
94
+ ] as unknown as FormBlock['options'],
95
+ },
96
+ { id: 'title', type: 'text', name: 'title', label: '标题', required: true },
97
+ ],
98
+ jsonSchema: {},
99
+ })
100
+
101
+ expect(def.blocks).toHaveLength(2)
102
+ expect(def.blocks[0]?.options).toEqual([])
103
+ expect(def.jsonSchema.properties).not.toHaveProperty('empty_choice')
104
+ expect(def.jsonSchema.required).toEqual(['title'])
105
+ })
106
+ })
107
+
71
108
  describe('normalizeFormDefinition preserves needLLM and editor fields', () => {
72
109
  it('keeps needLLM, hidden, condition, jsonSchemaDescription after normalize', () => {
73
110
  const def = normalizeFormDefinition({
@@ -128,6 +165,37 @@ describe('normalizeFormDefinition preserves needLLM and editor fields', () => {
128
165
  })
129
166
  })
130
167
 
168
+ describe('web display block', () => {
169
+ it('creates and normalizes web defaults without treating web as input or media', () => {
170
+ const empty = createEmptyBlock('web')
171
+ expect(empty.webUrl).toBe('')
172
+ expect(empty.webHeight).toBe(360)
173
+ expect(isWebBlockType(empty.type)).toBe(true)
174
+ expect(isDisplayBlockType(empty.type)).toBe(true)
175
+ expect(isInputBlockType(empty.type)).toBe(false)
176
+ expect(isMediaBlockType(empty.type)).toBe(false)
177
+
178
+ const normalized = normalizeFormDefinition({
179
+ title: '',
180
+ description: '',
181
+ blocks: [
182
+ {
183
+ id: 'web-1',
184
+ type: 'web',
185
+ name: 'must_not_submit',
186
+ label: '官网',
187
+ webUrl: ' javascript:alert(1) ',
188
+ webHeight: 100,
189
+ },
190
+ ],
191
+ jsonSchema: {},
192
+ })
193
+ expect(normalized.blocks[0]?.webUrl).toBe('javascript:alert(1)')
194
+ expect(normalized.blocks[0]?.webHeight).toBe(200)
195
+ expect(normalized.jsonSchema).toMatchObject({ properties: {}, required: [] })
196
+ })
197
+ })
198
+
131
199
  describe('mapFormDefinitionFromDto jsonSchema', () => {
132
200
  it('regenerates jsonSchema from DTO blocks', () => {
133
201
  const def = mapFormDefinitionFromDto({
@@ -6,7 +6,12 @@ import type {
6
6
  FormJsonSchema,
7
7
  FormSchema,
8
8
  } from '../types/index'
9
- import { isInputBlockType, isMediaBlockType, isOptionsBlockType } from '../types/index'
9
+ import {
10
+ isInputBlockType,
11
+ isMediaBlockType,
12
+ isOptionsBlockType,
13
+ isWebBlockType,
14
+ } from '../types/index'
10
15
  import { generateId } from './id'
11
16
  import {
12
17
  filterFileAcceptExtensionsForTypes,
@@ -17,6 +22,8 @@ import {
17
22
  normalizeMediaSize,
18
23
  normalizeMediaUrls,
19
24
  } from './formMedia'
25
+ import { FORM_WEB_DEFAULT_HEIGHT, normalizeWebHeight } from './formWeb'
26
+ import { hasRenderableFormOptions, normalizeFormOptions } from './formOptions'
20
27
 
21
28
  const DEFAULT_MEDIA_URL_MAX = 10
22
29
 
@@ -162,6 +169,8 @@ function normalizeBlock(raw: FormBlock): FormBlock {
162
169
  ? normalizeMediaUrls(migrated, DEFAULT_MEDIA_URL_MAX)
163
170
  : undefined,
164
171
  mediaSize: isMediaBlockType(type) ? normalizeMediaSize(migrated.mediaSize) : undefined,
172
+ webUrl: isWebBlockType(type) ? migrated.webUrl?.trim() ?? '' : undefined,
173
+ webHeight: isWebBlockType(type) ? normalizeWebHeight(migrated.webHeight) : undefined,
165
174
  fileAcceptTypes,
166
175
  fileAcceptExtensions: filterFileAcceptExtensionsForTypes(
167
176
  fileAcceptTypes,
@@ -175,10 +184,7 @@ function normalizeBlock(raw: FormBlock): FormBlock {
175
184
  hidden: type === 'text' ? migrated.hidden === true : undefined,
176
185
  needLLM: migrated.needLLM === true ? true : undefined,
177
186
  options: Array.isArray(migrated.options)
178
- ? migrated.options.map((opt, index) => ({
179
- value: opt.value?.trim() || `option_${index + 1}`,
180
- label: opt.label?.trim() || `选项 ${index + 1}`,
181
- }))
187
+ ? normalizeFormOptions(migrated.options)
182
188
  : undefined,
183
189
  condition: migrated.condition?.fieldName
184
190
  ? {
@@ -351,7 +357,7 @@ export function blocksToJsonSchema(
351
357
 
352
358
  for (const block of blocks) {
353
359
  const name = block.name?.trim()
354
- if (!name || !isInputBlockType(block.type)) continue
360
+ if (!name || !isInputBlockType(block.type) || !hasRenderableFormOptions(block)) continue
355
361
 
356
362
  const property = blockToJsonSchemaProperty(block)
357
363
  if (!property) continue
@@ -425,6 +431,13 @@ export function createEmptyBlock(type: FormBlockType): FormBlock {
425
431
  block.mediaSize = 'medium'
426
432
  }
427
433
 
434
+ if (isWebBlockType(type)) {
435
+ block.label = ''
436
+ block.help = ''
437
+ block.webUrl = ''
438
+ block.webHeight = FORM_WEB_DEFAULT_HEIGHT
439
+ }
440
+
428
441
  if (isOptionsBlockType(type)) {
429
442
  block.options = [
430
443
  { value: 'option_1', label: '选项 1' },
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+ import { describe, expect, it } from 'vitest'
5
+ import {
6
+ FORM_WEB_DEFAULT_HEIGHT,
7
+ FORM_WEB_MAX_HEIGHT,
8
+ FORM_WEB_MIN_HEIGHT,
9
+ isSupportedWebUrl,
10
+ normalizeWebHeight,
11
+ } from './formWeb'
12
+
13
+ describe('formWeb', () => {
14
+ it('normalizes iframe height to the supported integer range', () => {
15
+ expect(normalizeWebHeight(undefined)).toBe(FORM_WEB_DEFAULT_HEIGHT)
16
+ expect(normalizeWebHeight('bad')).toBe(FORM_WEB_DEFAULT_HEIGHT)
17
+ expect(normalizeWebHeight(199)).toBe(FORM_WEB_MIN_HEIGHT)
18
+ expect(normalizeWebHeight(480.9)).toBe(480)
19
+ expect(normalizeWebHeight(1201)).toBe(FORM_WEB_MAX_HEIGHT)
20
+ })
21
+
22
+ it('accepts HTTP(S) and explicit same-origin relative paths', () => {
23
+ expect(isSupportedWebUrl('https://example.com/page')).toBe(true)
24
+ expect(isSupportedWebUrl('http://localhost:3000/preview')).toBe(true)
25
+ expect(isSupportedWebUrl('/preview')).toBe(true)
26
+ expect(isSupportedWebUrl('./preview')).toBe(true)
27
+ expect(isSupportedWebUrl('../preview')).toBe(true)
28
+ })
29
+
30
+ it('rejects empty, dangerous, protocol-relative, and ambiguous paths', () => {
31
+ expect(isSupportedWebUrl('')).toBe(false)
32
+ expect(isSupportedWebUrl('javascript:alert(1)')).toBe(false)
33
+ expect(isSupportedWebUrl('data:text/html,test')).toBe(false)
34
+ expect(isSupportedWebUrl('//example.com/page')).toBe(false)
35
+ expect(isSupportedWebUrl('preview.html')).toBe(false)
36
+ expect(isSupportedWebUrl('https://')).toBe(false)
37
+ })
38
+ })
@@ -0,0 +1,41 @@
1
+ export const FORM_WEB_DEFAULT_HEIGHT = 360
2
+ export const FORM_WEB_MIN_HEIGHT = 200
3
+ export const FORM_WEB_MAX_HEIGHT = 1200
4
+
5
+ /**
6
+ * 网页展示高度统一持久化为整数像素。
7
+ * 空值或非数字回退默认值,越界值限制到允许范围。
8
+ */
9
+ export function normalizeWebHeight(raw: unknown): number {
10
+ if (raw === undefined || raw === null || raw === '') {
11
+ return FORM_WEB_DEFAULT_HEIGHT
12
+ }
13
+ const numeric = typeof raw === 'number' ? raw : Number(raw)
14
+ if (!Number.isFinite(numeric)) return FORM_WEB_DEFAULT_HEIGHT
15
+ return Math.min(
16
+ FORM_WEB_MAX_HEIGHT,
17
+ Math.max(FORM_WEB_MIN_HEIGHT, Math.floor(numeric)),
18
+ )
19
+ }
20
+
21
+ /**
22
+ * iframe 仅允许 HTTP(S) 绝对地址,或明确的同源相对路径。
23
+ * 不接受 protocol-relative、data、javascript 等地址。
24
+ */
25
+ export function isSupportedWebUrl(raw: unknown): raw is string {
26
+ if (typeof raw !== 'string') return false
27
+ const value = raw.trim()
28
+ if (!value) return false
29
+
30
+ if (/^\/(?!\/)/.test(value) || /^\.\.?\//.test(value)) {
31
+ return true
32
+ }
33
+ if (!/^https?:\/\//i.test(value)) return false
34
+
35
+ try {
36
+ const url = new URL(value)
37
+ return (url.protocol === 'http:' || url.protocol === 'https:') && Boolean(url.hostname)
38
+ } catch {
39
+ return false
40
+ }
41
+ }
@@ -2,8 +2,13 @@ import type { FormBlockType } from '../types/index'
2
2
  import type { BlockAdapterPlugin } from './types'
3
3
  import { caseMultiselectAdapter } from './case_multiselect'
4
4
  import { resumeMultiselectAdapter } from './resume_multiselect'
5
+ import { webAdapter } from './web'
5
6
 
6
- const adapters: BlockAdapterPlugin[] = [resumeMultiselectAdapter, caseMultiselectAdapter]
7
+ const adapters: BlockAdapterPlugin[] = [
8
+ resumeMultiselectAdapter,
9
+ caseMultiselectAdapter,
10
+ webAdapter,
11
+ ]
7
12
 
8
13
  const adapterByType = new Map<FormBlockType, BlockAdapterPlugin>(
9
14
  adapters.map((adapter) => [adapter.type, adapter]),
@@ -0,0 +1,36 @@
1
+ import type { A2uiComponentNode, FormBlock } from '../../types/index'
2
+ import { normalizeWebHeight } from '../../adapter/formWeb'
3
+ import type { BlockAdapterContext, BlockToComponentResult } from '../types'
4
+
5
+ export function webToComponent(
6
+ block: FormBlock,
7
+ ctx: BlockAdapterContext,
8
+ ): BlockToComponentResult {
9
+ return {
10
+ node: {
11
+ id: ctx.id,
12
+ component: 'SkoponWeb',
13
+ url: block.webUrl?.trim() ?? '',
14
+ height: normalizeWebHeight(block.webHeight),
15
+ label: ctx.label,
16
+ help: block.help ?? '',
17
+ },
18
+ }
19
+ }
20
+
21
+ export function webFromComponent(
22
+ node: A2uiComponentNode,
23
+ base: (type: 'web') => FormBlock,
24
+ helpers: {
25
+ asLiteral: (value: unknown) => string | undefined
26
+ nameFromValue: (value: unknown) => string
27
+ },
28
+ ): FormBlock {
29
+ const help = helpers.asLiteral(node.help)?.trim()
30
+ return {
31
+ ...base('web'),
32
+ webUrl: helpers.asLiteral(node.url)?.trim() ?? '',
33
+ webHeight: normalizeWebHeight(node.height),
34
+ ...(help ? { help } : {}),
35
+ }
36
+ }