@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.
Files changed (74) hide show
  1. package/README.md +82 -0
  2. package/dist/adapter/a2uiAdapter.d.ts +21 -0
  3. package/dist/adapter/a2uiAdapter.d.ts.map +1 -0
  4. package/dist/adapter/extractSurfaceValues.d.ts +8 -0
  5. package/dist/adapter/extractSurfaceValues.d.ts.map +1 -0
  6. package/dist/adapter/formFileAccept.d.ts +17 -0
  7. package/dist/adapter/formFileAccept.d.ts.map +1 -0
  8. package/dist/adapter/formFilePlaceholderIcon.d.ts +5 -0
  9. package/dist/adapter/formFilePlaceholderIcon.d.ts.map +1 -0
  10. package/dist/adapter/formMedia.d.ts +7 -0
  11. package/dist/adapter/formMedia.d.ts.map +1 -0
  12. package/dist/adapter/formSchema.d.ts +6 -0
  13. package/dist/adapter/formSchema.d.ts.map +1 -0
  14. package/dist/adapter/id.d.ts +4 -0
  15. package/dist/adapter/id.d.ts.map +1 -0
  16. package/dist/adapter/resolveSurface.d.ts +6 -0
  17. package/dist/adapter/resolveSurface.d.ts.map +1 -0
  18. package/dist/catalog/a2uiCustomCatalog.d.ts +10 -0
  19. package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -0
  20. package/dist/catalog/a2uiPreviewContext.d.ts +11 -0
  21. package/dist/catalog/a2uiPreviewContext.d.ts.map +1 -0
  22. package/dist/catalog/useSkoponBoundField.d.ts +10 -0
  23. package/dist/catalog/useSkoponBoundField.d.ts.map +1 -0
  24. package/dist/client/formClient.d.ts +22 -0
  25. package/dist/client/formClient.d.ts.map +1 -0
  26. package/dist/components/AskUserFormCard.d.ts +13 -0
  27. package/dist/components/AskUserFormCard.d.ts.map +1 -0
  28. package/dist/components/CurlSubmitBlock.d.ts +10 -0
  29. package/dist/components/CurlSubmitBlock.d.ts.map +1 -0
  30. package/dist/components/SkoponA2uiStreamRenderer.d.ts +11 -0
  31. package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -0
  32. package/dist/components/SkoponFormRenderer.d.ts +16 -0
  33. package/dist/components/SkoponFormRenderer.d.ts.map +1 -0
  34. package/dist/form-sdk.css +1 -0
  35. package/dist/icons/FilePlaceholderIcon.d.ts +10 -0
  36. package/dist/icons/FilePlaceholderIcon.d.ts.map +1 -0
  37. package/dist/index.d.ts +20 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +1332 -0
  40. package/dist/submit/buildCurlStatement.d.ts +2 -0
  41. package/dist/submit/buildCurlStatement.d.ts.map +1 -0
  42. package/dist/submit/intersectPayloadWithForm.d.ts +17 -0
  43. package/dist/submit/intersectPayloadWithForm.d.ts.map +1 -0
  44. package/dist/submit/submitFormJson.d.ts +12 -0
  45. package/dist/submit/submitFormJson.d.ts.map +1 -0
  46. package/dist/types/index.d.ts +76 -0
  47. package/dist/types/index.d.ts.map +1 -0
  48. package/package.json +53 -0
  49. package/src/adapter/a2uiAdapter.test.ts +150 -0
  50. package/src/adapter/a2uiAdapter.ts +490 -0
  51. package/src/adapter/extractSurfaceValues.ts +25 -0
  52. package/src/adapter/formFileAccept.ts +198 -0
  53. package/src/adapter/formFilePlaceholderIcon.ts +33 -0
  54. package/src/adapter/formMedia.ts +50 -0
  55. package/src/adapter/formSchema.ts +139 -0
  56. package/src/adapter/id.ts +24 -0
  57. package/src/adapter/resolveSurface.ts +66 -0
  58. package/src/catalog/a2uiCustomCatalog.tsx +548 -0
  59. package/src/catalog/a2uiPreviewContext.tsx +26 -0
  60. package/src/catalog/useSkoponBoundField.ts +57 -0
  61. package/src/client/formClient.ts +72 -0
  62. package/src/components/AskUserFormCard.tsx +155 -0
  63. package/src/components/CurlSubmitBlock.tsx +60 -0
  64. package/src/components/SkoponA2uiStreamRenderer.tsx +70 -0
  65. package/src/components/SkoponFormRenderer.tsx +100 -0
  66. package/src/icons/FilePlaceholderIcon.tsx +40 -0
  67. package/src/index.ts +67 -0
  68. package/src/styles/a2ui-preview.css +345 -0
  69. package/src/styles/index.css +190 -0
  70. package/src/submit/buildCurlStatement.ts +13 -0
  71. package/src/submit/intersectPayloadWithForm.ts +54 -0
  72. package/src/submit/submit.test.ts +63 -0
  73. package/src/submit/submitFormJson.ts +50 -0
  74. package/src/types/index.ts +139 -0
@@ -0,0 +1,548 @@
1
+ import { z } from 'zod'
2
+ import { Catalog, type ComponentContext } from '@a2ui/web_core/v0_9'
3
+ import {
4
+ basicCatalog,
5
+ createBinderlessComponentImplementation,
6
+ type ReactComponentImplementation,
7
+ } from '@a2ui/react/v0_9'
8
+ import { Checkbox, DatePicker, Radio, Select, Switch, TimePicker, Typography, Upload } from 'antd'
9
+ import dayjs, { type Dayjs } from 'dayjs'
10
+ import type { FormFilePlaceholderIcon, FormMediaSize } from '../types/index'
11
+ import {
12
+ buildFileAcceptAttribute,
13
+ formatFileAcceptSummary,
14
+ } from '../adapter/formFileAccept'
15
+ import { buildMediaListClassName, normalizeMediaSize } from '../adapter/formMedia'
16
+ import FilePlaceholderIcon from '../icons/FilePlaceholderIcon'
17
+ import { useA2uiPreviewMode } from './a2uiPreviewContext'
18
+ import {
19
+ asOptionalString,
20
+ asStringArray,
21
+ useSkoponBoundField,
22
+ } from './useSkoponBoundField'
23
+
24
+ /**
25
+ * 在 A2UI basicCatalog 基础上扩展/覆盖 skopon 预览组件,
26
+ * 使 A2uiFormRenderer 与编辑区 BlockPreview 视觉一致。
27
+ */
28
+ export const SKOPON_CATALOG_ID = 'skopon-form-v0_9'
29
+
30
+ const SKOPON_COMPONENT_NAMES = new Set([
31
+ 'SkoponMedia',
32
+ 'SkoponSelect',
33
+ 'Image',
34
+ 'Video',
35
+ 'AudioPlayer',
36
+ 'ChoicePicker',
37
+ 'CheckBox',
38
+ 'DateTimeInput',
39
+ 'FileUpload',
40
+ ])
41
+
42
+ function getBasicSchema(name: string): z.ZodTypeAny {
43
+ const impl = [...basicCatalog.components.values()].find((c) => c.name === name)
44
+ if (!impl) throw new Error(`Missing basic catalog component: ${name}`)
45
+ return impl.schema
46
+ }
47
+
48
+ function readString(value: unknown): string {
49
+ return typeof value === 'string' ? value : ''
50
+ }
51
+
52
+ function readOptions(
53
+ raw: unknown,
54
+ ): { label: string; value: string }[] {
55
+ if (!Array.isArray(raw)) return []
56
+ return raw
57
+ .map((item) => {
58
+ if (!item || typeof item !== 'object') return null
59
+ const rec = item as Record<string, unknown>
60
+ const value = String(rec.value ?? rec.label ?? '')
61
+ const label = String(rec.label ?? rec.value ?? '')
62
+ return { value, label }
63
+ })
64
+ .filter((item): item is { label: string; value: string } => !!item)
65
+ }
66
+
67
+ interface SkoponMediaPreviewProps {
68
+ mediaType: 'image' | 'video' | 'audio'
69
+ urls: string[]
70
+ mediaSize?: FormMediaSize
71
+ label?: string
72
+ help?: string
73
+ placeholder?: string
74
+ }
75
+
76
+ function SkoponMediaPreview({
77
+ mediaType,
78
+ urls,
79
+ mediaSize,
80
+ label,
81
+ help,
82
+ placeholder,
83
+ }: SkoponMediaPreviewProps) {
84
+ const fieldLabel = label?.trim()
85
+ const helpText = help?.trim()
86
+ const listClassName = buildMediaListClassName(mediaSize)
87
+ const emptyLabel =
88
+ placeholder?.trim() ||
89
+ (mediaType === 'image'
90
+ ? '未设置图片 URL'
91
+ : mediaType === 'video'
92
+ ? '未设置视频 URL'
93
+ : '未设置音频 URL')
94
+
95
+ return (
96
+ <div className="form-block-preview">
97
+ {fieldLabel ? <div className="form-block-preview-label">{fieldLabel}</div> : null}
98
+ {urls.length === 0 ? (
99
+ <div className="form-media-preview form-media-preview--empty">{emptyLabel}</div>
100
+ ) : (
101
+ <div className={listClassName}>
102
+ {urls.map((url, index) => {
103
+ if (mediaType === 'image') {
104
+ return (
105
+ <img
106
+ key={`media-${index}`}
107
+ src={url}
108
+ alt={fieldLabel || '图片'}
109
+ className="form-media-item"
110
+ />
111
+ )
112
+ }
113
+ if (mediaType === 'video') {
114
+ return (
115
+ <video
116
+ key={`media-${index}`}
117
+ src={url}
118
+ controls
119
+ className="form-media-item"
120
+ />
121
+ )
122
+ }
123
+ return (
124
+ <audio
125
+ key={`media-${index}`}
126
+ src={url}
127
+ controls
128
+ className="form-media-item form-media-item--audio"
129
+ />
130
+ )
131
+ })}
132
+ </div>
133
+ )}
134
+ {helpText ? (
135
+ <Typography.Text type="secondary" className="form-block-preview-help">
136
+ {helpText}
137
+ </Typography.Text>
138
+ ) : null}
139
+ </div>
140
+ )
141
+ }
142
+
143
+ function readMediaPreviewProps(
144
+ props: Record<string, unknown>,
145
+ mediaType: 'image' | 'video' | 'audio',
146
+ ): SkoponMediaPreviewProps {
147
+ const urls = Array.isArray(props.urls)
148
+ ? props.urls.map((u) => String(u).trim()).filter(Boolean)
149
+ : readString(props.url).trim()
150
+ ? [readString(props.url).trim()]
151
+ : []
152
+ return {
153
+ mediaType,
154
+ urls,
155
+ mediaSize: normalizeMediaSize(props.mediaSize as FormMediaSize | undefined),
156
+ label: readString(props.label) || readString(props.description),
157
+ help: readString(props.help),
158
+ placeholder: readString(props.placeholder),
159
+ }
160
+ }
161
+
162
+ const SkoponMediaApi = {
163
+ name: 'SkoponMedia',
164
+ schema: z
165
+ .object({
166
+ mediaType: z.any().optional(),
167
+ urls: z.any().optional(),
168
+ mediaSize: z.any().optional(),
169
+ label: z.any().optional(),
170
+ help: z.any().optional(),
171
+ placeholder: z.any().optional(),
172
+ })
173
+ .passthrough(),
174
+ }
175
+
176
+ const SkoponMediaImpl = createBinderlessComponentImplementation(SkoponMediaApi, ({ context }) => {
177
+ const props = context.componentModel.properties as Record<string, unknown>
178
+ const mediaTypeRaw = readString(props.mediaType)
179
+ const mediaType =
180
+ mediaTypeRaw === 'video' ? 'video' : mediaTypeRaw === 'audio' ? 'audio' : 'image'
181
+ return <SkoponMediaPreview {...readMediaPreviewProps(props, mediaType)} />
182
+ })
183
+
184
+ const ImageImpl = createBinderlessComponentImplementation(
185
+ { name: 'Image', schema: getBasicSchema('Image') },
186
+ ({ context }) => (
187
+ <SkoponMediaPreview {...readMediaPreviewProps(context.componentModel.properties as Record<string, unknown>, 'image')} />
188
+ ),
189
+ )
190
+
191
+ const VideoImpl = createBinderlessComponentImplementation(
192
+ { name: 'Video', schema: getBasicSchema('Video') },
193
+ ({ context }) => (
194
+ <SkoponMediaPreview {...readMediaPreviewProps(context.componentModel.properties as Record<string, unknown>, 'video')} />
195
+ ),
196
+ )
197
+
198
+ const AudioPlayerImpl = createBinderlessComponentImplementation(
199
+ { name: 'AudioPlayer', schema: getBasicSchema('AudioPlayer') },
200
+ ({ context }) => (
201
+ <SkoponMediaPreview {...readMediaPreviewProps(context.componentModel.properties as Record<string, unknown>, 'audio')} />
202
+ ),
203
+ )
204
+
205
+ const SkoponSelectApi = {
206
+ name: 'SkoponSelect',
207
+ schema: z
208
+ .object({
209
+ label: z.any().optional(),
210
+ mode: z.any().optional(),
211
+ placeholder: z.any().optional(),
212
+ options: z.any().optional(),
213
+ value: z.any().optional(),
214
+ })
215
+ .passthrough(),
216
+ }
217
+
218
+ function SkoponSelectPreview({ context }: { context: ComponentContext }) {
219
+ const { interactive } = useA2uiPreviewMode()
220
+ const { value, setValue } = useSkoponBoundField(context)
221
+ const props = context.componentModel.properties as Record<string, unknown>
222
+ const label = readString(props.label)
223
+ const placeholder = readString(props.placeholder)
224
+ const options = readOptions(props.options)
225
+ const isMultiple = readString(props.mode) === 'multiple'
226
+
227
+ return (
228
+ <div className="form-block-preview">
229
+ {label ? <div className="form-block-preview-label">{label}</div> : null}
230
+ <Select
231
+ mode={isMultiple ? 'multiple' : undefined}
232
+ disabled={!interactive}
233
+ allowClear
234
+ className="form-block-preview-control"
235
+ style={{ width: '100%' }}
236
+ placeholder={placeholder || (isMultiple ? '请选择多项' : '请选择')}
237
+ options={options}
238
+ value={isMultiple ? asStringArray(value) : asOptionalString(value)}
239
+ onChange={(next) => setValue(isMultiple ? (next ?? []) : (next ?? ''))}
240
+ />
241
+ </div>
242
+ )
243
+ }
244
+
245
+ const SkoponSelectImpl = createBinderlessComponentImplementation(
246
+ SkoponSelectApi,
247
+ SkoponSelectPreview,
248
+ )
249
+
250
+ function ChoicePickerPreview({ context }: { context: ComponentContext }) {
251
+ const { interactive } = useA2uiPreviewMode()
252
+ const { value, setValue } = useSkoponBoundField(context)
253
+ const props = context.componentModel.properties as Record<string, unknown>
254
+ const label = readString(props.label)
255
+ const variant = readString(props.variant) || 'mutuallyExclusive'
256
+ const pickerType = readString(props.skoponPickerType)
257
+ const placeholder = readString(props.placeholder)
258
+ const options = readOptions(props.options)
259
+ const isMultiple = variant === 'multipleSelection'
260
+ const effectiveType =
261
+ pickerType === 'select' ||
262
+ pickerType === 'multiselect' ||
263
+ pickerType === 'radio' ||
264
+ pickerType === 'checkbox'
265
+ ? pickerType
266
+ : isMultiple
267
+ ? 'checkbox'
268
+ : 'radio'
269
+
270
+ return (
271
+ <div className="form-block-preview">
272
+ {label ? <div className="form-block-preview-label">{label}</div> : null}
273
+ {effectiveType === 'select' ? (
274
+ <Select
275
+ disabled={!interactive}
276
+ allowClear
277
+ className="form-block-preview-control"
278
+ style={{ width: '100%' }}
279
+ placeholder={placeholder || '请选择'}
280
+ options={options}
281
+ value={asOptionalString(value)}
282
+ onChange={(next) => setValue(next ?? '')}
283
+ />
284
+ ) : effectiveType === 'multiselect' ? (
285
+ <Select
286
+ mode="multiple"
287
+ disabled={!interactive}
288
+ allowClear
289
+ className="form-block-preview-control"
290
+ style={{ width: '100%' }}
291
+ placeholder={placeholder || '请选择多项'}
292
+ options={options}
293
+ value={asStringArray(value)}
294
+ onChange={(next) => setValue(next ?? [])}
295
+ />
296
+ ) : effectiveType === 'checkbox' ? (
297
+ <Checkbox.Group
298
+ disabled={!interactive}
299
+ className="form-block-preview-control"
300
+ options={options}
301
+ value={asStringArray(value)}
302
+ onChange={(next) => setValue(next)}
303
+ />
304
+ ) : (
305
+ <Radio.Group
306
+ disabled={!interactive}
307
+ className="form-block-preview-control"
308
+ options={options}
309
+ value={asOptionalString(value)}
310
+ onChange={(e) => setValue(e.target.value)}
311
+ />
312
+ )}
313
+ </div>
314
+ )
315
+ }
316
+
317
+ const ChoicePickerImpl = createBinderlessComponentImplementation(
318
+ {
319
+ name: 'ChoicePicker',
320
+ schema: z
321
+ .object({
322
+ label: z.any().optional(),
323
+ variant: z.any().optional(),
324
+ options: z.any().optional(),
325
+ value: z.any().optional(),
326
+ skoponPickerType: z.any().optional(),
327
+ placeholder: z.any().optional(),
328
+ })
329
+ .passthrough(),
330
+ },
331
+ ChoicePickerPreview,
332
+ )
333
+
334
+ function ToggleSwitchPreview({ context }: { context: ComponentContext }) {
335
+ const { interactive } = useA2uiPreviewMode()
336
+ const { value, setValue } = useSkoponBoundField(context)
337
+ const props = context.componentModel.properties as Record<string, unknown>
338
+ const label = readString(props.label)
339
+
340
+ return (
341
+ <div className="form-block-preview">
342
+ {label ? <div className="form-block-preview-label">{label}</div> : null}
343
+ <Switch
344
+ disabled={!interactive}
345
+ className="form-block-preview-control"
346
+ checked={value === true}
347
+ onChange={(checked) => setValue(checked)}
348
+ />
349
+ </div>
350
+ )
351
+ }
352
+
353
+ const CheckBoxImpl = createBinderlessComponentImplementation(
354
+ { name: 'CheckBox', schema: getBasicSchema('CheckBox') },
355
+ ToggleSwitchPreview,
356
+ )
357
+
358
+ function toDateTimePickerValue(
359
+ raw: unknown,
360
+ enableDate: boolean,
361
+ enableTime: boolean,
362
+ ): Dayjs | undefined {
363
+ const text = asOptionalString(raw)
364
+ if (!text) return undefined
365
+ if (!enableDate && enableTime) {
366
+ const parsed = dayjs(text, 'HH:mm', true)
367
+ return parsed.isValid() ? parsed : undefined
368
+ }
369
+ const parsed = dayjs(text)
370
+ return parsed.isValid() ? parsed : undefined
371
+ }
372
+
373
+ function DateTimeInputPreview({ context }: { context: ComponentContext }) {
374
+ const { interactive } = useA2uiPreviewMode()
375
+ const { value, setValue } = useSkoponBoundField(context)
376
+ const props = context.componentModel.properties as Record<string, unknown>
377
+ const label = readString(props.label)
378
+ const placeholder = readString(props.placeholder)
379
+ const enableDate = props.enableDate === true
380
+ const enableTime = props.enableTime === true
381
+ const pickerValue = toDateTimePickerValue(value, enableDate, enableTime)
382
+
383
+ const handleChange = (next: Dayjs | null) => {
384
+ if (!next) {
385
+ setValue('')
386
+ return
387
+ }
388
+ if (!enableDate && enableTime) {
389
+ setValue(next.format('HH:mm'))
390
+ return
391
+ }
392
+ if (enableDate && enableTime) {
393
+ setValue(next.format('YYYY-MM-DDTHH:mm'))
394
+ return
395
+ }
396
+ setValue(next.format('YYYY-MM-DD'))
397
+ }
398
+
399
+ return (
400
+ <div className="form-block-preview">
401
+ {label ? <div className="form-block-preview-label">{label}</div> : null}
402
+ {!enableDate && enableTime ? (
403
+ <TimePicker
404
+ disabled={!interactive}
405
+ className="form-block-preview-control"
406
+ style={{ width: '100%' }}
407
+ placeholder={placeholder || '选择时间'}
408
+ value={pickerValue}
409
+ onChange={handleChange}
410
+ />
411
+ ) : (
412
+ <DatePicker
413
+ showTime={enableDate && enableTime}
414
+ disabled={!interactive}
415
+ className="form-block-preview-control"
416
+ style={{ width: '100%' }}
417
+ placeholder={
418
+ placeholder ||
419
+ (enableDate && enableTime ? '选择日期时间' : '选择日期')
420
+ }
421
+ value={pickerValue}
422
+ onChange={handleChange}
423
+ />
424
+ )}
425
+ </div>
426
+ )
427
+ }
428
+
429
+ const DateTimeInputImpl = createBinderlessComponentImplementation(
430
+ {
431
+ name: 'DateTimeInput',
432
+ schema: z
433
+ .object({
434
+ label: z.any().optional(),
435
+ enableDate: z.any().optional(),
436
+ enableTime: z.any().optional(),
437
+ placeholder: z.any().optional(),
438
+ value: z.any().optional(),
439
+ min: z.any().optional(),
440
+ max: z.any().optional(),
441
+ })
442
+ .passthrough(),
443
+ },
444
+ DateTimeInputPreview,
445
+ )
446
+
447
+ const FileUploadApi = {
448
+ name: 'FileUpload',
449
+ schema: z
450
+ .object({
451
+ label: z.any().optional(),
452
+ accept: z.any().optional(),
453
+ fileAcceptTypes: z.any().optional(),
454
+ filePlaceholderIcon: z.any().optional(),
455
+ minCount: z.any().optional(),
456
+ maxCount: z.any().optional(),
457
+ value: z.any().optional(),
458
+ })
459
+ .passthrough(),
460
+ }
461
+
462
+ function FileUploadPreview({ context }: { context: ComponentContext }) {
463
+ const { interactive } = useA2uiPreviewMode()
464
+ const { value, setValue } = useSkoponBoundField(context)
465
+ const props = context.componentModel.properties as Record<string, unknown>
466
+ const label = readString(props.label)
467
+ const accept = readString(props.accept)
468
+ const fileAcceptTypes = Array.isArray(props.fileAcceptTypes)
469
+ ? props.fileAcceptTypes.map(String)
470
+ : undefined
471
+ const fileAcceptExtensions = accept
472
+ ? accept.split(',').map((s) => s.trim()).filter(Boolean)
473
+ : undefined
474
+ const acceptSummary = formatFileAcceptSummary(fileAcceptTypes, fileAcceptExtensions)
475
+ const placeholderIcon: FormFilePlaceholderIcon =
476
+ typeof props.filePlaceholderIcon === 'string' &&
477
+ ['video', 'audio', 'image', 'file', 'spreadsheet', 'document'].includes(
478
+ props.filePlaceholderIcon,
479
+ )
480
+ ? (props.filePlaceholderIcon as FormFilePlaceholderIcon)
481
+ : 'document'
482
+ const maxCount = typeof props.maxCount === 'number' ? props.maxCount : 1
483
+ const selectedNames = asStringArray(value)
484
+
485
+ return (
486
+ <div className="form-block-preview">
487
+ {label ? <div className="form-block-preview-label">{label}</div> : null}
488
+ <Upload.Dragger
489
+ disabled={!interactive}
490
+ accept={buildFileAcceptAttribute(fileAcceptTypes, fileAcceptExtensions)}
491
+ className="form-file-upload-preview"
492
+ showUploadList={interactive && selectedNames.length > 0}
493
+ fileList={
494
+ interactive
495
+ ? selectedNames.map((name, index) => ({
496
+ uid: `${index}-${name}`,
497
+ name,
498
+ status: 'done' as const,
499
+ }))
500
+ : []
501
+ }
502
+ beforeUpload={(file) => {
503
+ if (!interactive) return Upload.LIST_IGNORE
504
+ const next = [...selectedNames, file.name].slice(0, Math.max(1, maxCount))
505
+ setValue(next)
506
+ return false
507
+ }}
508
+ onRemove={(file) => {
509
+ setValue(selectedNames.filter((name) => name !== file.name))
510
+ }}
511
+ >
512
+ <p className="form-file-upload-preview-icon">
513
+ <FilePlaceholderIcon type={placeholderIcon} size={40} />
514
+ </p>
515
+ <p className="form-file-upload-preview-text">点击或拖拽文件到此处上传</p>
516
+ </Upload.Dragger>
517
+ <Typography.Text type="secondary" className="form-block-preview-help">
518
+ 允许:{acceptSummary ?? '全部类型'}
519
+ </Typography.Text>
520
+ </div>
521
+ )
522
+ }
523
+
524
+ const FileUploadImpl = createBinderlessComponentImplementation(
525
+ FileUploadApi,
526
+ FileUploadPreview,
527
+ )
528
+
529
+ /** 构建合并后的 skopon catalog(预览每次重建,避免 HMR 缓存旧组件)。 */
530
+ export function buildSkoponCatalog(): Catalog<ReactComponentImplementation> {
531
+ const baseComponents = [...basicCatalog.components.values()].filter(
532
+ (component) => !SKOPON_COMPONENT_NAMES.has(component.name),
533
+ )
534
+ const components = [
535
+ ...baseComponents,
536
+ SkoponMediaImpl,
537
+ SkoponSelectImpl,
538
+ ImageImpl,
539
+ VideoImpl,
540
+ AudioPlayerImpl,
541
+ ChoicePickerImpl,
542
+ CheckBoxImpl,
543
+ DateTimeInputImpl,
544
+ FileUploadImpl,
545
+ ]
546
+ const functions = [...basicCatalog.functions.values()]
547
+ return new Catalog(SKOPON_CATALOG_ID, components, functions, basicCatalog.themeSchema)
548
+ }
@@ -0,0 +1,26 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+
3
+ export interface A2uiPreviewMode {
4
+ /** 预览是否允许填写交互(默认 true) */
5
+ interactive: boolean
6
+ }
7
+
8
+ const A2uiPreviewModeContext = createContext<A2uiPreviewMode>({ interactive: true })
9
+
10
+ export function A2uiPreviewModeProvider({
11
+ interactive,
12
+ children,
13
+ }: {
14
+ interactive: boolean
15
+ children: ReactNode
16
+ }) {
17
+ return (
18
+ <A2uiPreviewModeContext.Provider value={{ interactive }}>
19
+ {children}
20
+ </A2uiPreviewModeContext.Provider>
21
+ )
22
+ }
23
+
24
+ export function useA2uiPreviewMode(): A2uiPreviewMode {
25
+ return useContext(A2uiPreviewModeContext)
26
+ }
@@ -0,0 +1,57 @@
1
+ import { useCallback, useSyncExternalStore } from 'react'
2
+ import type { ComponentContext } from '@a2ui/web_core/v0_9'
3
+
4
+ type DynamicBinding = Parameters<ComponentContext['dataContext']['resolveDynamicValue']>[0]
5
+
6
+ function bindingPath(value: unknown): string | null {
7
+ if (value && typeof value === 'object' && 'path' in value) {
8
+ const path = String((value as { path: unknown }).path ?? '').trim()
9
+ return path || null
10
+ }
11
+ return null
12
+ }
13
+
14
+ /** 将 skopon 预览控件绑定到 A2UI surface dataModel(与 basicCatalog binder 行为一致)。 */
15
+ export function useSkoponBoundField(context: ComponentContext, field = 'value') {
16
+ const props = context.componentModel.properties as Record<string, unknown>
17
+ const binding = props[field] as DynamicBinding | undefined
18
+ const path = bindingPath(binding)
19
+
20
+ const subscribe = useCallback(
21
+ (onStoreChange: () => void) => {
22
+ if (!path || binding === undefined) return () => {}
23
+ const sub = context.dataContext.subscribeDynamicValue(binding, () => onStoreChange())
24
+ return () => sub.unsubscribe()
25
+ },
26
+ [context, binding, path],
27
+ )
28
+
29
+ const getSnapshot = useCallback(() => {
30
+ if (!path || binding === undefined) return undefined
31
+ return context.dataContext.resolveDynamicValue(binding)
32
+ }, [context, binding, path])
33
+
34
+ const value = path ? useSyncExternalStore(subscribe, getSnapshot, getSnapshot) : undefined
35
+
36
+ const setValue = useCallback(
37
+ (next: unknown) => {
38
+ if (!path || !binding || typeof binding !== 'object' || !('path' in binding)) return
39
+ context.dataContext.set(String((binding as { path: string }).path), next)
40
+ },
41
+ [context, binding, path],
42
+ )
43
+
44
+ return { value, setValue, hasBinding: !!path }
45
+ }
46
+
47
+ export function asOptionalString(value: unknown): string | undefined {
48
+ if (typeof value === 'string') return value || undefined
49
+ if (typeof value === 'number') return String(value)
50
+ return undefined
51
+ }
52
+
53
+ export function asStringArray(value: unknown): string[] {
54
+ if (Array.isArray(value)) return value.map(String)
55
+ if (typeof value === 'string' && value) return [value]
56
+ return []
57
+ }
@@ -0,0 +1,72 @@
1
+ import type { FormDetailResult, FormSchema } from '../types/index'
2
+ import { mapFormDefinitionFromDto, resolveSurfaceFromFormDefinition } from '../adapter/resolveSurface'
3
+
4
+ export interface FormDetailDto {
5
+ form_id: number
6
+ form_unique_id: string
7
+ name: string
8
+ disabled: number
9
+ form_definition: Record<string, unknown>
10
+ }
11
+
12
+ export interface FormClientOptions {
13
+ baseUrl?: string
14
+ detailPath?: string
15
+ fetch?: typeof fetch
16
+ getHeaders?: () => Record<string, string> | Promise<Record<string, string>>
17
+ }
18
+
19
+ export interface FormClient {
20
+ fetchDetail: (ref: { formUniqueId?: string; formId?: number }) => Promise<FormDetailResult>
21
+ }
22
+
23
+ interface ApiEnvelope<T> {
24
+ success?: boolean
25
+ data?: T
26
+ message?: string
27
+ }
28
+
29
+ async function parseEnvelope<T>(response: Response): Promise<T> {
30
+ const json = (await response.json()) as ApiEnvelope<T>
31
+ if (!response.ok || json.success === false) {
32
+ throw new Error(json.message || `请求失败 (${response.status})`)
33
+ }
34
+ return json.data as T
35
+ }
36
+
37
+ export function createFormClient(options: FormClientOptions = {}): FormClient {
38
+ const baseUrl = (options.baseUrl ?? '/api/v1').replace(/\/$/, '')
39
+ const detailPath = options.detailPath ?? '/dev/form/detail'
40
+ const fetchFn = options.fetch ?? fetch
41
+
42
+ async function fetchDetail(ref: {
43
+ formUniqueId?: string
44
+ formId?: number
45
+ }): Promise<FormDetailResult> {
46
+ const params = new URLSearchParams()
47
+ if (ref.formId !== undefined) {
48
+ params.set('form_id', String(ref.formId))
49
+ } else if (ref.formUniqueId) {
50
+ params.set('form_unique_id', ref.formUniqueId)
51
+ } else {
52
+ throw new Error('form_id 或 form_unique_id 至少传一个')
53
+ }
54
+
55
+ const headers = (await options.getHeaders?.()) ?? {}
56
+ const response = await fetchFn(`${baseUrl}${detailPath}?${params.toString()}`, {
57
+ method: 'GET',
58
+ headers,
59
+ })
60
+ const dto = await parseEnvelope<FormDetailDto>(response)
61
+ const formDefinition = mapFormDefinitionFromDto(dto.form_definition) as FormSchema | undefined
62
+ return {
63
+ formUniqueId: dto.form_unique_id,
64
+ formId: dto.form_id,
65
+ disabled: dto.disabled === 1,
66
+ formDefinition,
67
+ a2ui: resolveSurfaceFromFormDefinition(dto.form_definition) ?? undefined,
68
+ }
69
+ }
70
+
71
+ return { fetchDetail }
72
+ }