@skopon-cool/form-sdk 0.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/README.md +82 -0
- package/dist/adapter/a2uiAdapter.d.ts +21 -0
- package/dist/adapter/a2uiAdapter.d.ts.map +1 -0
- package/dist/adapter/extractSurfaceValues.d.ts +8 -0
- package/dist/adapter/extractSurfaceValues.d.ts.map +1 -0
- package/dist/adapter/formFileAccept.d.ts +17 -0
- package/dist/adapter/formFileAccept.d.ts.map +1 -0
- package/dist/adapter/formFilePlaceholderIcon.d.ts +5 -0
- package/dist/adapter/formFilePlaceholderIcon.d.ts.map +1 -0
- package/dist/adapter/formMedia.d.ts +7 -0
- package/dist/adapter/formMedia.d.ts.map +1 -0
- package/dist/adapter/formSchema.d.ts +6 -0
- package/dist/adapter/formSchema.d.ts.map +1 -0
- package/dist/adapter/id.d.ts +4 -0
- package/dist/adapter/id.d.ts.map +1 -0
- package/dist/adapter/resolveSurface.d.ts +6 -0
- package/dist/adapter/resolveSurface.d.ts.map +1 -0
- package/dist/catalog/a2uiCustomCatalog.d.ts +10 -0
- package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -0
- package/dist/catalog/a2uiPreviewContext.d.ts +11 -0
- package/dist/catalog/a2uiPreviewContext.d.ts.map +1 -0
- package/dist/catalog/useSkoponBoundField.d.ts +10 -0
- package/dist/catalog/useSkoponBoundField.d.ts.map +1 -0
- package/dist/client/formClient.d.ts +22 -0
- package/dist/client/formClient.d.ts.map +1 -0
- package/dist/components/AskUserFormCard.d.ts +13 -0
- package/dist/components/AskUserFormCard.d.ts.map +1 -0
- package/dist/components/CurlSubmitBlock.d.ts +10 -0
- package/dist/components/CurlSubmitBlock.d.ts.map +1 -0
- package/dist/components/SkoponA2uiStreamRenderer.d.ts +11 -0
- package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -0
- package/dist/components/SkoponFormRenderer.d.ts +16 -0
- package/dist/components/SkoponFormRenderer.d.ts.map +1 -0
- package/dist/form-sdk.css +1 -0
- package/dist/icons/FilePlaceholderIcon.d.ts +10 -0
- package/dist/icons/FilePlaceholderIcon.d.ts.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1332 -0
- package/dist/submit/buildCurlStatement.d.ts +2 -0
- package/dist/submit/buildCurlStatement.d.ts.map +1 -0
- package/dist/submit/intersectPayloadWithForm.d.ts +17 -0
- package/dist/submit/intersectPayloadWithForm.d.ts.map +1 -0
- package/dist/submit/submitFormJson.d.ts +12 -0
- package/dist/submit/submitFormJson.d.ts.map +1 -0
- package/dist/types/index.d.ts +76 -0
- package/dist/types/index.d.ts.map +1 -0
- package/package.json +53 -0
- package/src/adapter/a2uiAdapter.test.ts +150 -0
- package/src/adapter/a2uiAdapter.ts +490 -0
- package/src/adapter/extractSurfaceValues.ts +25 -0
- package/src/adapter/formFileAccept.ts +198 -0
- package/src/adapter/formFilePlaceholderIcon.ts +33 -0
- package/src/adapter/formMedia.ts +50 -0
- package/src/adapter/formSchema.ts +139 -0
- package/src/adapter/id.ts +24 -0
- package/src/adapter/resolveSurface.ts +66 -0
- package/src/catalog/a2uiCustomCatalog.tsx +548 -0
- package/src/catalog/a2uiPreviewContext.tsx +26 -0
- package/src/catalog/useSkoponBoundField.ts +57 -0
- package/src/client/formClient.ts +72 -0
- package/src/components/AskUserFormCard.tsx +155 -0
- package/src/components/CurlSubmitBlock.tsx +60 -0
- package/src/components/SkoponA2uiStreamRenderer.tsx +70 -0
- package/src/components/SkoponFormRenderer.tsx +100 -0
- package/src/icons/FilePlaceholderIcon.tsx +40 -0
- package/src/index.ts +67 -0
- package/src/styles/a2ui-preview.css +345 -0
- package/src/styles/index.css +190 -0
- package/src/submit/buildCurlStatement.ts +13 -0
- package/src/submit/intersectPayloadWithForm.ts +54 -0
- package/src/submit/submit.test.ts +63 -0
- package/src/submit/submitFormJson.ts +50 -0
- package/src/types/index.ts +139 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
A2uiComponentNode,
|
|
3
|
+
A2uiSurfaceDoc,
|
|
4
|
+
FormBlock,
|
|
5
|
+
FormBlockOption,
|
|
6
|
+
FormBlockType,
|
|
7
|
+
FormMediaSize,
|
|
8
|
+
FormSchema,
|
|
9
|
+
} from '../types/index'
|
|
10
|
+
import { generateId } from './id'
|
|
11
|
+
import { getMediaUrls, normalizeMediaSize } from './formMedia'
|
|
12
|
+
import { syncFormDefinition } from './formSchema'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A2UI surface <-> FormBlock 双向适配器。
|
|
16
|
+
*
|
|
17
|
+
* - 内部编辑模型仍是扁平的 FormBlock[](编辑器、JSON Schema、校验都基于它)。
|
|
18
|
+
* - 对外/存储/AI 交换使用 A2UI v0.9 surface(basicCatalog + skopon 自定义 catalog)。
|
|
19
|
+
* - load/save 时在两者之间做无损(尽量)转换。
|
|
20
|
+
*
|
|
21
|
+
* 已知有损点(可接受,blocks 仍是存储 source-of-truth):
|
|
22
|
+
* email/tel/url 回读时统一退化为 text(除非可由 validationRegexp 推断)。
|
|
23
|
+
* A2UI ChoicePicker 无原生下拉;select/multiselect 使用 SkoponSelect 扩展组件。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const ROOT_ID = 'root'
|
|
27
|
+
const TITLE_ID = '__title__'
|
|
28
|
+
const DESC_ID = '__desc__'
|
|
29
|
+
const EMAIL_REGEXP = '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$'
|
|
30
|
+
|
|
31
|
+
function pathFor(name: string): string {
|
|
32
|
+
return `/${name}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function nameFromValue(value: unknown): string {
|
|
36
|
+
if (value && typeof value === 'object' && 'path' in (value as Record<string, unknown>)) {
|
|
37
|
+
const path = String((value as { path: unknown }).path ?? '')
|
|
38
|
+
return path.replace(/^\//, '')
|
|
39
|
+
}
|
|
40
|
+
return ''
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function asLiteral(value: unknown): string | undefined {
|
|
44
|
+
if (typeof value === 'string') return value
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
function mediaBlockNode(block: FormBlock): A2uiComponentNode {
|
|
50
|
+
const id = block.id || generateId('a2-')
|
|
51
|
+
const mediaType =
|
|
52
|
+
block.type === 'video' ? 'video' : block.type === 'audio' ? 'audio' : 'image'
|
|
53
|
+
return {
|
|
54
|
+
id,
|
|
55
|
+
component: 'SkoponMedia',
|
|
56
|
+
mediaType,
|
|
57
|
+
urls: getMediaUrls(block),
|
|
58
|
+
mediaSize: normalizeMediaSize(block.mediaSize),
|
|
59
|
+
label: block.label ?? '',
|
|
60
|
+
help: block.help ?? '',
|
|
61
|
+
placeholder: block.placeholder ?? '',
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readMediaUrls(node: A2uiComponentNode): string[] {
|
|
66
|
+
if (Array.isArray(node.urls)) {
|
|
67
|
+
return node.urls.map((u) => String(u).trim()).filter(Boolean)
|
|
68
|
+
}
|
|
69
|
+
const single = asLiteral(node.url)
|
|
70
|
+
return single?.trim() ? [single.trim()] : []
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readMediaBlock(
|
|
74
|
+
node: A2uiComponentNode,
|
|
75
|
+
type: 'image' | 'video' | 'audio',
|
|
76
|
+
fallbackLabel: string,
|
|
77
|
+
): FormBlock {
|
|
78
|
+
const id = typeof node.id === 'string' ? node.id : generateId('block-')
|
|
79
|
+
const urls = readMediaUrls(node)
|
|
80
|
+
const label = asLiteral(node.label) ?? asLiteral(node.description) ?? fallbackLabel
|
|
81
|
+
const help = asLiteral(node.help)
|
|
82
|
+
const placeholder = asLiteral(node.placeholder)
|
|
83
|
+
const mediaSize = normalizeMediaSize(node.mediaSize as FormMediaSize | undefined)
|
|
84
|
+
return {
|
|
85
|
+
id,
|
|
86
|
+
type,
|
|
87
|
+
label,
|
|
88
|
+
...(help ? { help } : {}),
|
|
89
|
+
...(placeholder ? { placeholder } : {}),
|
|
90
|
+
mediaSize,
|
|
91
|
+
mediaUrls: urls.length > 0 ? urls : [''],
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function defaultValueForBlock(block: FormBlock): unknown {
|
|
96
|
+
switch (block.type) {
|
|
97
|
+
case 'toggle':
|
|
98
|
+
return typeof block.defaultValue === 'boolean' ? block.defaultValue : false
|
|
99
|
+
case 'multiselect':
|
|
100
|
+
case 'checkbox':
|
|
101
|
+
case 'select':
|
|
102
|
+
case 'radio':
|
|
103
|
+
if (Array.isArray(block.defaultValue)) return block.defaultValue
|
|
104
|
+
if (typeof block.defaultValue === 'string' && block.defaultValue) return [block.defaultValue]
|
|
105
|
+
return []
|
|
106
|
+
default:
|
|
107
|
+
if (typeof block.defaultValue === 'string') return block.defaultValue
|
|
108
|
+
return ''
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 单个 FormBlock -> A2UI 组件节点(input 类附带 dataModel 默认值) */
|
|
113
|
+
function blockToComponent(block: FormBlock): {
|
|
114
|
+
node: A2uiComponentNode | null
|
|
115
|
+
dataKey?: string
|
|
116
|
+
dataValue?: unknown
|
|
117
|
+
} {
|
|
118
|
+
const id = block.id || generateId('a2-')
|
|
119
|
+
const label = block.label ?? ''
|
|
120
|
+
const name = block.name?.trim() || ''
|
|
121
|
+
const path = name ? pathFor(name) : undefined
|
|
122
|
+
const options = (block.options ?? []).map((o) => ({ label: o.label, value: o.value }))
|
|
123
|
+
|
|
124
|
+
const withData = (node: A2uiComponentNode): {
|
|
125
|
+
node: A2uiComponentNode
|
|
126
|
+
dataKey?: string
|
|
127
|
+
dataValue?: unknown
|
|
128
|
+
} => (name ? { node, dataKey: name, dataValue: defaultValueForBlock(block) } : { node })
|
|
129
|
+
|
|
130
|
+
switch (block.type) {
|
|
131
|
+
case 'heading':
|
|
132
|
+
return { node: { id, component: 'Text', text: label, variant: 'h3' } }
|
|
133
|
+
case 'paragraph':
|
|
134
|
+
return { node: { id, component: 'Text', text: label, variant: 'body' } }
|
|
135
|
+
|
|
136
|
+
case 'text':
|
|
137
|
+
case 'tel':
|
|
138
|
+
case 'url':
|
|
139
|
+
return withData({ id, component: 'TextField', label, variant: 'shortText', value: { path } })
|
|
140
|
+
case 'email':
|
|
141
|
+
return withData({
|
|
142
|
+
id,
|
|
143
|
+
component: 'TextField',
|
|
144
|
+
label,
|
|
145
|
+
variant: 'shortText',
|
|
146
|
+
validationRegexp: EMAIL_REGEXP,
|
|
147
|
+
value: { path },
|
|
148
|
+
})
|
|
149
|
+
case 'textarea':
|
|
150
|
+
return withData({ id, component: 'TextField', label, variant: 'longText', value: { path } })
|
|
151
|
+
case 'number':
|
|
152
|
+
return withData({ id, component: 'TextField', label, variant: 'number', value: { path } })
|
|
153
|
+
|
|
154
|
+
case 'select':
|
|
155
|
+
return withData({
|
|
156
|
+
id,
|
|
157
|
+
component: 'SkoponSelect',
|
|
158
|
+
label,
|
|
159
|
+
mode: 'single',
|
|
160
|
+
placeholder: block.placeholder ?? '',
|
|
161
|
+
options,
|
|
162
|
+
value: { path },
|
|
163
|
+
})
|
|
164
|
+
case 'radio':
|
|
165
|
+
return withData({
|
|
166
|
+
id,
|
|
167
|
+
component: 'ChoicePicker',
|
|
168
|
+
label,
|
|
169
|
+
variant: 'mutuallyExclusive',
|
|
170
|
+
skoponPickerType: 'radio',
|
|
171
|
+
options,
|
|
172
|
+
value: { path },
|
|
173
|
+
})
|
|
174
|
+
case 'multiselect':
|
|
175
|
+
return withData({
|
|
176
|
+
id,
|
|
177
|
+
component: 'SkoponSelect',
|
|
178
|
+
label,
|
|
179
|
+
mode: 'multiple',
|
|
180
|
+
placeholder: block.placeholder ?? '',
|
|
181
|
+
options,
|
|
182
|
+
value: { path },
|
|
183
|
+
})
|
|
184
|
+
case 'checkbox':
|
|
185
|
+
return withData({
|
|
186
|
+
id,
|
|
187
|
+
component: 'ChoicePicker',
|
|
188
|
+
label,
|
|
189
|
+
variant: 'multipleSelection',
|
|
190
|
+
skoponPickerType: 'checkbox',
|
|
191
|
+
options,
|
|
192
|
+
value: { path },
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
case 'toggle':
|
|
196
|
+
return withData({ id, component: 'CheckBox', label, value: { path } })
|
|
197
|
+
|
|
198
|
+
case 'datetime':
|
|
199
|
+
return withData({
|
|
200
|
+
id,
|
|
201
|
+
component: 'DateTimeInput',
|
|
202
|
+
label,
|
|
203
|
+
enableDate: true,
|
|
204
|
+
enableTime: true,
|
|
205
|
+
placeholder: block.placeholder ?? '',
|
|
206
|
+
value: { path },
|
|
207
|
+
})
|
|
208
|
+
case 'time':
|
|
209
|
+
return withData({
|
|
210
|
+
id,
|
|
211
|
+
component: 'DateTimeInput',
|
|
212
|
+
label,
|
|
213
|
+
enableTime: true,
|
|
214
|
+
placeholder: block.placeholder ?? '',
|
|
215
|
+
value: { path },
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
case 'image':
|
|
219
|
+
case 'video':
|
|
220
|
+
case 'audio':
|
|
221
|
+
return { node: mediaBlockNode(block) }
|
|
222
|
+
|
|
223
|
+
case 'file':
|
|
224
|
+
return withData({
|
|
225
|
+
id,
|
|
226
|
+
component: 'FileUpload',
|
|
227
|
+
label,
|
|
228
|
+
accept: (block.fileAcceptExtensions ?? []).join(','),
|
|
229
|
+
fileAcceptTypes: block.fileAcceptTypes ?? [],
|
|
230
|
+
filePlaceholderIcon: block.filePlaceholderIcon ?? 'document',
|
|
231
|
+
minCount: block.fileMinCount ?? 0,
|
|
232
|
+
maxCount: block.fileMaxCount ?? 1,
|
|
233
|
+
value: { path },
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
default:
|
|
237
|
+
return { node: null }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface BlocksToA2uiOptions {
|
|
242
|
+
styleId?: string | null
|
|
243
|
+
/** 是否写入 title/description 为 Text 组件;预览区由 FormTitleField 展示时可设为 false */
|
|
244
|
+
includeHeader?: boolean
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** surface 是否仅含空 root Column(无可视内容) */
|
|
248
|
+
export function isA2uiSurfaceEmpty(doc: A2uiSurfaceDoc | null | undefined): boolean {
|
|
249
|
+
if (!doc || !Array.isArray(doc.components) || doc.components.length === 0) return true
|
|
250
|
+
const root = doc.components.find((c) => c.id === ROOT_ID)
|
|
251
|
+
if (!root) return doc.components.length === 0
|
|
252
|
+
const children = root.children
|
|
253
|
+
if (!Array.isArray(children) || children.length === 0) return true
|
|
254
|
+
return false
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** FormBlock[] -> A2UI surface 文档 */
|
|
258
|
+
export function blocksToA2ui(
|
|
259
|
+
definition: Pick<FormSchema, 'title' | 'description' | 'blocks'>,
|
|
260
|
+
options?: BlocksToA2uiOptions,
|
|
261
|
+
): A2uiSurfaceDoc {
|
|
262
|
+
const components: A2uiComponentNode[] = []
|
|
263
|
+
const childIds: string[] = []
|
|
264
|
+
const dataModel: Record<string, unknown> = {}
|
|
265
|
+
|
|
266
|
+
const includeHeader = options?.includeHeader !== false
|
|
267
|
+
const title = (definition.title ?? '').trim()
|
|
268
|
+
const description = (definition.description ?? '').trim()
|
|
269
|
+
if (includeHeader && title) {
|
|
270
|
+
components.push({ id: TITLE_ID, component: 'Text', text: title, variant: 'h2' })
|
|
271
|
+
childIds.push(TITLE_ID)
|
|
272
|
+
}
|
|
273
|
+
if (includeHeader && description) {
|
|
274
|
+
components.push({ id: DESC_ID, component: 'Text', text: description, variant: 'body' })
|
|
275
|
+
childIds.push(DESC_ID)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
for (const block of definition.blocks ?? []) {
|
|
279
|
+
const { node, dataKey, dataValue } = blockToComponent(block)
|
|
280
|
+
if (!node) continue
|
|
281
|
+
components.push(node)
|
|
282
|
+
childIds.push(node.id)
|
|
283
|
+
if (dataKey) dataModel[dataKey] = dataValue
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
components.unshift({ id: ROOT_ID, component: 'Column', children: childIds })
|
|
287
|
+
|
|
288
|
+
const doc: A2uiSurfaceDoc = { root: ROOT_ID, components, dataModel }
|
|
289
|
+
const styleId = options?.styleId
|
|
290
|
+
if (styleId !== undefined) {
|
|
291
|
+
doc.surfaceProperties = { styleId: styleId ?? null }
|
|
292
|
+
}
|
|
293
|
+
return doc
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function indexComponents(doc: A2uiSurfaceDoc): Map<string, A2uiComponentNode> {
|
|
297
|
+
const map = new Map<string, A2uiComponentNode>()
|
|
298
|
+
for (const node of doc.components ?? []) {
|
|
299
|
+
if (node && typeof node.id === 'string') map.set(node.id, node)
|
|
300
|
+
}
|
|
301
|
+
return map
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function readOptions(node: A2uiComponentNode): FormBlockOption[] {
|
|
305
|
+
const raw = node.options
|
|
306
|
+
if (!Array.isArray(raw)) return []
|
|
307
|
+
return raw
|
|
308
|
+
.map((o) => {
|
|
309
|
+
if (!o || typeof o !== 'object') return null
|
|
310
|
+
const rec = o as Record<string, unknown>
|
|
311
|
+
return {
|
|
312
|
+
value: String(rec.value ?? rec.label ?? ''),
|
|
313
|
+
label: String(rec.label ?? rec.value ?? ''),
|
|
314
|
+
} as FormBlockOption
|
|
315
|
+
})
|
|
316
|
+
.filter((o): o is FormBlockOption => !!o)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** 单个 A2UI 组件节点 -> FormBlock(无法识别返回 null) */
|
|
320
|
+
function componentToBlock(node: A2uiComponentNode): FormBlock | null {
|
|
321
|
+
const id = typeof node.id === 'string' ? node.id : generateId('block-')
|
|
322
|
+
const component = String(node.component ?? '')
|
|
323
|
+
const label = asLiteral(node.label) ?? asLiteral(node.text) ?? ''
|
|
324
|
+
const name = nameFromValue(node.value)
|
|
325
|
+
|
|
326
|
+
const base = (type: FormBlockType): FormBlock => ({ id, type, label })
|
|
327
|
+
|
|
328
|
+
switch (component) {
|
|
329
|
+
case 'Text': {
|
|
330
|
+
const variant = String(node.variant ?? 'body')
|
|
331
|
+
const isHeading = /^h[1-6]$/i.test(variant) || variant === 'title' || variant === 'subtitle'
|
|
332
|
+
return { id, type: isHeading ? 'heading' : 'paragraph', label }
|
|
333
|
+
}
|
|
334
|
+
case 'TextField': {
|
|
335
|
+
const variant = String(node.variant ?? 'shortText')
|
|
336
|
+
let type: FormBlockType = 'text'
|
|
337
|
+
if (variant === 'longText') type = 'textarea'
|
|
338
|
+
else if (variant === 'number') type = 'number'
|
|
339
|
+
else if (asLiteral(node.validationRegexp) === EMAIL_REGEXP) type = 'email'
|
|
340
|
+
return { ...base(type), name }
|
|
341
|
+
}
|
|
342
|
+
case 'CheckBox':
|
|
343
|
+
return { ...base('toggle'), name }
|
|
344
|
+
case 'SkoponSelect': {
|
|
345
|
+
const mode = String(node.mode ?? 'single')
|
|
346
|
+
const type: FormBlockType = mode === 'multiple' ? 'multiselect' : 'select'
|
|
347
|
+
const block: FormBlock = {
|
|
348
|
+
...base(type),
|
|
349
|
+
name,
|
|
350
|
+
options: readOptions(node),
|
|
351
|
+
}
|
|
352
|
+
const placeholder = asLiteral(node.placeholder)
|
|
353
|
+
if (placeholder) block.placeholder = placeholder
|
|
354
|
+
return block
|
|
355
|
+
}
|
|
356
|
+
case 'ChoicePicker': {
|
|
357
|
+
const pickerType = asLiteral(node.skoponPickerType)
|
|
358
|
+
if (
|
|
359
|
+
pickerType === 'select' ||
|
|
360
|
+
pickerType === 'multiselect' ||
|
|
361
|
+
pickerType === 'radio' ||
|
|
362
|
+
pickerType === 'checkbox'
|
|
363
|
+
) {
|
|
364
|
+
const block: FormBlock = {
|
|
365
|
+
...base(pickerType),
|
|
366
|
+
name,
|
|
367
|
+
options: readOptions(node),
|
|
368
|
+
}
|
|
369
|
+
const placeholder = asLiteral(node.placeholder)
|
|
370
|
+
if (placeholder) block.placeholder = placeholder
|
|
371
|
+
return block
|
|
372
|
+
}
|
|
373
|
+
const variant = String(node.variant ?? 'mutuallyExclusive')
|
|
374
|
+
const type: FormBlockType = variant === 'multipleSelection' ? 'checkbox' : 'radio'
|
|
375
|
+
return { ...base(type), name, options: readOptions(node) }
|
|
376
|
+
}
|
|
377
|
+
case 'DateTimeInput': {
|
|
378
|
+
const enableDate = node.enableDate === true
|
|
379
|
+
const enableTime = node.enableTime === true
|
|
380
|
+
const type: FormBlockType = !enableDate && enableTime ? 'time' : 'datetime'
|
|
381
|
+
const block: FormBlock = { ...base(type), name }
|
|
382
|
+
const placeholder = asLiteral(node.placeholder)
|
|
383
|
+
if (placeholder) block.placeholder = placeholder
|
|
384
|
+
return block
|
|
385
|
+
}
|
|
386
|
+
case 'SkoponMedia': {
|
|
387
|
+
const mediaType = String(node.mediaType ?? 'image')
|
|
388
|
+
const type: FormBlockType =
|
|
389
|
+
mediaType === 'video' ? 'video' : mediaType === 'audio' ? 'audio' : 'image'
|
|
390
|
+
return readMediaBlock(node, type, label)
|
|
391
|
+
}
|
|
392
|
+
case 'Image':
|
|
393
|
+
return readMediaBlock(node, 'image', label)
|
|
394
|
+
case 'Video':
|
|
395
|
+
return readMediaBlock(node, 'video', label)
|
|
396
|
+
case 'AudioPlayer':
|
|
397
|
+
return readMediaBlock(node, 'audio', label)
|
|
398
|
+
case 'FileUpload': {
|
|
399
|
+
const accept = asLiteral(node.accept) ?? ''
|
|
400
|
+
const block: FormBlock = { ...base('file'), name }
|
|
401
|
+
if (accept) block.fileAcceptExtensions = accept.split(',').map((s) => s.trim()).filter(Boolean)
|
|
402
|
+
if (Array.isArray(node.fileAcceptTypes)) {
|
|
403
|
+
block.fileAcceptTypes = node.fileAcceptTypes.map(String)
|
|
404
|
+
}
|
|
405
|
+
if (typeof node.filePlaceholderIcon === 'string') {
|
|
406
|
+
block.filePlaceholderIcon = node.filePlaceholderIcon as FormBlock['filePlaceholderIcon']
|
|
407
|
+
}
|
|
408
|
+
if (typeof node.minCount === 'number') block.fileMinCount = node.minCount
|
|
409
|
+
if (typeof node.maxCount === 'number') block.fileMaxCount = node.maxCount
|
|
410
|
+
return block
|
|
411
|
+
}
|
|
412
|
+
default:
|
|
413
|
+
return null
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** 递归收集容器下的叶子组件顺序(扁平化 Column/Row 嵌套) */
|
|
418
|
+
function collectOrder(
|
|
419
|
+
rootId: string,
|
|
420
|
+
index: Map<string, A2uiComponentNode>,
|
|
421
|
+
seen: Set<string>,
|
|
422
|
+
out: A2uiComponentNode[],
|
|
423
|
+
): void {
|
|
424
|
+
if (seen.has(rootId)) return
|
|
425
|
+
seen.add(rootId)
|
|
426
|
+
const node = index.get(rootId)
|
|
427
|
+
if (!node) return
|
|
428
|
+
const component = String(node.component ?? '')
|
|
429
|
+
if ((component === 'Column' || component === 'Row') && Array.isArray(node.children)) {
|
|
430
|
+
for (const childId of node.children) {
|
|
431
|
+
if (typeof childId === 'string') collectOrder(childId, index, seen, out)
|
|
432
|
+
}
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
out.push(node)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** A2UI surface 文档 -> FormSchema(已 sync 出 jsonSchema) */
|
|
439
|
+
export function a2uiToBlocks(doc: A2uiSurfaceDoc | null | undefined): FormSchema {
|
|
440
|
+
if (!doc || !Array.isArray(doc.components)) {
|
|
441
|
+
return syncFormDefinition({ title: '', description: '', blocks: [], jsonSchema: {} })
|
|
442
|
+
}
|
|
443
|
+
const index = indexComponents(doc)
|
|
444
|
+
const ordered: A2uiComponentNode[] = []
|
|
445
|
+
collectOrder(doc.root ?? ROOT_ID, index, new Set(), ordered)
|
|
446
|
+
|
|
447
|
+
let title = ''
|
|
448
|
+
let description = ''
|
|
449
|
+
const blocks: FormBlock[] = []
|
|
450
|
+
|
|
451
|
+
for (const node of ordered) {
|
|
452
|
+
if (node.id === TITLE_ID) {
|
|
453
|
+
title = asLiteral(node.text) ?? title
|
|
454
|
+
continue
|
|
455
|
+
}
|
|
456
|
+
if (node.id === DESC_ID) {
|
|
457
|
+
description = asLiteral(node.text) ?? description
|
|
458
|
+
continue
|
|
459
|
+
}
|
|
460
|
+
const block = componentToBlock(node)
|
|
461
|
+
if (block) blocks.push(block)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return syncFormDefinition({ title, description, blocks, jsonSchema: {} })
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* 便携 surface 文档 -> A2UI v0.9 消息列表(供 MessageProcessor 消费)。
|
|
469
|
+
* 返回宽松类型,调用方按需 `as unknown as A2uiMessage[]`。
|
|
470
|
+
*/
|
|
471
|
+
export function surfaceDocToMessages(
|
|
472
|
+
doc: A2uiSurfaceDoc,
|
|
473
|
+
options: { surfaceId: string; catalogId: string },
|
|
474
|
+
): Record<string, unknown>[] {
|
|
475
|
+
const { surfaceId, catalogId } = options
|
|
476
|
+
const messages: Record<string, unknown>[] = [
|
|
477
|
+
{ version: 'v0.9', createSurface: { surfaceId, catalogId } },
|
|
478
|
+
{
|
|
479
|
+
version: 'v0.9',
|
|
480
|
+
updateComponents: { surfaceId, components: doc.components },
|
|
481
|
+
},
|
|
482
|
+
]
|
|
483
|
+
if (doc.dataModel && Object.keys(doc.dataModel).length > 0) {
|
|
484
|
+
messages.push({
|
|
485
|
+
version: 'v0.9',
|
|
486
|
+
updateDataModel: { surfaceId, path: '/', value: doc.dataModel },
|
|
487
|
+
})
|
|
488
|
+
}
|
|
489
|
+
return messages
|
|
490
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { MessageProcessor } from '@a2ui/web_core/v0_9'
|
|
2
|
+
import type { ReactComponentImplementation } from '@a2ui/react/v0_9'
|
|
3
|
+
|
|
4
|
+
type SkoponProcessor = MessageProcessor<ReactComponentImplementation>
|
|
5
|
+
|
|
6
|
+
/** 从 MessageProcessor surface 的 dataModel 读取已填字段值。 */
|
|
7
|
+
export function extractSurfaceValues(
|
|
8
|
+
processor: SkoponProcessor,
|
|
9
|
+
surfaceId: string,
|
|
10
|
+
fieldNames: string[],
|
|
11
|
+
): Record<string, unknown> {
|
|
12
|
+
const surface = processor.model.surfacesMap.get(surfaceId)
|
|
13
|
+
if (!surface) return {}
|
|
14
|
+
const values: Record<string, unknown> = {}
|
|
15
|
+
for (const name of fieldNames) {
|
|
16
|
+
const trimmed = name.trim()
|
|
17
|
+
if (!trimmed) continue
|
|
18
|
+
values[trimmed] = surface.dataModel.get(`/${trimmed}`)
|
|
19
|
+
}
|
|
20
|
+
return values
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function collectInputFieldNames(fieldNames?: string[]): string[] {
|
|
24
|
+
return (fieldNames ?? []).map((name) => name.trim()).filter(Boolean)
|
|
25
|
+
}
|