@skopon-cool/form-sdk 0.1.0 → 0.1.3

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 (53) hide show
  1. package/README.md +52 -12
  2. package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
  3. package/dist/adapter/formFileAccept.d.ts.map +1 -1
  4. package/dist/adapter/formSchema.d.ts +1 -0
  5. package/dist/adapter/formSchema.d.ts.map +1 -1
  6. package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
  7. package/dist/catalog/textFieldPreview.test.d.ts +2 -0
  8. package/dist/catalog/textFieldPreview.test.d.ts.map +1 -0
  9. package/dist/catalog/useSkoponBoundField.d.ts +2 -0
  10. package/dist/catalog/useSkoponBoundField.d.ts.map +1 -1
  11. package/dist/client/formClient.d.ts.map +1 -1
  12. package/dist/components/AskUserFormCard.d.ts +3 -1
  13. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  14. package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -1
  15. package/dist/components/SkoponA2uiStreamRenderer.test.d.ts +2 -0
  16. package/dist/components/SkoponA2uiStreamRenderer.test.d.ts.map +1 -0
  17. package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
  18. package/dist/form-sdk.css +1 -1
  19. package/dist/index.d.ts +2 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +998 -667
  22. package/dist/submit/buildCurlStatement.d.ts +8 -0
  23. package/dist/submit/buildCurlStatement.d.ts.map +1 -1
  24. package/dist/submit/intersectPayloadBlocksWithForm.d.ts +26 -0
  25. package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -0
  26. package/dist/submit/submitFormJson.d.ts.map +1 -1
  27. package/package.json +16 -5
  28. package/src/adapter/a2uiAdapter.test.ts +91 -0
  29. package/src/adapter/a2uiAdapter.ts +36 -7
  30. package/src/adapter/formFileAccept.test.ts +53 -0
  31. package/src/adapter/formFileAccept.ts +11 -2
  32. package/src/adapter/formSchema.test.ts +35 -0
  33. package/src/adapter/formSchema.ts +5 -1
  34. package/src/catalog/a2uiCustomCatalog.tsx +154 -5
  35. package/src/catalog/textFieldPreview.test.tsx +88 -0
  36. package/src/catalog/useSkoponBoundField.test.ts +62 -0
  37. package/src/catalog/useSkoponBoundField.ts +10 -1
  38. package/src/client/formClient.test.ts +83 -0
  39. package/src/client/formClient.ts +10 -2
  40. package/src/components/AskUserFormCard.tsx +136 -58
  41. package/src/components/SkoponA2uiStreamRenderer.test.ts +62 -0
  42. package/src/components/SkoponA2uiStreamRenderer.test.tsx +79 -0
  43. package/src/components/SkoponA2uiStreamRenderer.tsx +96 -15
  44. package/src/components/SkoponFormRenderer.tsx +10 -7
  45. package/src/index.ts +11 -2
  46. package/src/styles/index.css +5 -0
  47. package/src/submit/buildCurlStatement.ts +49 -0
  48. package/src/submit/intersectPayloadBlocksWithForm.ts +175 -0
  49. package/src/submit/submit.test.ts +170 -10
  50. package/src/submit/submitFormJson.ts +20 -1
  51. package/dist/submit/intersectPayloadWithForm.d.ts +0 -17
  52. package/dist/submit/intersectPayloadWithForm.d.ts.map +0 -1
  53. package/src/submit/intersectPayloadWithForm.ts +0 -54
@@ -0,0 +1,79 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'
3
+
4
+ vi.mock('@a2ui/web_core/v0_9/basic_catalog', async (importOriginal) => {
5
+ const actual = await importOriginal<typeof import('@a2ui/web_core/v0_9/basic_catalog')>()
6
+ return { ...actual, injectBasicCatalogStyles: vi.fn() }
7
+ })
8
+
9
+ vi.mock('@a2ui/react/styles', () => ({
10
+ injectStyles: vi.fn(),
11
+ removeStyles: vi.fn(),
12
+ }))
13
+
14
+ import { createElement } from 'react'
15
+ import { render, cleanup } from '@testing-library/react'
16
+ import type { A2uiMessage } from '@a2ui/web_core/v0_9'
17
+ import { blocksToA2ui, surfaceDocToMessages } from '../adapter/a2uiAdapter'
18
+ import { SKOPON_CATALOG_ID } from '../catalog/a2uiCustomCatalog'
19
+ import SkoponA2uiStreamRenderer from './SkoponA2uiStreamRenderer'
20
+
21
+ function buildStreamMessages(surfaceId: string): A2uiMessage[] {
22
+ const doc = blocksToA2ui(
23
+ {
24
+ title: '',
25
+ description: '',
26
+ blocks: [{ id: 'b-name', type: 'text', name: 'name', label: '姓名' }],
27
+ },
28
+ { includeHeader: false },
29
+ )
30
+ return surfaceDocToMessages(doc, { surfaceId, catalogId: SKOPON_CATALOG_ID }) as unknown as A2uiMessage[]
31
+ }
32
+
33
+ beforeAll(() => {
34
+ class ResizeObserverMock {
35
+ observe() {}
36
+ unobserve() {}
37
+ disconnect() {}
38
+ }
39
+ vi.stubGlobal('ResizeObserver', ResizeObserverMock)
40
+ })
41
+
42
+ describe('SkoponA2uiStreamRenderer component', () => {
43
+ afterEach(() => {
44
+ cleanup()
45
+ })
46
+
47
+ it('mounts with empty messages without infinite update loop', () => {
48
+ expect(() =>
49
+ render(
50
+ createElement(SkoponA2uiStreamRenderer, {
51
+ messages: [],
52
+ emptyHint: createElement('span', null, 'stream-empty'),
53
+ }),
54
+ ),
55
+ ).not.toThrow()
56
+ })
57
+
58
+ it('reprocesses messages when surfaceId changes', () => {
59
+ const surfaceA = 'stream-a'
60
+ const surfaceB = 'stream-b'
61
+ const messages = buildStreamMessages(surfaceA)
62
+ const { rerender, getByText, queryByText } = render(
63
+ createElement(SkoponA2uiStreamRenderer, {
64
+ messages,
65
+ surfaceId: surfaceA,
66
+ }),
67
+ )
68
+ expect(getByText('姓名')).toBeTruthy()
69
+
70
+ rerender(
71
+ createElement(SkoponA2uiStreamRenderer, {
72
+ messages,
73
+ surfaceId: surfaceB,
74
+ }),
75
+ )
76
+ expect(getByText('姓名')).toBeTruthy()
77
+ expect(queryByText('stream-empty')).toBeNull()
78
+ })
79
+ })
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
1
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
2
2
  import { MessageProcessor, type A2uiMessage } from '@a2ui/web_core/v0_9'
3
3
  import { injectBasicCatalogStyles } from '@a2ui/web_core/v0_9/basic_catalog'
4
4
  import { A2uiSurface, type ReactComponentImplementation } from '@a2ui/react/v0_9'
@@ -14,6 +14,10 @@ export interface SkoponA2uiStreamRendererProps {
14
14
  interactive?: boolean
15
15
  }
16
16
 
17
+ function createProcessor(): MessageProcessor<ReactComponentImplementation> {
18
+ return new MessageProcessor([buildSkoponCatalog()])
19
+ }
20
+
17
21
  export default function SkoponA2uiStreamRenderer({
18
22
  messages,
19
23
  surfaceId = 'skopon-form-stream',
@@ -25,20 +29,32 @@ export default function SkoponA2uiStreamRenderer({
25
29
  injectBasicCatalogStyles()
26
30
  }, [])
27
31
 
28
- const processorRef = useRef<MessageProcessor<ReactComponentImplementation> | null>(null)
32
+ const processorRef = useRef<MessageProcessor<ReactComponentImplementation>>(createProcessor())
29
33
  const processedCountRef = useRef(0)
34
+ const mountedRef = useRef(true)
35
+ const surfaceIdRef = useRef(surfaceId)
36
+
37
+ const [processor, setProcessor] = useState(() => processorRef.current)
38
+ const [surfaces, setSurfaces] = useState(() =>
39
+ Array.from(processorRef.current.model.surfacesMap.values()),
40
+ )
30
41
 
31
- const processor = useMemo(() => {
32
- const p = new MessageProcessor([buildSkoponCatalog()])
33
- processorRef.current = p
34
- processedCountRef.current = 0
35
- return p
36
- }, [surfaceId])
42
+ useEffect(() => {
43
+ mountedRef.current = true
44
+ return () => {
45
+ mountedRef.current = false
46
+ }
47
+ }, [])
37
48
 
38
- const [surfaces, setSurfaces] = useState(() => Array.from(processor.model.surfacesMap.values()))
49
+ useEffect(() => {
50
+ processorRef.current = processor
51
+ }, [processor])
39
52
 
40
53
  useEffect(() => {
41
- const sync = () => setSurfaces(Array.from(processor.model.surfacesMap.values()))
54
+ const sync = () => {
55
+ if (!mountedRef.current) return
56
+ setSurfaces(Array.from(processor.model.surfacesMap.values()))
57
+ }
42
58
  const createdSub = processor.onSurfaceCreated(sync)
43
59
  const deletedSub = processor.onSurfaceDeleted(sync)
44
60
  return () => {
@@ -48,13 +64,78 @@ export default function SkoponA2uiStreamRenderer({
48
64
  }, [processor])
49
65
 
50
66
  useEffect(() => {
51
- if (!Array.isArray(messages) || messages.length === 0) return
67
+ let cancelled = false
68
+ const surfaceIdChanged = surfaceIdRef.current !== surfaceId
69
+ surfaceIdRef.current = surfaceId
70
+
71
+ if (surfaceIdChanged) {
72
+ const next = createProcessor()
73
+ processorRef.current = next
74
+ processedCountRef.current = 0
75
+ if (Array.isArray(messages) && messages.length > 0) {
76
+ next.processMessages(messages)
77
+ processedCountRef.current = messages.length
78
+ if (!cancelled) {
79
+ setProcessor(next)
80
+ setSurfaces(Array.from(next.model.surfacesMap.values()))
81
+ }
82
+ } else if (!cancelled) {
83
+ setProcessor(next)
84
+ setSurfaces([])
85
+ }
86
+ return () => {
87
+ cancelled = true
88
+ }
89
+ }
90
+
91
+ if (!Array.isArray(messages) || messages.length === 0) {
92
+ if (processedCountRef.current > 0) {
93
+ const next = createProcessor()
94
+ processorRef.current = next
95
+ processedCountRef.current = 0
96
+ if (!cancelled) {
97
+ setProcessor(next)
98
+ setSurfaces([])
99
+ }
100
+ }
101
+ return () => {
102
+ cancelled = true
103
+ }
104
+ }
105
+
106
+ const activeProcessor = processorRef.current
107
+
108
+ if (messages.length < processedCountRef.current) {
109
+ const next = createProcessor()
110
+ processorRef.current = next
111
+ processedCountRef.current = 0
112
+ next.processMessages(messages)
113
+ processedCountRef.current = messages.length
114
+ if (!cancelled) {
115
+ setProcessor(next)
116
+ setSurfaces(Array.from(next.model.surfacesMap.values()))
117
+ }
118
+ return () => {
119
+ cancelled = true
120
+ }
121
+ }
122
+
52
123
  const pending = messages.slice(processedCountRef.current)
53
- if (pending.length === 0) return
54
- processor.processMessages(pending)
124
+ if (pending.length === 0) {
125
+ return () => {
126
+ cancelled = true
127
+ }
128
+ }
129
+ activeProcessor.processMessages(pending)
55
130
  processedCountRef.current = messages.length
56
- setSurfaces(Array.from(processor.model.surfacesMap.values()))
57
- }, [messages, processor])
131
+ if (!cancelled) {
132
+ setSurfaces(Array.from(activeProcessor.model.surfacesMap.values()))
133
+ }
134
+
135
+ return () => {
136
+ cancelled = true
137
+ }
138
+ }, [messages, surfaceId])
58
139
 
59
140
  if (surfaces.length === 0) return <>{emptyHint}</>
60
141
 
@@ -49,10 +49,9 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
49
49
  const fieldNamesRef = useRef(fieldNames)
50
50
  fieldNamesRef.current = fieldNames
51
51
 
52
- const surfaces = useMemo(() => {
52
+ const renderResult = useMemo(() => {
53
53
  if (isA2uiSurfaceEmpty(doc)) {
54
- processorRef.current = null
55
- return []
54
+ return { surfaces: [], processor: null as MessageProcessor<ReactComponentImplementation> | null }
56
55
  }
57
56
  try {
58
57
  const processor = new MessageProcessor([buildSkoponCatalog()])
@@ -61,15 +60,19 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
61
60
  catalogId: SKOPON_CATALOG_ID,
62
61
  }) as unknown as A2uiMessage[]
63
62
  processor.processMessages(messages)
64
- processorRef.current = processor
65
- return Array.from(processor.model.surfacesMap.values())
63
+ return {
64
+ surfaces: Array.from(processor.model.surfacesMap.values()),
65
+ processor,
66
+ }
66
67
  } catch (error) {
67
68
  console.error('[SkoponFormRenderer] 渲染 A2UI surface 失败', error)
68
- processorRef.current = null
69
- return []
69
+ return { surfaces: [], processor: null as MessageProcessor<ReactComponentImplementation> | null }
70
70
  }
71
71
  }, [doc, surfaceId])
72
72
 
73
+ const { surfaces, processor } = renderResult
74
+ processorRef.current = processor
75
+
73
76
  useImperativeHandle(
74
77
  ref,
75
78
  () => ({
package/src/index.ts CHANGED
@@ -41,8 +41,17 @@ export { normalizeFormDefinition, syncFormDefinition } from './adapter/formSchem
41
41
 
42
42
  export { extractSurfaceValues } from './adapter/extractSurfaceValues'
43
43
 
44
- export { buildCurlStatement } from './submit/buildCurlStatement'
45
- export { intersectPayloadWithForm, type PayloadFormIntersection } from './submit/intersectPayloadWithForm'
44
+ export { buildCurlStatement, buildAskUserCurlStatement, buildAskUserCurlBodyJson } from './submit/buildCurlStatement'
45
+ export {
46
+ extractExtraBlockValues,
47
+ getPayloadInputFieldNames,
48
+ getPayloadRenderableBlocks,
49
+ intersectPayloadBlocksWithForm,
50
+ parsePayloadBlocksJson,
51
+ payloadHasInputBlocks,
52
+ type PayloadBlocksDefinition,
53
+ type PayloadBlocksIntersection,
54
+ } from './submit/intersectPayloadBlocksWithForm'
46
55
  export {
47
56
  copyTextToClipboard,
48
57
  submitFormJson,
@@ -132,6 +132,11 @@
132
132
  min-width: 0;
133
133
  }
134
134
 
135
+ .ask-user-form-card-status {
136
+ display: flex;
137
+ justify-content: flex-end;
138
+ }
139
+
135
140
  .ask-user-form-actions {
136
141
  display: flex;
137
142
  justify-content: flex-end;
@@ -11,3 +11,52 @@ export function buildCurlStatement(payload: unknown, callbackUrl?: string | null
11
11
  ` -d '${escapeSingleQuotes(body)}'`,
12
12
  ].join('\n')
13
13
  }
14
+
15
+ function formatJsonBodyLine(key: string, value: unknown, trailingComma: boolean): string {
16
+ return ` ${JSON.stringify(key)}: ${JSON.stringify(value)}${trailingComma ? ',' : ''}`
17
+ }
18
+
19
+ /** 构建含行注释的 JSON body:extra 段标注「额外字段(未在卡片展示)」 */
20
+ export function buildAskUserCurlBodyJson(
21
+ cardValues: Record<string, unknown>,
22
+ extraValues: Record<string, unknown>,
23
+ ): string {
24
+ const lines: string[] = ['{']
25
+ const cardEntries = Object.entries(cardValues)
26
+ const extraEntries = Object.entries(extraValues)
27
+
28
+ cardEntries.forEach(([key, value], index) => {
29
+ const hasMore = index < cardEntries.length - 1 || extraEntries.length > 0
30
+ lines.push(formatJsonBodyLine(key, value, hasMore))
31
+ })
32
+
33
+ if (extraEntries.length > 0) {
34
+ lines.push(' // 额外字段(未在卡片展示)')
35
+ extraEntries.forEach(([key, value], index) => {
36
+ lines.push(formatJsonBodyLine(key, value, index < extraEntries.length - 1))
37
+ })
38
+ }
39
+
40
+ lines.push('}')
41
+ return lines.join('\n')
42
+ }
43
+
44
+ export interface BuildAskUserCurlStatementOptions {
45
+ cardValues: Record<string, unknown>
46
+ extraValues?: Record<string, unknown>
47
+ callbackUrl?: string | null
48
+ }
49
+
50
+ export function buildAskUserCurlStatement({
51
+ cardValues,
52
+ extraValues = {},
53
+ callbackUrl,
54
+ }: BuildAskUserCurlStatementOptions): string {
55
+ const url = (callbackUrl ?? '').trim() || '<callback_url>'
56
+ const body = buildAskUserCurlBodyJson(cardValues, extraValues)
57
+ return [
58
+ `curl -X POST '${url}' \\`,
59
+ ` -H 'Content-Type: application/json' \\`,
60
+ ` -d '${escapeSingleQuotes(body)}'`,
61
+ ].join('\n')
62
+ }
@@ -0,0 +1,175 @@
1
+ import { normalizeFormDefinition } from '../adapter/formSchema'
2
+ import type { FormBlock, FormSchema } from '../types/index'
3
+ import { isInputBlockType, isLayoutBlockType } from '../types/index'
4
+
5
+ export type PayloadBlocksDefinition = Pick<FormSchema, 'title' | 'description' | 'blocks'>
6
+
7
+ export interface PayloadBlocksIntersection {
8
+ matchedBlocks: FormBlock[]
9
+ extraBlocks: FormBlock[]
10
+ /** 按 payload 顺序:layout + matched input(不含 extra input) */
11
+ renderBlocks: FormBlock[]
12
+ title: string
13
+ description: string
14
+ }
15
+
16
+ function normalizePayloadBlock(block: FormBlock): FormBlock {
17
+ return normalizeFormDefinition({
18
+ title: '',
19
+ description: '',
20
+ blocks: [block],
21
+ jsonSchema: {},
22
+ }).blocks[0]!
23
+ }
24
+
25
+ function buildRenderBlocks(
26
+ payloadDef: PayloadBlocksDefinition,
27
+ matchedBlocks: FormBlock[],
28
+ ): FormBlock[] {
29
+ const matchedByName = new Map<string, FormBlock>()
30
+ for (const block of matchedBlocks) {
31
+ const name = block.name?.trim()
32
+ if (name) matchedByName.set(name, block)
33
+ }
34
+
35
+ const renderBlocks: FormBlock[] = []
36
+ for (const payloadBlock of payloadDef.blocks) {
37
+ if (isLayoutBlockType(payloadBlock.type)) {
38
+ renderBlocks.push(normalizePayloadBlock(payloadBlock))
39
+ continue
40
+ }
41
+ if (!isInputBlockType(payloadBlock.type)) continue
42
+ const name = payloadBlock.name?.trim()
43
+ if (!name) continue
44
+ const matched = matchedByName.get(name)
45
+ if (matched) renderBlocks.push(matched)
46
+ }
47
+ return renderBlocks
48
+ }
49
+
50
+ /** 交集块以 form 定义为准,仅从 payload 合并运行时 defaultValue。 */
51
+ function mergeBlock(formBlock: FormBlock, payloadBlock: FormBlock): FormBlock {
52
+ const merged: FormBlock = {
53
+ ...formBlock,
54
+ name: formBlock.name?.trim() || payloadBlock.name,
55
+ }
56
+ if (payloadBlock.defaultValue !== undefined) {
57
+ merged.defaultValue = payloadBlock.defaultValue
58
+ }
59
+ return normalizeFormDefinition({
60
+ title: '',
61
+ description: '',
62
+ blocks: [merged],
63
+ jsonSchema: {},
64
+ }).blocks[0]!
65
+ }
66
+
67
+ /** 解析 ask_user payload 为 blocksJson 形(title / description / blocks) */
68
+ export function parsePayloadBlocksJson(payload: unknown): PayloadBlocksDefinition | null {
69
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null
70
+ const obj = payload as Record<string, unknown>
71
+ if (!Array.isArray(obj.blocks)) return null
72
+ return normalizeFormDefinition({
73
+ title: typeof obj.title === 'string' ? obj.title : '',
74
+ description: typeof obj.description === 'string' ? obj.description : '',
75
+ blocks: obj.blocks as FormBlock[],
76
+ jsonSchema: {},
77
+ })
78
+ }
79
+
80
+ /**
81
+ * payload.blocks 与 vt_forms 按 input 块 name 取交集用于渲染;
82
+ * payload 中 form 不存在的块归入 extraBlocks(不渲染,提交时写入 curl)。
83
+ */
84
+ export function intersectPayloadBlocksWithForm(
85
+ payloadDef: PayloadBlocksDefinition,
86
+ formDefinition: FormSchema | undefined,
87
+ ): PayloadBlocksIntersection {
88
+ const formByName = new Map<string, FormBlock>()
89
+ for (const block of formDefinition?.blocks ?? []) {
90
+ if (isInputBlockType(block.type) && block.name?.trim()) {
91
+ formByName.set(block.name.trim(), block)
92
+ }
93
+ }
94
+
95
+ const matchedBlocks: FormBlock[] = []
96
+ const extraBlocks: FormBlock[] = []
97
+
98
+ for (const payloadBlock of payloadDef.blocks) {
99
+ if (!isInputBlockType(payloadBlock.type)) continue
100
+ const name = payloadBlock.name?.trim()
101
+ if (!name) continue
102
+ const formBlock = formByName.get(name)
103
+ if (formBlock) {
104
+ matchedBlocks.push(mergeBlock(formBlock, payloadBlock))
105
+ } else {
106
+ extraBlocks.push(
107
+ normalizeFormDefinition({
108
+ title: '',
109
+ description: '',
110
+ blocks: [payloadBlock],
111
+ jsonSchema: {},
112
+ }).blocks[0]!,
113
+ )
114
+ }
115
+ }
116
+
117
+ return {
118
+ matchedBlocks,
119
+ extraBlocks,
120
+ renderBlocks: buildRenderBlocks(payloadDef, matchedBlocks),
121
+ title: payloadDef.title?.trim() || formDefinition?.title?.trim() || '',
122
+ description: payloadDef.description?.trim() || formDefinition?.description?.trim() || '',
123
+ }
124
+ }
125
+
126
+ function blockValueFromDefault(block: FormBlock): unknown {
127
+ const { type, defaultValue } = block
128
+ if (defaultValue !== undefined) {
129
+ if (type === 'toggle') return defaultValue === true || defaultValue === 'true'
130
+ if (type === 'multiselect' || type === 'checkbox') {
131
+ if (Array.isArray(defaultValue)) return defaultValue.map(String)
132
+ if (typeof defaultValue === 'string' && defaultValue) return [defaultValue]
133
+ return []
134
+ }
135
+ return defaultValue
136
+ }
137
+ if (type === 'toggle') return false
138
+ if (type === 'multiselect' || type === 'checkbox') return []
139
+ if (type === 'number') return null
140
+ return ''
141
+ }
142
+
143
+ /** 从 extraBlocks 提取提交值(优先 defaultValue) */
144
+ export function extractExtraBlockValues(extraBlocks: FormBlock[]): Record<string, unknown> {
145
+ const values: Record<string, unknown> = {}
146
+ for (const block of extraBlocks) {
147
+ if (!isInputBlockType(block.type)) continue
148
+ const name = block.name?.trim()
149
+ if (!name) continue
150
+ values[name] = blockValueFromDefault(block)
151
+ }
152
+ return values
153
+ }
154
+
155
+ /** payload 是否含至少一个具 name 的 input 块(可用于卡片 fallback) */
156
+ export function payloadHasInputBlocks(payloadDef: PayloadBlocksDefinition): boolean {
157
+ return payloadDef.blocks.some(
158
+ (block) => isInputBlockType(block.type) && Boolean(block.name?.trim()),
159
+ )
160
+ }
161
+
162
+ /** 交集为空时 fallback:payload 中可上屏的 blocks(layout + input) */
163
+ export function getPayloadRenderableBlocks(payloadDef: PayloadBlocksDefinition): FormBlock[] {
164
+ return payloadDef.blocks.filter((block) => {
165
+ if (isLayoutBlockType(block.type)) return true
166
+ return isInputBlockType(block.type) && Boolean(block.name?.trim())
167
+ })
168
+ }
169
+
170
+ /** payload 中所有 input 块 field name(fallback 提交用) */
171
+ export function getPayloadInputFieldNames(payloadDef: PayloadBlocksDefinition): string[] {
172
+ return payloadDef.blocks
173
+ .filter((block) => isInputBlockType(block.type) && block.name?.trim())
174
+ .map((block) => block.name!.trim())
175
+ }