@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,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
|
+
}
|