@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.
- package/README.md +26 -1
- package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
- package/dist/adapter/formOptions.d.ts +6 -0
- package/dist/adapter/formOptions.d.ts.map +1 -0
- package/dist/adapter/formSchema.d.ts.map +1 -1
- package/dist/adapter/formWeb.d.ts +14 -0
- package/dist/adapter/formWeb.d.ts.map +1 -0
- package/dist/blocks/registry.d.ts.map +1 -1
- package/dist/blocks/web/adapter.d.ts +8 -0
- package/dist/blocks/web/adapter.d.ts.map +1 -0
- package/dist/blocks/web/catalog.d.ts +9 -0
- package/dist/blocks/web/catalog.d.ts.map +1 -0
- package/dist/blocks/web/index.d.ts +3 -0
- package/dist/blocks/web/index.d.ts.map +1 -0
- package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
- package/dist/client/authStorage.d.ts +1 -1
- package/dist/client/authStorage.d.ts.map +1 -1
- package/dist/form-sdk.css +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1408 -1220
- package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -1
- package/dist/types/index.d.ts +7 -2
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapter/a2uiAdapter.test.ts +96 -0
- package/src/adapter/a2uiAdapter.ts +3 -12
- package/src/adapter/formOptions.test.ts +60 -0
- package/src/adapter/formOptions.ts +36 -0
- package/src/adapter/formSchema.test.ts +68 -0
- package/src/adapter/formSchema.ts +19 -6
- package/src/adapter/formWeb.test.ts +38 -0
- package/src/adapter/formWeb.ts +41 -0
- package/src/blocks/registry.ts +6 -1
- package/src/blocks/web/adapter.ts +36 -0
- package/src/blocks/web/catalog.test.tsx +95 -0
- package/src/blocks/web/catalog.tsx +158 -0
- package/src/blocks/web/index.ts +11 -0
- package/src/catalog/a2uiCustomCatalog.tsx +6 -10
- package/src/catalog/invalidStaticOptions.test.tsx +62 -0
- package/src/client/authStorage.ts +1 -1
- package/src/index.ts +9 -0
- package/src/styles/index.css +87 -0
- package/src/submit/intersectPayloadBlocksWithForm.ts +14 -4
- package/src/submit/submit.test.ts +54 -2
- package/src/submit/validateFormSubmit.ts +2 -2
- package/src/types/index.ts +12 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"intersectPayloadBlocksWithForm.d.ts","sourceRoot":"","sources":["../../src/submit/intersectPayloadBlocksWithForm.ts"],"names":[],"mappings":"
|
|
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"}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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":"AAAA;;GAEG;AACH,MAAM,MAAM,WAAW,CAAC,CAAC,IAAI,CAAC,GAAG;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAA;AAEjD,MAAM,MAAM,iBAAiB,GACzB,QAAQ,GACR,KAAK,GACL,MAAM,GACN,OAAO,GACP,OAAO,GACP,aAAa,GACb,WAAW,GACX,UAAU,GACV,cAAc,GACd,eAAe,GACf,YAAY,GACZ,aAAa,GACb,cAAc,GACd,oBAAoB,GACpB,kBAAkB,CAAA;AAEtB,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,MAAM,CAAA;IACV,SAAS,EAAE,iBAAiB,GAAG,MAAM,CAAA;IACrC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;IACnB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;CACxB;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAA;IACZ,UAAU,EAAE,iBAAiB,EAAE,CAAA;IAC/B,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACnC,iBAAiB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;QAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAA;CAC1E;AAED,eAAO,MAAM,qBAAqB,EAAG,MAAe,CAAA;AAEpD,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;AAEpD,MAAM,MAAM,aAAa,GACrB,SAAS,GACT,WAAW,GACX,MAAM,GACN,UAAU,GACV,OAAO,GACP,QAAQ,GACR,QAAQ,GACR,aAAa,GACb,OAAO,GACP,UAAU,GACV,QAAQ,GACR,KAAK,GACL,KAAK,GACL,UAAU,GACV,MAAM,GACN,MAAM,GACN,oBAAoB,GACpB,kBAAkB;AACpB,4CAA4C;GAC1C,mBAAmB,GACnB,OAAO,GACP,OAAO,GACP,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
|
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
|
+
}
|
package/src/blocks/registry.ts
CHANGED
|
@@ -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[] = [
|
|
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
|
+
}
|