@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,198 @@
|
|
|
1
|
+
export interface FormFileAcceptOption {
|
|
2
|
+
value: string
|
|
3
|
+
label: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** UI 选项 value;Office 类为分组 key,生成 accept 时展开为多个 MIME */
|
|
7
|
+
export const FORM_FILE_TYPE_OPTIONS: FormFileAcceptOption[] = [
|
|
8
|
+
{ value: 'image/*', label: '图片' },
|
|
9
|
+
{ value: 'video/*', label: '视频' },
|
|
10
|
+
{ value: 'audio/*', label: '音频' },
|
|
11
|
+
{ value: 'application/pdf', label: 'PDF' },
|
|
12
|
+
{ value: 'office/word', label: 'Word' },
|
|
13
|
+
{ value: 'office/excel', label: 'Excel' },
|
|
14
|
+
{ value: 'office/ppt', label: 'PPT' },
|
|
15
|
+
{ value: 'text/plain', label: '纯文本' },
|
|
16
|
+
{ value: 'application/zip', label: 'ZIP 压缩包' },
|
|
17
|
+
{ value: 'application/x-rar-compressed', label: 'RAR 压缩包' },
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
export const FORM_FILE_TYPE_MIME_MAP: Record<string, string[]> = {
|
|
21
|
+
'image/*': ['image/*'],
|
|
22
|
+
'video/*': ['video/*'],
|
|
23
|
+
'audio/*': ['audio/*'],
|
|
24
|
+
'application/pdf': ['application/pdf'],
|
|
25
|
+
'office/word': [
|
|
26
|
+
'application/msword',
|
|
27
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
28
|
+
],
|
|
29
|
+
'office/excel': [
|
|
30
|
+
'application/vnd.ms-excel',
|
|
31
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
32
|
+
],
|
|
33
|
+
'office/ppt': [
|
|
34
|
+
'application/vnd.ms-powerpoint',
|
|
35
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
36
|
+
],
|
|
37
|
+
'text/plain': ['text/plain'],
|
|
38
|
+
'application/zip': ['application/zip'],
|
|
39
|
+
'application/x-rar-compressed': ['application/x-rar-compressed'],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const FORM_FILE_TYPE_EXTENSION_MAP: Record<string, string[]> = {
|
|
43
|
+
'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'],
|
|
44
|
+
'video/*': ['.mp4', '.mov', '.avi', '.webm'],
|
|
45
|
+
'audio/*': ['.mp3', '.wav', '.ogg', '.m4a'],
|
|
46
|
+
'application/pdf': ['.pdf'],
|
|
47
|
+
'office/word': ['.doc', '.docx'],
|
|
48
|
+
'office/excel': ['.xls', '.xlsx'],
|
|
49
|
+
'office/ppt': ['.ppt', '.pptx'],
|
|
50
|
+
'text/plain': ['.txt'],
|
|
51
|
+
'application/zip': ['.zip', '.7z'],
|
|
52
|
+
'application/x-rar-compressed': ['.rar'],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 旧版按 MIME 存储的数据,归一化为分组 key */
|
|
56
|
+
const LEGACY_FILE_ACCEPT_TYPE_TO_GROUP: Record<string, string> = {
|
|
57
|
+
'application/msword': 'office/word',
|
|
58
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
|
|
59
|
+
'office/word',
|
|
60
|
+
'application/vnd.ms-excel': 'office/excel',
|
|
61
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
|
|
62
|
+
'office/excel',
|
|
63
|
+
'application/vnd.ms-powerpoint': 'office/ppt',
|
|
64
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation':
|
|
65
|
+
'office/ppt',
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const FORM_FILE_EXTENSION_OPTIONS: FormFileAcceptOption[] = [
|
|
69
|
+
{ value: '.jpg', label: '.jpg' },
|
|
70
|
+
{ value: '.jpeg', label: '.jpeg' },
|
|
71
|
+
{ value: '.png', label: '.png' },
|
|
72
|
+
{ value: '.gif', label: '.gif' },
|
|
73
|
+
{ value: '.webp', label: '.webp' },
|
|
74
|
+
{ value: '.svg', label: '.svg' },
|
|
75
|
+
{ value: '.mp4', label: '.mp4' },
|
|
76
|
+
{ value: '.mov', label: '.mov' },
|
|
77
|
+
{ value: '.avi', label: '.avi' },
|
|
78
|
+
{ value: '.webm', label: '.webm' },
|
|
79
|
+
{ value: '.mp3', label: '.mp3' },
|
|
80
|
+
{ value: '.wav', label: '.wav' },
|
|
81
|
+
{ value: '.ogg', label: '.ogg' },
|
|
82
|
+
{ value: '.m4a', label: '.m4a' },
|
|
83
|
+
{ value: '.pdf', label: '.pdf' },
|
|
84
|
+
{ value: '.doc', label: '.doc' },
|
|
85
|
+
{ value: '.docx', label: '.docx' },
|
|
86
|
+
{ value: '.xls', label: '.xls' },
|
|
87
|
+
{ value: '.xlsx', label: '.xlsx' },
|
|
88
|
+
{ value: '.ppt', label: '.ppt' },
|
|
89
|
+
{ value: '.pptx', label: '.pptx' },
|
|
90
|
+
{ value: '.txt', label: '.txt' },
|
|
91
|
+
{ value: '.zip', label: '.zip' },
|
|
92
|
+
{ value: '.rar', label: '.rar' },
|
|
93
|
+
{ value: '.7z', label: '.7z' },
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
const FILE_TYPE_LABELS = Object.fromEntries(
|
|
97
|
+
FORM_FILE_TYPE_OPTIONS.map((item) => [item.value, item.label]),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const FILE_EXTENSION_LABELS = Object.fromEntries(
|
|
101
|
+
FORM_FILE_EXTENSION_OPTIONS.map((item) => [item.value, item.label]),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
const FILE_TYPE_VALUE_SET = new Set(FORM_FILE_TYPE_OPTIONS.map((item) => item.value))
|
|
105
|
+
|
|
106
|
+
function resolveFileAcceptTypeValue(value: string): string | null {
|
|
107
|
+
const trimmed = value.trim()
|
|
108
|
+
if (!trimmed) return null
|
|
109
|
+
if (FILE_TYPE_VALUE_SET.has(trimmed)) return trimmed
|
|
110
|
+
return LEGACY_FILE_ACCEPT_TYPE_TO_GROUP[trimmed] ?? null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function normalizeFileAcceptExtensions(extensions?: string[]): string[] {
|
|
114
|
+
if (!Array.isArray(extensions)) return []
|
|
115
|
+
return extensions
|
|
116
|
+
.map((item) => {
|
|
117
|
+
const trimmed = item.trim().toLowerCase()
|
|
118
|
+
if (!trimmed) return ''
|
|
119
|
+
return trimmed.startsWith('.') ? trimmed : `.${trimmed}`
|
|
120
|
+
})
|
|
121
|
+
.filter(Boolean)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function normalizeFileAcceptTypes(types?: string[]): string[] {
|
|
125
|
+
if (!Array.isArray(types)) return []
|
|
126
|
+
const result: string[] = []
|
|
127
|
+
const seen = new Set<string>()
|
|
128
|
+
for (const item of types) {
|
|
129
|
+
const resolved = resolveFileAcceptTypeValue(item)
|
|
130
|
+
if (resolved && !seen.has(resolved)) {
|
|
131
|
+
seen.add(resolved)
|
|
132
|
+
result.push(resolved)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return result
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function expandFileAcceptTypesToMime(types?: string[]): string[] {
|
|
139
|
+
const normalizedTypes = normalizeFileAcceptTypes(types)
|
|
140
|
+
const mimes: string[] = []
|
|
141
|
+
for (const type of normalizedTypes) {
|
|
142
|
+
mimes.push(...(FORM_FILE_TYPE_MIME_MAP[type] ?? [type]))
|
|
143
|
+
}
|
|
144
|
+
return mimes
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function getFileExtensionOptionsForTypes(
|
|
148
|
+
types?: string[],
|
|
149
|
+
): FormFileAcceptOption[] {
|
|
150
|
+
const normalizedTypes = normalizeFileAcceptTypes(types)
|
|
151
|
+
if (normalizedTypes.length === 0) return []
|
|
152
|
+
|
|
153
|
+
const extensionSet = new Set<string>()
|
|
154
|
+
for (const type of normalizedTypes) {
|
|
155
|
+
for (const extension of FORM_FILE_TYPE_EXTENSION_MAP[type] ?? []) {
|
|
156
|
+
extensionSet.add(extension)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return FORM_FILE_EXTENSION_OPTIONS.filter((item) => extensionSet.has(item.value))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function filterFileAcceptExtensionsForTypes(
|
|
164
|
+
types?: string[],
|
|
165
|
+
extensions?: string[],
|
|
166
|
+
): string[] {
|
|
167
|
+
const allowed = new Set(
|
|
168
|
+
getFileExtensionOptionsForTypes(types).map((item) => item.value),
|
|
169
|
+
)
|
|
170
|
+
return normalizeFileAcceptExtensions(extensions).filter((item) => allowed.has(item))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function buildFileAcceptAttribute(
|
|
174
|
+
types?: string[],
|
|
175
|
+
extensions?: string[],
|
|
176
|
+
): string | undefined {
|
|
177
|
+
const mimeTypes = expandFileAcceptTypesToMime(types)
|
|
178
|
+
const normalizedExtensions = filterFileAcceptExtensionsForTypes(types, extensions)
|
|
179
|
+
const parts = [...mimeTypes, ...normalizedExtensions]
|
|
180
|
+
return parts.length > 0 ? parts.join(',') : undefined
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function formatFileAcceptSummary(
|
|
184
|
+
types?: string[],
|
|
185
|
+
extensions?: string[],
|
|
186
|
+
): string | null {
|
|
187
|
+
const normalizedTypes = normalizeFileAcceptTypes(types)
|
|
188
|
+
const normalizedExtensions = filterFileAcceptExtensionsForTypes(
|
|
189
|
+
normalizedTypes,
|
|
190
|
+
extensions,
|
|
191
|
+
)
|
|
192
|
+
const labels = [
|
|
193
|
+
...normalizedTypes.map((item) => FILE_TYPE_LABELS[item] ?? item),
|
|
194
|
+
...normalizedExtensions.map((item) => FILE_EXTENSION_LABELS[item] ?? item),
|
|
195
|
+
]
|
|
196
|
+
if (labels.length === 0) return null
|
|
197
|
+
return labels.join('、')
|
|
198
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { FormFilePlaceholderIcon } from '../types/index'
|
|
2
|
+
|
|
3
|
+
export const FORM_FILE_PLACEHOLDER_ICON_VALUES: FormFilePlaceholderIcon[] = [
|
|
4
|
+
'video',
|
|
5
|
+
'audio',
|
|
6
|
+
'image',
|
|
7
|
+
'file',
|
|
8
|
+
'spreadsheet',
|
|
9
|
+
'document',
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
export const FORM_FILE_PLACEHOLDER_ICON_LABELS: Record<
|
|
13
|
+
FormFilePlaceholderIcon,
|
|
14
|
+
string
|
|
15
|
+
> = {
|
|
16
|
+
video: '视频',
|
|
17
|
+
audio: '音频',
|
|
18
|
+
image: '图片',
|
|
19
|
+
file: '文件',
|
|
20
|
+
spreadsheet: '表格',
|
|
21
|
+
document: '文档',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const PLACEHOLDER_ICON_VALUE_SET = new Set<string>(FORM_FILE_PLACEHOLDER_ICON_VALUES)
|
|
25
|
+
|
|
26
|
+
export function normalizeFilePlaceholderIcon(
|
|
27
|
+
value?: string,
|
|
28
|
+
): FormFilePlaceholderIcon {
|
|
29
|
+
if (value && PLACEHOLDER_ICON_VALUE_SET.has(value)) {
|
|
30
|
+
return value as FormFilePlaceholderIcon
|
|
31
|
+
}
|
|
32
|
+
return 'file'
|
|
33
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { FormBlock, FormMediaSize } from '../types/index'
|
|
2
|
+
import { FORM_MEDIA_SIZES } from '../types/index'
|
|
3
|
+
|
|
4
|
+
export function normalizeMediaSize(raw: unknown): FormMediaSize {
|
|
5
|
+
if (
|
|
6
|
+
typeof raw === 'string' &&
|
|
7
|
+
FORM_MEDIA_SIZES.includes(raw as FormMediaSize)
|
|
8
|
+
) {
|
|
9
|
+
return raw as FormMediaSize
|
|
10
|
+
}
|
|
11
|
+
return 'medium'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function normalizeMediaUrls(
|
|
15
|
+
raw: Pick<FormBlock, 'mediaUrls' | 'mediaUrl'>,
|
|
16
|
+
maxCount: number,
|
|
17
|
+
): string[] {
|
|
18
|
+
const limit = Math.max(1, maxCount)
|
|
19
|
+
let urls: string[] = []
|
|
20
|
+
|
|
21
|
+
if (Array.isArray(raw.mediaUrls)) {
|
|
22
|
+
urls = raw.mediaUrls.map((item) => String(item).trim())
|
|
23
|
+
} else if (raw.mediaUrl?.trim()) {
|
|
24
|
+
urls = [raw.mediaUrl.trim()]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (urls.length === 0) {
|
|
28
|
+
return ['']
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return urls.slice(0, limit)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getMediaUrls(block: Pick<FormBlock, 'mediaUrls' | 'mediaUrl'>): string[] {
|
|
35
|
+
if (Array.isArray(block.mediaUrls)) {
|
|
36
|
+
return block.mediaUrls.map((item) => item.trim()).filter(Boolean)
|
|
37
|
+
}
|
|
38
|
+
if (block.mediaUrl?.trim()) {
|
|
39
|
+
return [block.mediaUrl.trim()]
|
|
40
|
+
}
|
|
41
|
+
return []
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getMediaSizeClass(size: FormMediaSize | undefined): string {
|
|
45
|
+
return `form-media-size-${normalizeMediaSize(size)}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildMediaListClassName(size: FormMediaSize | undefined): string {
|
|
49
|
+
return `form-media-preview form-media-list ${getMediaSizeClass(size)}`
|
|
50
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { FormBlock, FormBlockType, FormSchema } from '../types/index'
|
|
2
|
+
import { isInputBlockType, isMediaBlockType } from '../types/index'
|
|
3
|
+
import { generateId } from './id'
|
|
4
|
+
import {
|
|
5
|
+
filterFileAcceptExtensionsForTypes,
|
|
6
|
+
normalizeFileAcceptTypes,
|
|
7
|
+
} from './formFileAccept'
|
|
8
|
+
import { normalizeFilePlaceholderIcon } from './formFilePlaceholderIcon'
|
|
9
|
+
import {
|
|
10
|
+
normalizeMediaSize,
|
|
11
|
+
normalizeMediaUrls,
|
|
12
|
+
} from './formMedia'
|
|
13
|
+
|
|
14
|
+
const DEFAULT_MEDIA_URL_MAX = 10
|
|
15
|
+
|
|
16
|
+
function normalizeFileUploadCount(raw: unknown, fallback = 1): number {
|
|
17
|
+
const num = typeof raw === 'number' ? raw : Number(raw)
|
|
18
|
+
if (!Number.isFinite(num)) return fallback
|
|
19
|
+
return Math.max(0, Math.floor(num))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeDefaultValue(
|
|
23
|
+
type: FormBlockType,
|
|
24
|
+
raw: FormBlock['defaultValue'],
|
|
25
|
+
): FormBlock['defaultValue'] {
|
|
26
|
+
if (raw === undefined) return undefined
|
|
27
|
+
if (type === 'toggle') return raw === true
|
|
28
|
+
if (type === 'multiselect' || type === 'checkbox') {
|
|
29
|
+
if (Array.isArray(raw)) return raw.map(String)
|
|
30
|
+
if (typeof raw === 'string' && raw) return [raw]
|
|
31
|
+
return []
|
|
32
|
+
}
|
|
33
|
+
if (typeof raw === 'boolean') return raw ? 'true' : 'false'
|
|
34
|
+
if (Array.isArray(raw)) return raw.map(String)
|
|
35
|
+
return String(raw)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeBlock(raw: FormBlock): FormBlock {
|
|
39
|
+
const fileAcceptTypes = normalizeFileAcceptTypes(raw.fileAcceptTypes)
|
|
40
|
+
const type = raw.type
|
|
41
|
+
return {
|
|
42
|
+
id: raw.id?.trim() || generateId('block-'),
|
|
43
|
+
type,
|
|
44
|
+
name: raw.name?.trim() || undefined,
|
|
45
|
+
label: raw.label ?? '',
|
|
46
|
+
placeholder: raw.placeholder ?? '',
|
|
47
|
+
help: raw.help ?? '',
|
|
48
|
+
required: raw.required === true,
|
|
49
|
+
mediaUrls: isMediaBlockType(type)
|
|
50
|
+
? normalizeMediaUrls(raw, DEFAULT_MEDIA_URL_MAX)
|
|
51
|
+
: undefined,
|
|
52
|
+
mediaSize: isMediaBlockType(type) ? normalizeMediaSize(raw.mediaSize) : undefined,
|
|
53
|
+
fileAcceptTypes,
|
|
54
|
+
fileAcceptExtensions: filterFileAcceptExtensionsForTypes(
|
|
55
|
+
fileAcceptTypes,
|
|
56
|
+
raw.fileAcceptExtensions,
|
|
57
|
+
),
|
|
58
|
+
filePlaceholderIcon: normalizeFilePlaceholderIcon(raw.filePlaceholderIcon),
|
|
59
|
+
fileMinCount: type === 'file' ? normalizeFileUploadCount(raw.fileMinCount, 1) : undefined,
|
|
60
|
+
fileMaxCount: type === 'file' ? normalizeFileUploadCount(raw.fileMaxCount, 1) : undefined,
|
|
61
|
+
defaultValue: normalizeDefaultValue(type, raw.defaultValue),
|
|
62
|
+
options: Array.isArray(raw.options)
|
|
63
|
+
? raw.options.map((opt, index) => ({
|
|
64
|
+
value: opt.value?.trim() || `option_${index + 1}`,
|
|
65
|
+
label: opt.label?.trim() || `选项 ${index + 1}`,
|
|
66
|
+
}))
|
|
67
|
+
: undefined,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function syncFormDefinition(definition: FormSchema): FormSchema {
|
|
72
|
+
return {
|
|
73
|
+
title: definition.title ?? '',
|
|
74
|
+
description: definition.description ?? '',
|
|
75
|
+
blocks: definition.blocks,
|
|
76
|
+
jsonSchema: definition.jsonSchema ?? {},
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function normalizeFormDefinition(raw: FormSchema | undefined): FormSchema {
|
|
81
|
+
if (!raw || !Array.isArray(raw.blocks)) {
|
|
82
|
+
return syncFormDefinition({ title: '', description: '', blocks: [], jsonSchema: {} })
|
|
83
|
+
}
|
|
84
|
+
const blocks = raw.blocks.map(normalizeBlock)
|
|
85
|
+
return syncFormDefinition({
|
|
86
|
+
title: typeof raw.title === 'string' ? raw.title : '',
|
|
87
|
+
description: typeof raw.description === 'string' ? raw.description : '',
|
|
88
|
+
blocks,
|
|
89
|
+
jsonSchema: {},
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createEmptyFormDefinition(): FormSchema {
|
|
94
|
+
return syncFormDefinition({ title: '', description: '', blocks: [], jsonSchema: {} })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createEmptyBlock(type: FormBlockType): FormBlock {
|
|
98
|
+
const block: FormBlock = {
|
|
99
|
+
id: generateId('block-'),
|
|
100
|
+
type,
|
|
101
|
+
label: type,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (isInputBlockType(type)) {
|
|
105
|
+
block.label = ''
|
|
106
|
+
block.name = generateId('field_')
|
|
107
|
+
block.placeholder = ''
|
|
108
|
+
block.required = false
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (isMediaBlockType(type)) {
|
|
112
|
+
block.label = ''
|
|
113
|
+
block.placeholder = ''
|
|
114
|
+
block.help = ''
|
|
115
|
+
block.mediaUrls = ['']
|
|
116
|
+
block.mediaSize = 'medium'
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (isOptionsBlockType(type)) {
|
|
120
|
+
block.options = [
|
|
121
|
+
{ value: 'option_1', label: '选项 1' },
|
|
122
|
+
{ value: 'option_2', label: '选项 2' },
|
|
123
|
+
]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (type === 'file') {
|
|
127
|
+
block.fileAcceptTypes = []
|
|
128
|
+
block.fileAcceptExtensions = []
|
|
129
|
+
block.filePlaceholderIcon = 'file'
|
|
130
|
+
block.fileMinCount = 1
|
|
131
|
+
block.fileMaxCount = 1
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return block
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isOptionsBlockType(type: FormBlockType): boolean {
|
|
138
|
+
return type === 'select' || type === 'multiselect' || type === 'radio' || type === 'checkbox'
|
|
139
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** 生成 UUID v4;内网 HTTP 等非 Secure Context 下 randomUUID 不可用,回退 getRandomValues / Math.random。 */
|
|
2
|
+
export function randomUuid(): string {
|
|
3
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
4
|
+
return crypto.randomUUID()
|
|
5
|
+
}
|
|
6
|
+
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
|
7
|
+
const bytes = new Uint8Array(16)
|
|
8
|
+
crypto.getRandomValues(bytes)
|
|
9
|
+
bytes[6] = (bytes[6]! & 0x0f) | 0x40
|
|
10
|
+
bytes[8] = (bytes[8]! & 0x3f) | 0x80
|
|
11
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
|
|
12
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
|
13
|
+
}
|
|
14
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
15
|
+
const r = (Math.random() * 16) | 0
|
|
16
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8
|
|
17
|
+
return v.toString(16)
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function generateId(prefix: string): string {
|
|
22
|
+
const suffix = randomUuid().replace(/-/g, '').slice(0, 8)
|
|
23
|
+
return `${prefix}${suffix}`
|
|
24
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { A2uiSurfaceDoc, FormBlock, FormDefinitionPayload, FormSchema } from '../types/index'
|
|
2
|
+
import { a2uiToBlocks, blocksToA2ui, isA2uiSurfaceEmpty, type BlocksToA2uiOptions } from './a2uiAdapter'
|
|
3
|
+
import { normalizeFormDefinition } from './formSchema'
|
|
4
|
+
|
|
5
|
+
function isA2uiSurfaceDoc(raw: unknown): raw is A2uiSurfaceDoc {
|
|
6
|
+
return (
|
|
7
|
+
!!raw &&
|
|
8
|
+
typeof raw === 'object' &&
|
|
9
|
+
!Array.isArray(raw) &&
|
|
10
|
+
Array.isArray((raw as Record<string, unknown>).components)
|
|
11
|
+
)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveSurfaceFromFormDefinition(
|
|
15
|
+
raw: FormDefinitionPayload | FormSchema | undefined,
|
|
16
|
+
options?: BlocksToA2uiOptions,
|
|
17
|
+
): A2uiSurfaceDoc | null {
|
|
18
|
+
if (!raw || typeof raw !== 'object') return null
|
|
19
|
+
const payload = raw as FormDefinitionPayload
|
|
20
|
+
const a2ui = isA2uiSurfaceDoc(payload.a2ui) ? payload.a2ui : undefined
|
|
21
|
+
if (a2ui && !isA2uiSurfaceEmpty(a2ui)) return a2ui
|
|
22
|
+
|
|
23
|
+
const blocks = Array.isArray(payload.blocks) ? payload.blocks : []
|
|
24
|
+
if (blocks.length === 0) return null
|
|
25
|
+
|
|
26
|
+
const normalized = normalizeFormDefinition({
|
|
27
|
+
title: typeof payload.title === 'string' ? payload.title : '',
|
|
28
|
+
description: typeof payload.description === 'string' ? payload.description : '',
|
|
29
|
+
blocks,
|
|
30
|
+
jsonSchema: {},
|
|
31
|
+
})
|
|
32
|
+
const doc = blocksToA2ui(normalized, options)
|
|
33
|
+
return isA2uiSurfaceEmpty(doc) ? null : doc
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function buildAskUserSurface(
|
|
37
|
+
formDef: Pick<FormSchema, 'title' | 'description'>,
|
|
38
|
+
matchedBlocks: FormBlock[],
|
|
39
|
+
options?: BlocksToA2uiOptions,
|
|
40
|
+
): A2uiSurfaceDoc {
|
|
41
|
+
return blocksToA2ui(
|
|
42
|
+
{
|
|
43
|
+
title: formDef.title,
|
|
44
|
+
description: formDef.description,
|
|
45
|
+
blocks: matchedBlocks,
|
|
46
|
+
},
|
|
47
|
+
options,
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function mapFormDefinitionFromDto(raw: unknown): FormSchema | undefined {
|
|
52
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
const obj = raw as Record<string, unknown>
|
|
56
|
+
const hasBlocks = Array.isArray(obj.blocks) && obj.blocks.length > 0
|
|
57
|
+
if (!hasBlocks && isA2uiSurfaceDoc(obj.a2ui)) {
|
|
58
|
+
return a2uiToBlocks(obj.a2ui)
|
|
59
|
+
}
|
|
60
|
+
return normalizeFormDefinition({
|
|
61
|
+
title: typeof obj.title === 'string' ? obj.title : '',
|
|
62
|
+
description: typeof obj.description === 'string' ? obj.description : '',
|
|
63
|
+
blocks: Array.isArray(obj.blocks) ? (obj.blocks as FormSchema['blocks']) : [],
|
|
64
|
+
jsonSchema: {},
|
|
65
|
+
})
|
|
66
|
+
}
|