@skopon-cool/form-sdk 0.4.9 → 0.5.1

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 (47) hide show
  1. package/README.md +26 -1
  2. package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
  3. package/dist/adapter/formOptions.d.ts +6 -0
  4. package/dist/adapter/formOptions.d.ts.map +1 -0
  5. package/dist/adapter/formSchema.d.ts.map +1 -1
  6. package/dist/adapter/formWeb.d.ts +14 -0
  7. package/dist/adapter/formWeb.d.ts.map +1 -0
  8. package/dist/blocks/registry.d.ts.map +1 -1
  9. package/dist/blocks/web/adapter.d.ts +8 -0
  10. package/dist/blocks/web/adapter.d.ts.map +1 -0
  11. package/dist/blocks/web/catalog.d.ts +9 -0
  12. package/dist/blocks/web/catalog.d.ts.map +1 -0
  13. package/dist/blocks/web/index.d.ts +3 -0
  14. package/dist/blocks/web/index.d.ts.map +1 -0
  15. package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
  16. package/dist/client/authStorage.d.ts +1 -1
  17. package/dist/client/authStorage.d.ts.map +1 -1
  18. package/dist/form-sdk.css +1 -1
  19. package/dist/index.d.ts +2 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1408 -1220
  22. package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -1
  23. package/dist/types/index.d.ts +7 -2
  24. package/dist/types/index.d.ts.map +1 -1
  25. package/package.json +1 -1
  26. package/src/adapter/a2uiAdapter.test.ts +96 -0
  27. package/src/adapter/a2uiAdapter.ts +3 -12
  28. package/src/adapter/formOptions.test.ts +60 -0
  29. package/src/adapter/formOptions.ts +36 -0
  30. package/src/adapter/formSchema.test.ts +68 -0
  31. package/src/adapter/formSchema.ts +19 -6
  32. package/src/adapter/formWeb.test.ts +38 -0
  33. package/src/adapter/formWeb.ts +41 -0
  34. package/src/blocks/registry.ts +6 -1
  35. package/src/blocks/web/adapter.ts +36 -0
  36. package/src/blocks/web/catalog.test.tsx +95 -0
  37. package/src/blocks/web/catalog.tsx +158 -0
  38. package/src/blocks/web/index.ts +11 -0
  39. package/src/catalog/a2uiCustomCatalog.tsx +6 -10
  40. package/src/catalog/invalidStaticOptions.test.tsx +62 -0
  41. package/src/client/authStorage.ts +1 -1
  42. package/src/index.ts +9 -0
  43. package/src/styles/index.css +87 -0
  44. package/src/submit/intersectPayloadBlocksWithForm.ts +14 -4
  45. package/src/submit/submit.test.ts +54 -2
  46. package/src/submit/validateFormSubmit.ts +2 -2
  47. package/src/types/index.ts +12 -2
@@ -0,0 +1,95 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
3
+ import { cleanup, fireEvent, render } from '@testing-library/react'
4
+ import { buildSkoponCatalog } from '../../catalog/a2uiCustomCatalog'
5
+ import { SkoponWebPreview } from './catalog'
6
+
7
+ beforeAll(() => {
8
+ const getComputedStyle = window.getComputedStyle.bind(window)
9
+ vi.spyOn(window, 'getComputedStyle').mockImplementation((element) =>
10
+ getComputedStyle(element),
11
+ )
12
+ class ResizeObserverMock {
13
+ observe() {}
14
+ unobserve() {}
15
+ disconnect() {}
16
+ }
17
+ vi.stubGlobal('ResizeObserver', ResizeObserverMock)
18
+ vi.stubGlobal(
19
+ 'matchMedia',
20
+ vi.fn().mockImplementation((query: string) => ({
21
+ matches: false,
22
+ media: query,
23
+ onchange: null,
24
+ addListener: vi.fn(),
25
+ removeListener: vi.fn(),
26
+ addEventListener: vi.fn(),
27
+ removeEventListener: vi.fn(),
28
+ dispatchEvent: vi.fn(),
29
+ })),
30
+ )
31
+ })
32
+
33
+ afterEach(() => {
34
+ cleanup()
35
+ })
36
+
37
+ function activeFrames(url: string): HTMLIFrameElement[] {
38
+ return Array.from(document.querySelectorAll<HTMLIFrameElement>('iframe')).filter(
39
+ (frame) => frame.getAttribute('src') === url,
40
+ )
41
+ }
42
+
43
+ describe('SkoponWebPreview', () => {
44
+ it('is registered in the merged Skopon A2UI catalog', () => {
45
+ const catalog = buildSkoponCatalog()
46
+ expect(
47
+ [...catalog.components.values()].some((component) => component.name === 'SkoponWeb'),
48
+ ).toBe(true)
49
+ })
50
+
51
+ it('renders one inline iframe and moves the active URL into the modal', () => {
52
+ const url = 'https://example.com/page'
53
+ const view = render(
54
+ <SkoponWebPreview
55
+ url={url}
56
+ height={480}
57
+ label="官网"
58
+ help="可扩展查看"
59
+ />,
60
+ )
61
+
62
+ const inlineFrame = view.getByTitle('官网') as HTMLIFrameElement
63
+ expect(inlineFrame.style.height).toBe('480px')
64
+ expect(inlineFrame.getAttribute('loading')).toBe('lazy')
65
+ expect(activeFrames(url)).toHaveLength(1)
66
+ expect(view.getByText('可扩展查看')).toBeTruthy()
67
+
68
+ fireEvent.click(view.getByRole('button', { name: '扩展显示' }))
69
+
70
+ expect(document.querySelector('.skopon-web-modal')).toBeTruthy()
71
+ expect(view.queryByTitle('官网')).toBeNull()
72
+ expect(document.querySelector('iframe[title="官网(扩展显示)"]')).toBeTruthy()
73
+ expect(activeFrames(url)).toHaveLength(1)
74
+
75
+ const closeButton = document.querySelector<HTMLButtonElement>('.ant-modal-close')
76
+ expect(closeButton).toBeTruthy()
77
+ if (closeButton) fireEvent.click(closeButton)
78
+
79
+ expect(activeFrames(url)).toHaveLength(1)
80
+ expect(view.getByTitle('官网')).toBeTruthy()
81
+ })
82
+
83
+ it('shows distinct placeholders and never passes unsupported URLs to iframe', () => {
84
+ const emptyView = render(<SkoponWebPreview url="" />)
85
+ expect(emptyView.getByText('未设置网页 URL')).toBeTruthy()
86
+ expect(document.querySelector('iframe')).toBeNull()
87
+ expect(emptyView.queryByRole('button', { name: '扩展显示' })).toBeNull()
88
+ emptyView.unmount()
89
+
90
+ const invalidView = render(<SkoponWebPreview url="javascript:alert(1)" />)
91
+ expect(invalidView.getByText('网页 URL 无效')).toBeTruthy()
92
+ expect(document.querySelector('iframe')).toBeNull()
93
+ expect(invalidView.queryByRole('button', { name: '扩展显示' })).toBeNull()
94
+ })
95
+ })
@@ -0,0 +1,158 @@
1
+ import { FullscreenOutlined } from '@ant-design/icons'
2
+ import { Button, Modal, Tooltip, Typography } from 'antd'
3
+ import { useRef, useState, type Ref } from 'react'
4
+ import { createBinderlessComponentImplementation } from '@a2ui/react/v0_9'
5
+ import { z } from 'zod'
6
+ import { isSupportedWebUrl, normalizeWebHeight } from '../../adapter/formWeb'
7
+
8
+ export interface SkoponWebPreviewProps {
9
+ url?: string
10
+ height?: number
11
+ label?: string
12
+ help?: string
13
+ }
14
+
15
+ function WebFrame({
16
+ url,
17
+ title,
18
+ className,
19
+ height,
20
+ frameRef,
21
+ }: {
22
+ url: string
23
+ title: string
24
+ className: string
25
+ height?: number
26
+ frameRef?: Ref<HTMLIFrameElement>
27
+ }) {
28
+ return (
29
+ <iframe
30
+ ref={frameRef}
31
+ src={url}
32
+ title={title}
33
+ className={className}
34
+ style={height === undefined ? undefined : { height }}
35
+ loading="lazy"
36
+ referrerPolicy="strict-origin-when-cross-origin"
37
+ allowFullScreen
38
+ />
39
+ )
40
+ }
41
+
42
+ export function SkoponWebPreview({
43
+ url = '',
44
+ height,
45
+ label,
46
+ help,
47
+ }: SkoponWebPreviewProps) {
48
+ const [expanded, setExpanded] = useState(false)
49
+ const [inlineActive, setInlineActive] = useState(true)
50
+ const modalFrameRef = useRef<HTMLIFrameElement>(null)
51
+ const trimmedUrl = url.trim()
52
+ const supportedUrl = isSupportedWebUrl(trimmedUrl) ? trimmedUrl : null
53
+ const resolvedHeight = normalizeWebHeight(height)
54
+ const title = label?.trim() || '网页展示'
55
+ const helpText = help?.trim()
56
+ const emptyText = trimmedUrl ? '网页 URL 无效' : '未设置网页 URL'
57
+
58
+ return (
59
+ <div className="form-block-preview skopon-web-preview">
60
+ {label?.trim() ? <div className="form-block-preview-label">{label.trim()}</div> : null}
61
+
62
+ <div
63
+ className={`skopon-web-frame-shell${supportedUrl ? '' : ' skopon-web-frame-shell--empty'}`}
64
+ style={{ height: resolvedHeight }}
65
+ >
66
+ {supportedUrl && inlineActive ? (
67
+ <WebFrame
68
+ url={supportedUrl}
69
+ title={title}
70
+ className="skopon-web-frame skopon-web-frame--inline"
71
+ height={resolvedHeight}
72
+ />
73
+ ) : null}
74
+
75
+ {!supportedUrl ? (
76
+ <div className="skopon-web-empty" role="status">
77
+ {emptyText}
78
+ </div>
79
+ ) : null}
80
+
81
+ {supportedUrl ? (
82
+ <Tooltip title="扩展显示">
83
+ <Button
84
+ type="default"
85
+ shape="circle"
86
+ className="skopon-web-expand-button"
87
+ icon={<FullscreenOutlined />}
88
+ aria-label="扩展显示"
89
+ onClick={() => {
90
+ setInlineActive(false)
91
+ setExpanded(true)
92
+ }}
93
+ />
94
+ </Tooltip>
95
+ ) : null}
96
+ </div>
97
+
98
+ {helpText ? (
99
+ <Typography.Text type="secondary" className="form-block-preview-help">
100
+ {helpText}
101
+ </Typography.Text>
102
+ ) : null}
103
+
104
+ <Modal
105
+ open={expanded}
106
+ title={title}
107
+ footer={null}
108
+ width="min(1200px, calc(100vw - 32px))"
109
+ className="skopon-web-modal"
110
+ destroyOnHidden
111
+ mask={{ closable: true }}
112
+ onCancel={() => {
113
+ // rc-dialog 在关闭动画期间会缓存子树;先清掉 src,避免行内 iframe
114
+ // 恢复后与缓存中的弹窗 iframe 同时继续运行。
115
+ modalFrameRef.current?.removeAttribute('src')
116
+ setExpanded(false)
117
+ setInlineActive(true)
118
+ }}
119
+ >
120
+ {supportedUrl && !inlineActive ? (
121
+ <WebFrame
122
+ url={supportedUrl}
123
+ title={`${title}(扩展显示)`}
124
+ className="skopon-web-frame skopon-web-frame--modal"
125
+ frameRef={modalFrameRef}
126
+ />
127
+ ) : null}
128
+ </Modal>
129
+ </div>
130
+ )
131
+ }
132
+
133
+ const SkoponWebApi = {
134
+ name: 'SkoponWeb',
135
+ schema: z
136
+ .object({
137
+ url: z.any().optional(),
138
+ height: z.any().optional(),
139
+ label: z.any().optional(),
140
+ help: z.any().optional(),
141
+ })
142
+ .passthrough(),
143
+ }
144
+
145
+ export const SkoponWebImpl = createBinderlessComponentImplementation(
146
+ SkoponWebApi,
147
+ ({ context }) => {
148
+ const props = context.componentModel.properties as Record<string, unknown>
149
+ return (
150
+ <SkoponWebPreview
151
+ url={typeof props.url === 'string' ? props.url : ''}
152
+ height={normalizeWebHeight(props.height)}
153
+ label={typeof props.label === 'string' ? props.label : ''}
154
+ help={typeof props.help === 'string' ? props.help : ''}
155
+ />
156
+ )
157
+ },
158
+ )
@@ -0,0 +1,11 @@
1
+ import type { BlockAdapterPlugin } from '../types'
2
+ import { webFromComponent, webToComponent } from './adapter'
3
+ import { SkoponWebImpl } from './catalog'
4
+
5
+ export const webAdapter: BlockAdapterPlugin = {
6
+ type: 'web',
7
+ componentName: 'SkoponWeb',
8
+ toComponent: webToComponent,
9
+ fromComponent: webFromComponent,
10
+ catalogComponents: [SkoponWebImpl],
11
+ }
@@ -27,6 +27,7 @@ import {
27
27
  formatFileAcceptSummary,
28
28
  } from '../adapter/formFileAccept'
29
29
  import { buildMediaListClassName, normalizeMediaSize } from '../adapter/formMedia'
30
+ import { normalizeFormOptions } from '../adapter/formOptions'
30
31
  import {
31
32
  getRegisteredCatalogComponentNames,
32
33
  getRegisteredCatalogComponents,
@@ -77,16 +78,7 @@ function readString(value: unknown): string {
77
78
  function readOptions(
78
79
  raw: unknown,
79
80
  ): { label: string; value: string }[] {
80
- if (!Array.isArray(raw)) return []
81
- return raw
82
- .map((item) => {
83
- if (!item || typeof item !== 'object') return null
84
- const rec = item as Record<string, unknown>
85
- const value = String(rec.value ?? rec.label ?? '')
86
- const label = String(rec.label ?? rec.value ?? '')
87
- return { value, label }
88
- })
89
- .filter((item): item is { label: string; value: string } => !!item)
81
+ return normalizeFormOptions(raw)
90
82
  }
91
83
 
92
84
  interface SkoponMediaPreviewProps {
@@ -257,6 +249,8 @@ function SkoponSelectPreview({ context }: { context: ComponentContext }) {
257
249
  if (normalized !== null) setValueSilent(normalized)
258
250
  }, [isMultiple, value, setValueSilent])
259
251
 
252
+ if (options.length === 0) return null
253
+
260
254
  return (
261
255
  <div className="form-block-preview" data-skopon-field={fieldName || undefined}>
262
256
  {label ? <div className="form-block-preview-label">{label}</div> : null}
@@ -310,6 +304,8 @@ function ChoicePickerPreview({ context }: { context: ComponentContext }) {
310
304
  if (normalized !== null) setValueSilent(normalized)
311
305
  }, [isMultiplePicker, value, setValueSilent])
312
306
 
307
+ if (options.length === 0) return null
308
+
313
309
  return (
314
310
  <div className="form-block-preview" data-skopon-field={fieldName || undefined}>
315
311
  {label ? <div className="form-block-preview-label">{label}</div> : null}
@@ -0,0 +1,62 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeAll, describe, expect, it, vi } 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 { cleanup, render } from '@testing-library/react'
16
+ import SkoponFormRenderer from '../components/SkoponFormRenderer'
17
+
18
+ beforeAll(() => {
19
+ class ResizeObserverMock {
20
+ observe() {}
21
+ unobserve() {}
22
+ disconnect() {}
23
+ }
24
+ vi.stubGlobal('ResizeObserver', ResizeObserverMock)
25
+ })
26
+
27
+ afterEach(() => cleanup())
28
+
29
+ describe('direct A2UI static options', () => {
30
+ it('does not render an invalid empty question and keeps a numeric option question', () => {
31
+ const doc = {
32
+ root: 'root',
33
+ components: [
34
+ { id: 'root', component: 'Column', children: ['invalid', 'valid'] },
35
+ {
36
+ id: 'invalid',
37
+ component: 'SkoponSelect',
38
+ label: '不应显示的问题',
39
+ options: [{ value: null, label: '空' }, { value: {}, label: '对象' }],
40
+ value: { path: '/invalid' },
41
+ },
42
+ {
43
+ id: 'valid',
44
+ component: 'ChoicePicker',
45
+ label: '应显示的问题',
46
+ options: [{ value: 0, label: 123 }],
47
+ value: { path: '/valid' },
48
+ },
49
+ ],
50
+ }
51
+
52
+ const view = render(
53
+ createElement(SkoponFormRenderer, {
54
+ doc,
55
+ surfaceId: 'invalid-options-test',
56
+ }),
57
+ )
58
+
59
+ expect(view.queryByText('不应显示的问题')).toBeNull()
60
+ expect(view.queryByText('应显示的问题')).toBeTruthy()
61
+ })
62
+ })
@@ -1,5 +1,5 @@
1
1
  export interface SkoponAuthStorageOptions {
2
- /** 默认 sessionStorage(与 skopon-admin / HiAUTH 约定一致) */
2
+ /** 默认 sessionStorage(与 skopon-admin 会话约定一致) */
3
3
  storage?: 'sessionStorage' | 'localStorage'
4
4
  /** 默认 auth_user */
5
5
  storageKey?: string
package/src/index.ts CHANGED
@@ -27,9 +27,18 @@ export {
27
27
  isLayoutBlockType,
28
28
  isMediaBlockType,
29
29
  isOptionsBlockType,
30
+ isWebBlockType,
30
31
  supportsDefaultValue,
31
32
  } from './types/index'
32
33
 
34
+ export {
35
+ FORM_WEB_DEFAULT_HEIGHT,
36
+ FORM_WEB_MAX_HEIGHT,
37
+ FORM_WEB_MIN_HEIGHT,
38
+ isSupportedWebUrl,
39
+ normalizeWebHeight,
40
+ } from './adapter/formWeb'
41
+
33
42
  export {
34
43
  a2uiToBlocks,
35
44
  blocksToA2ui,
@@ -105,6 +105,93 @@
105
105
  text-align: center;
106
106
  }
107
107
 
108
+ /* 网页展示 */
109
+ .skopon-web-preview {
110
+ min-width: 0;
111
+ max-width: 100%;
112
+ overflow-x: hidden;
113
+ }
114
+
115
+ .skopon-web-frame-shell {
116
+ position: relative;
117
+ box-sizing: border-box;
118
+ width: 100%;
119
+ max-width: 100%;
120
+ min-width: 0;
121
+ overflow: hidden;
122
+ border: 1px solid var(--color-border-subtle);
123
+ border-radius: var(--radius-md);
124
+ background: var(--color-bg-subtle);
125
+ }
126
+
127
+ .skopon-web-frame-shell--empty {
128
+ border-style: dashed;
129
+ border-color: var(--color-border);
130
+ }
131
+
132
+ .skopon-web-frame {
133
+ display: block;
134
+ box-sizing: border-box;
135
+ width: 100%;
136
+ max-width: 100%;
137
+ min-width: 0;
138
+ border: 0;
139
+ background: var(--color-surface);
140
+ }
141
+
142
+ .skopon-web-empty {
143
+ display: flex;
144
+ width: 100%;
145
+ height: 100%;
146
+ align-items: center;
147
+ justify-content: center;
148
+ padding: var(--space-4);
149
+ color: var(--color-text-muted);
150
+ font-size: var(--font-size-sm);
151
+ text-align: center;
152
+ }
153
+
154
+ .skopon-web-expand-button.ant-btn {
155
+ position: absolute;
156
+ right: var(--space-3);
157
+ bottom: var(--space-3);
158
+ z-index: 1;
159
+ width: var(--button-height-md);
160
+ min-width: var(--button-height-md);
161
+ height: var(--button-height-md);
162
+ padding: 0;
163
+ color: var(--color-primary);
164
+ border-color: var(--color-border);
165
+ background: color-mix(in srgb, var(--color-surface) 92%, transparent);
166
+ box-shadow: var(--shadow-md);
167
+ }
168
+
169
+ .skopon-web-expand-button.ant-btn:hover {
170
+ color: var(--color-primary-hover);
171
+ border-color: var(--color-primary-hover);
172
+ background: var(--color-surface);
173
+ }
174
+
175
+ .skopon-web-expand-button.ant-btn:focus-visible {
176
+ outline: none;
177
+ box-shadow: var(--shadow-focus), var(--shadow-md);
178
+ }
179
+
180
+ .skopon-web-modal .ant-modal-content {
181
+ overflow: hidden;
182
+ border-radius: var(--radius-lg);
183
+ }
184
+
185
+ .skopon-web-modal .ant-modal-body {
186
+ padding: 0;
187
+ overflow: hidden;
188
+ }
189
+
190
+ .skopon-web-frame--modal {
191
+ height: min(80vh, 900px);
192
+ height: min(80dvh, 900px);
193
+ }
194
+
108
195
  .form-file-upload-preview {
109
196
  margin-top: var(--space-1);
110
197
  }
@@ -1,4 +1,5 @@
1
1
  import { normalizeFormDefinition } from '../adapter/formSchema'
2
+ import { hasRenderableFormOptions } from '../adapter/formOptions'
2
3
  import type { FormBlock, FormSchema } from '../types/index'
3
4
  import { isDisplayBlockType, isInputBlockType } from '../types/index'
4
5
 
@@ -58,7 +59,7 @@ function buildRenderBlocks(
58
59
  if (!matched) continue
59
60
  // hidden text 不上屏;提交值由 AskUser 从 matchedBlocks 提取
60
61
  if (isHiddenTextBlock(matched)) continue
61
- renderBlocks.push(matched)
62
+ if (hasRenderableFormOptions(matched)) renderBlocks.push(matched)
62
63
  }
63
64
  return renderBlocks
64
65
  }
@@ -191,7 +192,7 @@ function blockValueFromDefault(block: FormBlock): unknown {
191
192
  export function extractExtraBlockValues(extraBlocks: FormBlock[]): Record<string, unknown> {
192
193
  const values: Record<string, unknown> = {}
193
194
  for (const block of extraBlocks) {
194
- if (!isInputBlockType(block.type)) continue
195
+ if (!isInputBlockType(block.type) || !hasRenderableFormOptions(block)) continue
195
196
  const name = block.name?.trim()
196
197
  if (!name) continue
197
198
  values[name] = blockValueFromDefault(block)
@@ -202,7 +203,10 @@ export function extractExtraBlockValues(extraBlocks: FormBlock[]): Record<string
202
203
  /** payload 是否含至少一个具 name 的 input 块(可用于卡片 fallback) */
203
204
  export function payloadHasInputBlocks(payloadDef: PayloadBlocksDefinition): boolean {
204
205
  return payloadDef.blocks.some(
205
- (block) => isInputBlockType(block.type) && Boolean(block.name?.trim()),
206
+ (block) =>
207
+ isInputBlockType(block.type) &&
208
+ Boolean(block.name?.trim()) &&
209
+ hasRenderableFormOptions(block),
206
210
  )
207
211
  }
208
212
 
@@ -211,6 +215,7 @@ export function getPayloadRenderableBlocks(payloadDef: PayloadBlocksDefinition):
211
215
  return payloadDef.blocks.filter((block) => {
212
216
  if (isDisplayBlockType(block.type)) return true
213
217
  if (!isInputBlockType(block.type) || !block.name?.trim()) return false
218
+ if (!hasRenderableFormOptions(block)) return false
214
219
  // hidden text 不上屏;提交值由 getPayloadHiddenBlockValues / dataModel 补齐
215
220
  return !isHiddenTextBlock(block)
216
221
  })
@@ -219,7 +224,12 @@ export function getPayloadRenderableBlocks(payloadDef: PayloadBlocksDefinition):
219
224
  /** payload 中所有 input 块 field name(fallback 提交用,含 hidden) */
220
225
  export function getPayloadInputFieldNames(payloadDef: PayloadBlocksDefinition): string[] {
221
226
  return payloadDef.blocks
222
- .filter((block) => isInputBlockType(block.type) && block.name?.trim())
227
+ .filter(
228
+ (block) =>
229
+ isInputBlockType(block.type) &&
230
+ block.name?.trim() &&
231
+ hasRenderableFormOptions(block),
232
+ )
223
233
  .map((block) => block.name!.trim())
224
234
  }
225
235
 
@@ -165,6 +165,32 @@ describe('intersectPayloadBlocksWithForm', () => {
165
165
  expect(extractExtraBlockValues(result.extraBlocks)).toEqual({ agent_hint: 'hint' })
166
166
  })
167
167
 
168
+ it('excludes empty static questions from rendering, field names, and extra values', () => {
169
+ const invalidOptions = [
170
+ { value: null, label: '空' },
171
+ { value: {}, label: '对象' },
172
+ ]
173
+ const payload = parsePayloadBlocksJson({
174
+ title: 'Ask',
175
+ blocks: [
176
+ {
177
+ id: 'invalid',
178
+ type: 'select',
179
+ name: 'invalid_choice',
180
+ label: '无效选择',
181
+ options: invalidOptions,
182
+ },
183
+ { id: 'valid', type: 'text', name: 'title', label: '标题' },
184
+ ],
185
+ })!
186
+
187
+ expect(payload.blocks[0]?.options).toEqual([])
188
+ expect(payloadHasInputBlocks(payload)).toBe(true)
189
+ expect(getPayloadRenderableBlocks(payload).map((block) => block.name)).toEqual(['title'])
190
+ expect(getPayloadInputFieldNames(payload)).toEqual(['title'])
191
+ expect(extractExtraBlockValues(payload.blocks)).toEqual({ title: '' })
192
+ })
193
+
168
194
  it('defaults resume_multiselect extra blocks to empty array', () => {
169
195
  const extraBlocks = [
170
196
  {
@@ -415,7 +441,13 @@ describe('payload fallback helpers', () => {
415
441
  title: '',
416
442
  blocks: [
417
443
  { id: 'title', type: 'heading', label: '问卷调查' },
418
- { id: 'q1', type: 'radio', name: 'occupation', label: '职业', options: [] },
444
+ {
445
+ id: 'q1',
446
+ type: 'radio',
447
+ name: 'occupation',
448
+ label: '职业',
449
+ options: [{ value: 'operations', label: '运营' }],
450
+ },
419
451
  ],
420
452
  })!
421
453
 
@@ -433,7 +465,13 @@ describe('payload fallback helpers', () => {
433
465
  title: '',
434
466
  blocks: [
435
467
  { id: 'title', type: 'heading', label: '问卷调查' },
436
- { id: 'q1', type: 'radio', name: 'occupation', label: '职业', options: [] },
468
+ {
469
+ id: 'q1',
470
+ type: 'radio',
471
+ name: 'occupation',
472
+ label: '职业',
473
+ options: [{ value: 'operations', label: '运营' }],
474
+ },
437
475
  {
438
476
  id: 'hint',
439
477
  type: 'text',
@@ -734,6 +772,20 @@ describe('submitFormJson', () => {
734
772
  })
735
773
 
736
774
  describe('validateFormSubmitValues', () => {
775
+ it('ignores display blocks even if they contain input-like metadata', () => {
776
+ const blocks = [
777
+ {
778
+ id: 'web-1',
779
+ type: 'web' as const,
780
+ name: 'embedded_page',
781
+ label: '网页',
782
+ required: true,
783
+ webUrl: 'https://example.com',
784
+ },
785
+ ]
786
+ expect(validateFormSubmitValues(blocks, {})).toBeNull()
787
+ })
788
+
737
789
  it('rejects empty required text field', () => {
738
790
  const blocks = [
739
791
  { id: 'b1', type: 'text' as const, name: 'title', label: '标题', required: true },
@@ -1,5 +1,5 @@
1
1
  import type { FormBlock } from '../types/index'
2
- import { isLayoutBlockType, isMediaBlockType } from '../types/index'
2
+ import { isInputBlockType } from '../types/index'
3
3
  import { EMAIL_REGEXP } from '../adapter/a2uiAdapter'
4
4
  import { coerceToggleValue } from '../adapter/formSchema'
5
5
  import { asStringArray } from '../catalog/useSkoponBoundField'
@@ -52,9 +52,9 @@ export function validateFormSubmitValues(
52
52
  values: Record<string, unknown>,
53
53
  ): FormSubmitValidationError | null {
54
54
  for (const block of blocks) {
55
+ if (!isInputBlockType(block.type)) continue
55
56
  const name = block.name?.trim()
56
57
  if (!name) continue
57
- if (isLayoutBlockType(block.type) || isMediaBlockType(block.type)) continue
58
58
 
59
59
  const raw = values[name]
60
60
  const label = block.label?.trim() || name