@skopon-cool/form-sdk 0.6.3 → 0.6.5

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 (43) hide show
  1. package/dist/adapter/a2uiAdapter.d.ts +1 -1
  2. package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
  3. package/dist/adapter/formAssetOptions.d.ts +3 -0
  4. package/dist/adapter/formAssetOptions.d.ts.map +1 -0
  5. package/dist/adapter/formSchema.d.ts.map +1 -1
  6. package/dist/adapter/resolveSurface.d.ts +1 -1
  7. package/dist/adapter/resolveSurface.d.ts.map +1 -1
  8. package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
  9. package/dist/catalog/materialUploadContext.d.ts +24 -0
  10. package/dist/catalog/materialUploadContext.d.ts.map +1 -0
  11. package/dist/components/AskUserFormCard.d.ts +5 -0
  12. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  13. package/dist/components/SkoponFormRenderer.d.ts +3 -0
  14. package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
  15. package/dist/form-sdk.css +1 -1
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +2170 -1928
  19. package/dist/submit/buildAnswerAgentBody.d.ts.map +1 -1
  20. package/dist/submit/intersectPayloadBlocksWithForm.d.ts +11 -2
  21. package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -1
  22. package/dist/types/index.d.ts +35 -1
  23. package/dist/types/index.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/src/adapter/a2uiAdapter.test.ts +4 -0
  26. package/src/adapter/a2uiAdapter.ts +58 -2
  27. package/src/adapter/formAssetOptions.ts +19 -0
  28. package/src/adapter/formSchema.ts +54 -0
  29. package/src/adapter/resolveSurface.ts +4 -1
  30. package/src/catalog/a2uiCustomCatalog.tsx +296 -46
  31. package/src/catalog/fileUploadPreview.test.tsx +77 -11
  32. package/src/catalog/materialUploadContext.tsx +43 -0
  33. package/src/catalog/moduleTabs.test.tsx +59 -0
  34. package/src/components/AskUserFormCard.tsx +33 -9
  35. package/src/components/AskUserFormCard.upload.test.tsx +66 -0
  36. package/src/components/SkoponFormRenderer.tsx +6 -0
  37. package/src/icons/FilePlaceholderIcon.tsx +2 -2
  38. package/src/index.ts +2 -0
  39. package/src/styles/index.css +295 -0
  40. package/src/submit/buildAnswerAgentBody.ts +2 -0
  41. package/src/submit/intersectPayloadBlocksWithForm.ts +52 -3
  42. package/src/submit/submit.test.ts +54 -0
  43. package/src/types/index.ts +37 -0
@@ -58,7 +58,11 @@ function renderFileSurface(options: {
58
58
  onUploadingChange?: (uploading: boolean) => void
59
59
  fileExampleUrls?: string[]
60
60
  fileReviewCriteria?: string
61
+ fileReviewStatus?: 'passed' | 'failed'
62
+ fileReviewMessage?: string
63
+ fileRejectedUrls?: string[]
61
64
  help?: string
65
+ withUploader?: boolean
62
66
  } = {}) {
63
67
  const doc = blocksToA2ui(
64
68
  {
@@ -75,6 +79,9 @@ function renderFileSurface(options: {
75
79
  defaultValue: options.defaultValue,
76
80
  fileExampleUrls: options.fileExampleUrls,
77
81
  fileReviewCriteria: options.fileReviewCriteria,
82
+ fileReviewStatus: options.fileReviewStatus,
83
+ fileReviewMessage: options.fileReviewMessage,
84
+ fileRejectedUrls: options.fileRejectedUrls,
78
85
  help: options.help,
79
86
  },
80
87
  ],
@@ -92,6 +99,7 @@ function renderFileSurface(options: {
92
99
  surfaceId: 'upload-test',
93
100
  fieldNames: ['attachments'],
94
101
  onUploadingChange: options.onUploadingChange,
102
+ uploadMaterial: options.withUploader === false ? undefined : uploadMock,
95
103
  }),
96
104
  )
97
105
  const input = view.container.querySelector('input[type="file"]') as HTMLInputElement
@@ -111,9 +119,31 @@ describe('FileUploadPreview', () => {
111
119
  expect(getByAltText('附件示例 1').getAttribute('src')).toBe('https://cdn.example.com/example.jpg')
112
120
  expect(ref.current?.getValues()).toEqual({ attachments: [] })
113
121
  })
114
- it('stores successful CDN URLs in user selection order', async () => {
115
- const first = deferred<{ url: string }>()
116
- const second = deferred<{ url: string }>()
122
+ it('shows a rejected uploaded file and reason without treating its old URL as a valid answer', () => {
123
+ const { getByText, container, ref } = renderFileSurface({
124
+ minCount: 1,
125
+ fileReviewStatus: 'failed',
126
+ fileReviewMessage: '场景中检测到人物',
127
+ fileRejectedUrls: ['https://cdn.example.com/rejected-scene.jpg'],
128
+ })
129
+ expect(getByText('❌ 审核不合格:场景中检测到人物')).toBeTruthy()
130
+ expect(container.textContent).toContain('rejected-scene.jpg')
131
+ expect(ref.current?.getValues()).toEqual({ attachments: [] })
132
+ })
133
+
134
+ it('shows and locks a retained approved upload', () => {
135
+ const { getByText, container, ref } = renderFileSurface({
136
+ defaultValue: ['https://cdn.example.com/approved.jpg'],
137
+ fileReviewStatus: 'passed',
138
+ })
139
+ expect(getByText('✅ 审核通过,已保留,无需重新上传')).toBeTruthy()
140
+ expect(container.textContent).toContain('approved.jpg')
141
+ expect(container.querySelector('input[type="file"]')?.hasAttribute('disabled')).toBe(true)
142
+ expect(ref.current?.getValues()).toEqual({ attachments: ['https://cdn.example.com/approved.jpg'] })
143
+ })
144
+ it('stores successful private asset IDs in user selection order', async () => {
145
+ const first = deferred<{ assetId: string; name: string; previewUrl: string }>()
146
+ const second = deferred<{ assetId: string; name: string; previewUrl: string }>()
117
147
  uploadMock
118
148
  .mockImplementationOnce(() => first.promise)
119
149
  .mockImplementationOnce(() => second.promise)
@@ -130,16 +160,16 @@ describe('FileUploadPreview', () => {
130
160
  await waitFor(() => expect(uploadMock).toHaveBeenCalledTimes(2))
131
161
  expect(uploadingChanges).toContain(true)
132
162
 
133
- await act(async () => second.resolve({ url: 'https://cdn.example.com/second.pdf' }))
163
+ await act(async () => second.resolve({ assetId: 'mat_second', name: 'second.pdf', previewUrl: 'https://cdn.example.com/second.pdf' }))
134
164
  expect(ref.current?.getValues()).toEqual({
135
- attachments: ['https://cdn.example.com/second.pdf'],
165
+ attachments: [{ asset_id: 'mat_second', source: 'upload' }],
136
166
  })
137
- await act(async () => first.resolve({ url: 'https://cdn.example.com/first.pdf' }))
167
+ await act(async () => first.resolve({ assetId: 'mat_first', name: 'first.pdf', previewUrl: 'https://cdn.example.com/first.pdf' }))
138
168
  await waitFor(() =>
139
169
  expect(ref.current?.getValues()).toEqual({
140
170
  attachments: [
141
- 'https://cdn.example.com/first.pdf',
142
- 'https://cdn.example.com/second.pdf',
171
+ { asset_id: 'mat_first', source: 'upload' },
172
+ { asset_id: 'mat_second', source: 'upload' },
143
173
  ],
144
174
  }),
145
175
  )
@@ -157,11 +187,11 @@ describe('FileUploadPreview', () => {
157
187
  it('allows removing below minCount and aborts an in-flight request', async () => {
158
188
  let signal: AbortSignal | undefined
159
189
  uploadMock.mockImplementation(
160
- (_file: File, options?: { signal?: AbortSignal }) => {
190
+ (_file: File, _field: unknown, options?: { signal?: AbortSignal }) => {
161
191
  signal = options?.signal
162
- return new Promise<{ url: string }>((resolve) => {
192
+ return new Promise<{ assetId: string; name: string }>((resolve) => {
163
193
  signal?.addEventListener('abort', () => {
164
- resolve({ url: 'https://cdn.example.com/ignored.pdf' })
194
+ resolve({ assetId: 'mat_ignored', name: 'ignored.pdf' })
165
195
  })
166
196
  })
167
197
  },
@@ -191,4 +221,40 @@ describe('FileUploadPreview', () => {
191
221
  )
192
222
  expect(getByText('legacy file.pdf')).toBeTruthy()
193
223
  })
224
+
225
+ it('fails closed when the host does not inject a private uploader', async () => {
226
+ const { input, getByText, ref } = renderFileSurface({ withUploader: false })
227
+ fireEvent.change(input, { target: { files: [new File(['x'], 'private.pdf')] } })
228
+ await waitFor(() => expect(getByText('当前宿主未配置私有物料上传,请联系管理员')).toBeTruthy())
229
+ expect(ref.current?.getValues()).toEqual({ attachments: [] })
230
+ expect(uploadMock).not.toHaveBeenCalled()
231
+ })
232
+ })
233
+
234
+ describe('Footage selection in generic material forms', () => {
235
+ function renderAssetField(mode: 'asset_only' | 'upload_or_asset') {
236
+ const ref = createRef<SkoponFormRendererRef>()
237
+ const doc = blocksToA2ui({ blocks: [{
238
+ id: 'asset-field', type: 'file', name: 'scene', label: '场景', fileSourceMode: mode,
239
+ fileMaxCount: 1, fileAssetOptions: [{ assetId: '17', label: '山景', assetType: 'image', url: 'https://cdn.test/scene.jpg' }],
240
+ }] }, { includeHeader: false })
241
+ return { ref, ...render(createElement(SkoponFormRenderer, { ref, doc, surfaceId: 'asset-test', fieldNames: ['scene'] })) }
242
+ }
243
+
244
+ it('asset-only fields submit the selected asset identity without uploading', async () => {
245
+ const view = renderAssetField('asset_only')
246
+ expect(view.container.querySelector('input[type="file"]')).toBeNull()
247
+ fireEvent.click(view.getByRole('button', { name: '选择素材' }))
248
+ fireEvent.click(await view.findByRole('button', { name: '选择素材 山景' }))
249
+ await waitFor(() => expect(view.ref.current?.getValues().scene).toEqual([{ asset_id: '17', source: 'library_select' }]))
250
+ expect(uploadMock).not.toHaveBeenCalled()
251
+ })
252
+
253
+ it('mixed fields retain both upload and library selection actions', async () => {
254
+ const view = renderAssetField('upload_or_asset')
255
+ expect(view.container.querySelector('input[type="file"]')).not.toBeNull()
256
+ fireEvent.click(view.getByRole('button', { name: '选择素材' }))
257
+ fireEvent.click(await view.findByRole('button', { name: '选择素材 山景' }))
258
+ await waitFor(() => expect(view.ref.current?.getValues().scene).toEqual([{ asset_id: '17', source: 'library_select' }]))
259
+ })
194
260
  })
@@ -0,0 +1,43 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+
3
+ export interface MaterialUploadFieldContext {
4
+ fieldName?: string
5
+ moduleId?: string
6
+ positionKey?: string
7
+ assetSlotKey?: string
8
+ assetType?: string
9
+ }
10
+
11
+ export interface MaterialUploadResult {
12
+ assetId: string
13
+ name: string
14
+ mimeType?: string
15
+ size?: number
16
+ previewUrl?: string
17
+ }
18
+
19
+ export type MaterialUploadFn = (
20
+ file: File,
21
+ context: MaterialUploadFieldContext,
22
+ options?: { signal?: AbortSignal },
23
+ ) => Promise<MaterialUploadResult>
24
+
25
+ const MaterialUploadContext = createContext<MaterialUploadFn | null>(null)
26
+
27
+ export function MaterialUploadProvider({
28
+ uploadMaterial,
29
+ children,
30
+ }: {
31
+ uploadMaterial?: MaterialUploadFn | null
32
+ children: ReactNode
33
+ }) {
34
+ return (
35
+ <MaterialUploadContext.Provider value={uploadMaterial ?? null}>
36
+ {children}
37
+ </MaterialUploadContext.Provider>
38
+ )
39
+ }
40
+
41
+ export function useMaterialUpload(): MaterialUploadFn | null {
42
+ return useContext(MaterialUploadContext)
43
+ }
@@ -0,0 +1,59 @@
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
+ vi.mock('@a2ui/react/styles', () => ({ injectStyles: vi.fn(), removeStyles: vi.fn() }))
9
+
10
+ import { createElement } from 'react'
11
+ import { cleanup, fireEvent, render } from '@testing-library/react'
12
+ import { blocksToA2ui } from '../adapter/a2uiAdapter'
13
+ import SkoponFormRenderer from '../components/SkoponFormRenderer'
14
+
15
+ beforeAll(() => {
16
+ class ResizeObserverMock { observe() {} unobserve() {} disconnect() {} }
17
+ vi.stubGlobal('ResizeObserver', ResizeObserverMock)
18
+ })
19
+ afterEach(cleanup)
20
+
21
+ describe('form module tabs', () => {
22
+ it('renders grouped upload fields and module guidance inside tabs', () => {
23
+ const doc = blocksToA2ui({
24
+ title: '',
25
+ description: '',
26
+ modules: [
27
+ { id: 'person', label: '人物', reviewCriteria: '人物五官清晰' },
28
+ { id: 'scene', label: '场景', reviewCriteria: '场景中不能有人物' },
29
+ ],
30
+ blocks: [
31
+ { id: 'p', type: 'file', name: 'person', label: '人物图', moduleId: 'person', fileSourceMode: 'upload_required', fileReviewCriteria: '人物五官清晰' },
32
+ { id: 'p2', type: 'file', name: 'person2', label: '人物备选图', moduleId: 'person', fileSourceMode: 'upload_or_asset', fileReviewCriteria: '人物五官清晰' },
33
+ { id: 's', type: 'file', name: 'scene', label: '场景图', moduleId: 'scene', fileSourceMode: 'asset_only', fileReviewCriteria: '场景中不能有人物' },
34
+ ],
35
+ })
36
+ const view = render(createElement(SkoponFormRenderer, { doc, surfaceId: 'modules-test' }))
37
+ expect(view.container.querySelector('.skopon-compact-upload')).toBeTruthy()
38
+ expect(view.container.querySelector('.ant-upload-drag')).toBeNull()
39
+ expect(view.getByText('人物图')).toBeTruthy()
40
+ expect(view.getByText('物料 · 仅上传')).toBeTruthy()
41
+ expect(view.container.querySelectorAll('.skopon-compact-upload__state-mark')).toHaveLength(0)
42
+ expect(view.container.textContent).not.toContain('最多 1')
43
+ const cards = view.container.querySelectorAll('.skopon-compact-upload')
44
+ expect(cards).toHaveLength(2)
45
+ expect(cards[0].querySelector('button')?.textContent?.replace(/\s/g, '')).toContain('上传')
46
+ expect(cards[0].textContent).not.toContain('选择素材')
47
+ expect(cards[1].textContent).toContain('物料或素材')
48
+ expect(cards[1].textContent?.replace(/\s/g, '')).toContain('上传')
49
+ expect(cards[1].textContent).toContain('选择素材')
50
+ expect(view.getByText('人物素材要求')).toBeTruthy()
51
+ expect(view.getByText('人物五官清晰')).toBeTruthy()
52
+ fireEvent.click(view.getByRole('button', { name: '场景' }))
53
+ expect(view.getByText('场景图')).toBeTruthy()
54
+ expect(view.getByText('素材 · 仅选择')).toBeTruthy()
55
+ expect(view.queryByRole('button', { name: '上传' })).toBeNull()
56
+ expect(view.getByRole('button', { name: '选择素材' })).toBeTruthy()
57
+ expect(view.getByText('场景中不能有人物')).toBeTruthy()
58
+ })
59
+ })
@@ -41,6 +41,7 @@ import {
41
41
  import SkoponFormRenderer, { type SkoponFormRendererRef } from './SkoponFormRenderer'
42
42
  import CurlSubmitBlock from './CurlSubmitBlock'
43
43
  import SkoponFormProvider from './SkoponFormProvider'
44
+ import type { MaterialUploadFn } from '../catalog/materialUploadContext'
44
45
 
45
46
  const REFRESH_REPORT_DEBOUNCE_MS = 80
46
47
 
@@ -61,6 +62,8 @@ export interface AskUserFormCardProps {
61
62
  payload: unknown
62
63
  /** 可选;无则跳过拉取 vt_forms,仅用 payload blocks fallback */
63
64
  formUniqueId?: string | null
65
+ /** 开放卡使用 latest;已提交/失效卡使用 snapshot,冻结发卡时外观。 */
66
+ formDefinitionPolicy?: 'latest' | 'snapshot'
64
67
  /**
65
68
  * ask_source_unique_id(Flow/QA 业务 id)。
66
69
  * 有值且 sessionStorage.auth_user.realtime_ws_url 存在、且渲染出表单控件时按卡建连。
@@ -80,6 +83,8 @@ export interface AskUserFormCardProps {
80
83
  contentSearch?: ContentSearchFn | null
81
84
  /** 案例单选组件:注入搜索函数 */
82
85
  caseSearch?: CaseSearchFn | null
86
+ /** 用户私有物料上传函数,由宿主负责浏览器直传及上下文。 */
87
+ uploadMaterial?: MaterialUploadFn | null
83
88
  }
84
89
 
85
90
  interface AskUserFormCardBodyProps extends AskUserFormCardProps {
@@ -90,6 +95,7 @@ interface AskUserFormCardBodyProps extends AskUserFormCardProps {
90
95
  function AskUserFormCardBody({
91
96
  payload,
92
97
  formUniqueId,
98
+ formDefinitionPolicy = 'latest',
93
99
  askUserMessageId,
94
100
  callbackUrl,
95
101
  submitMode = 'curl',
@@ -100,6 +106,7 @@ function AskUserFormCardBody({
100
106
  resumeSearch = null,
101
107
  contentSearch = null,
102
108
  caseSearch = null,
109
+ uploadMaterial = null,
103
110
  onSurfaceReadyChange,
104
111
  onAutoControlReady,
105
112
  }: AskUserFormCardBodyProps) {
@@ -147,7 +154,7 @@ function AskUserFormCardBody({
147
154
 
148
155
  useEffect(() => {
149
156
  let cancelled = false
150
- if (!resolvedFormUniqueId) {
157
+ if (!resolvedFormUniqueId || formDefinitionPolicy === 'snapshot') {
151
158
  setLoading(false)
152
159
  setFormDisabled(false)
153
160
  setFormDef(null)
@@ -178,18 +185,30 @@ function AskUserFormCardBody({
178
185
  return () => {
179
186
  cancelled = true
180
187
  }
181
- }, [resolvedFormUniqueId, fetchFormDetail])
188
+ }, [resolvedFormUniqueId, fetchFormDetail, formDefinitionPolicy])
182
189
 
183
190
  const payloadDef = useMemo(() => parsePayloadBlocksJson(payload), [payload])
184
191
 
192
+ // Older hibot servers omitted schema when persisting card_data.payload.
193
+ // Runtime source mode is unique to the dynamic material collection payload,
194
+ // so retain its snapshot blocks in that compatibility case as well.
195
+ const dynamicMaterialForm = payloadDef?.schema === 'skopon.material-form/v2' || Boolean(
196
+ payloadDef?.rawBlocks?.some((block) =>
197
+ block.type === 'file' &&
198
+ ['upload_required', 'upload_or_asset', 'asset_only'].includes(String(block.fileSourceMode)),
199
+ ),
200
+ )
201
+
185
202
  const intersection = useMemo(() => {
186
203
  if (!payloadDef) return null
187
- return intersectPayloadBlocksWithForm(payloadDef, formDef ?? undefined)
188
- }, [payloadDef, formDef])
204
+ return intersectPayloadBlocksWithForm(payloadDef, formDef ?? undefined, {
205
+ definitionPolicy: formDefinitionPolicy,
206
+ })
207
+ }, [payloadDef, formDef, formDefinitionPolicy])
189
208
 
190
209
  const matchedBlocks = intersection?.matchedBlocks ?? []
191
210
  const usePayloadFallback =
192
- Boolean(payloadDef && payloadHasInputBlocks(payloadDef) && matchedBlocks.length === 0)
211
+ Boolean(payloadDef && payloadHasInputBlocks(payloadDef) && (dynamicMaterialForm || matchedBlocks.length === 0))
193
212
 
194
213
  const renderBlocks = useMemo(() => {
195
214
  if (usePayloadFallback && payloadDef) {
@@ -224,16 +243,17 @@ function AskUserFormCardBody({
224
243
  [renderBlocks],
225
244
  )
226
245
 
227
- const surfaceTitle = intersection?.title ?? payloadDef?.title ?? ''
228
- const surfaceDescription = intersection?.description ?? payloadDef?.description ?? ''
246
+ const surfaceTitle = dynamicMaterialForm ? payloadDef?.title ?? '' : intersection?.title ?? payloadDef?.title ?? ''
247
+ const surfaceDescription = dynamicMaterialForm ? payloadDef?.description ?? '' : intersection?.description ?? payloadDef?.description ?? ''
248
+ const surfaceModules = dynamicMaterialForm ? payloadDef?.modules : intersection?.modules ?? payloadDef?.modules
229
249
 
230
250
  const surfaceDoc = useMemo(() => {
231
251
  if (renderBlocks.length === 0) return null
232
252
  return buildAskUserSurface(
233
- { title: surfaceTitle, description: surfaceDescription },
253
+ { title: surfaceTitle, description: surfaceDescription, modules: surfaceModules },
234
254
  renderBlocks,
235
255
  )
236
- }, [surfaceTitle, surfaceDescription, renderBlocks])
256
+ }, [surfaceTitle, surfaceDescription, surfaceModules, renderBlocks])
237
257
 
238
258
  const docKey = useMemo(() => {
239
259
  if (!surfaceDoc) return ''
@@ -370,8 +390,10 @@ function AskUserFormCardBody({
370
390
  const effectiveForm =
371
391
  renderBlocks.length > 0
372
392
  ? {
393
+ ...(dynamicMaterialForm ? { schema: 'skopon.material-form/v2' as const } : {}),
373
394
  title: surfaceTitle,
374
395
  description: surfaceDescription,
396
+ modules: surfaceModules,
375
397
  blocks: renderBlocks,
376
398
  }
377
399
  : (payloadDef ?? undefined)
@@ -411,6 +433,7 @@ function AskUserFormCardBody({
411
433
  renderBlocks,
412
434
  surfaceTitle,
413
435
  surfaceDescription,
436
+ surfaceModules,
414
437
  extraValues,
415
438
  reportSnapshot,
416
439
  runtime,
@@ -497,6 +520,7 @@ function AskUserFormCardBody({
497
520
  resumeSearch={resumeSearch}
498
521
  contentSearch={contentSearch}
499
522
  caseSearch={caseSearch}
523
+ uploadMaterial={uploadMaterial}
500
524
  onUploadingChange={setUploading}
501
525
  />
502
526
 
@@ -39,6 +39,29 @@ beforeEach(() => {
39
39
  afterEach(cleanup)
40
40
 
41
41
  describe('AskUserFormCard file upload guard', () => {
42
+ it('freezes settled cards on the message snapshot without fetching latest definition', async () => {
43
+ const fetchFormDetail = vi.fn()
44
+ const snapshotBlock = {
45
+ id: 'snapshot-title',
46
+ type: 'text' as const,
47
+ name: 'title',
48
+ label: '历史标题',
49
+ defaultValue: '已提交内容',
50
+ }
51
+ const view = render(
52
+ createElement(AskUserFormCard, {
53
+ payload: { title: '历史表单', description: '', blocks: [snapshotBlock] },
54
+ formUniqueId: 'form-versioned',
55
+ formDefinitionPolicy: 'snapshot',
56
+ callbackUrl: null,
57
+ fetchFormDetail,
58
+ }),
59
+ )
60
+
61
+ await waitFor(() => expect(view.getByText('历史标题')).toBeTruthy())
62
+ expect(fetchFormDetail).not.toHaveBeenCalled()
63
+ })
64
+
42
65
  it('disables submit until every file upload settles', async () => {
43
66
  let resolveUpload!: (value: { url: string }) => void
44
67
  uploadMock.mockReturnValue(
@@ -89,3 +112,46 @@ describe('AskUserFormCard file upload guard', () => {
89
112
  await waitFor(() => expect(submit.disabled).toBe(false))
90
113
  })
91
114
  })
115
+
116
+ it('generic material schemas render every runtime position even when only some keys match the stored form', async () => {
117
+ const field = { id: 'portrait', type: 'file' as const, name: 'portrait', label: '人物', moduleId: 'people' }
118
+ const view = render(createElement(AskUserFormCard, {
119
+ payload: { schema: 'skopon.material-form/v2', title: '通用素材收集', modules: [{ id: 'people', label: '人物 Tab' }], blocks: [field, { ...field, id: 'outfit', name: 'outfit', label: '服装' }] },
120
+ formUniqueId: 'generic-form', callbackUrl: '/callback',
121
+ fetchFormDetail: async () => ({ formUniqueId: 'generic-form', disabled: false, formDefinition: normalizeFormDefinition({ title: '旧标题', blocks: [field], jsonSchema: {} }) }),
122
+ }))
123
+ await waitFor(() => expect(view.getByText('服装')).toBeTruthy())
124
+ expect(view.getByText('人物')).toBeTruthy()
125
+ expect(view.getByText('人物 Tab')).toBeTruthy()
126
+ })
127
+
128
+ it('keeps runtime material source modes when hibot snapshot omits the schema marker', async () => {
129
+ const module = { id: 'materials', label: '素材' }
130
+ const payloadBlocks = [
131
+ { id: 'upload', name: 'upload', type: 'file' as const, label: '物料', moduleId: 'materials', fileSourceMode: 'upload_required' as const },
132
+ { id: 'asset', name: 'asset', type: 'file' as const, label: '素材', moduleId: 'materials', fileSourceMode: 'asset_only' as const },
133
+ { id: 'mixed', name: 'mixed', type: 'file' as const, label: '物料或素材', moduleId: 'materials', fileSourceMode: 'upload_or_asset' as const },
134
+ ]
135
+ const storedBlocks = payloadBlocks.map(({ fileSourceMode: _mode, ...block }) => block)
136
+ const formDefinition = normalizeFormDefinition({
137
+ title: '存量表单', modules: [module], blocks: storedBlocks, jsonSchema: {},
138
+ })
139
+ const view = render(createElement(AskUserFormCard, {
140
+ payload: { title: '本次素材', modules: [module], blocks: payloadBlocks },
141
+ formUniqueId: 'generic-form', callbackUrl: '/callback',
142
+ fetchFormDetail: async () => ({ formUniqueId: 'generic-form', disabled: false, formDefinition }),
143
+ }))
144
+
145
+ await waitFor(() => expect(view.container.querySelectorAll('.skopon-compact-upload')).toHaveLength(3))
146
+ const cards = view.container.querySelectorAll('.skopon-compact-upload')
147
+ const text = (index: number) => (cards[index].textContent || '').replace(/\s/g, '')
148
+ expect(text(0)).toContain('物料·仅上传')
149
+ expect(text(0)).toContain('上传')
150
+ expect(text(0)).not.toContain('选择素材')
151
+ expect(text(1)).toContain('素材·仅选择')
152
+ expect(text(1)).toContain('选择素材')
153
+ expect(text(1)).not.toContain('上传')
154
+ expect(text(2)).toContain('物料或素材')
155
+ expect(text(2)).toContain('上传')
156
+ expect(text(2)).toContain('选择素材')
157
+ })
@@ -16,6 +16,7 @@ import { isA2uiSurfaceEmpty, surfaceDocToMessages } from '../adapter/a2uiAdapter
16
16
  import { SKOPON_CATALOG_ID, buildSkoponCatalog } from '../catalog/a2uiCustomCatalog'
17
17
  import { A2uiPreviewModeProvider } from '../catalog/a2uiPreviewContext'
18
18
  import { FileUploadStatusProvider } from '../catalog/fileUploadStatusContext'
19
+ import { MaterialUploadProvider, type MaterialUploadFn } from '../catalog/materialUploadContext'
19
20
  import { CaseSearchProvider, type CaseSearchFn } from '../catalog/caseSearchContext'
20
21
  import { ContentSearchProvider, type ContentSearchFn } from '../catalog/contentSearchContext'
21
22
  import { ResumeSearchProvider, type ResumeSearchFn } from '../catalog/resumeSearchContext'
@@ -41,6 +42,8 @@ export interface SkoponFormRendererProps {
41
42
  caseSearch?: CaseSearchFn | null
42
43
  /** 任意文件上传任务的聚合状态 */
43
44
  onUploadingChange?: (uploading: boolean) => void
45
+ /** 宿主注入的用户私有物料直传;未注入时上传会失败并提示配置。 */
46
+ uploadMaterial?: MaterialUploadFn | null
44
47
  /** 外层已包裹 SkoponFormProvider 时可设为 false,避免重复 ConfigProvider */
45
48
  wrapProvider?: boolean
46
49
  }
@@ -57,6 +60,7 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
57
60
  contentSearch = null,
58
61
  caseSearch = null,
59
62
  onUploadingChange,
63
+ uploadMaterial = null,
60
64
  wrapProvider = true,
61
65
  },
62
66
  ref,
@@ -144,6 +148,7 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
144
148
  }
145
149
 
146
150
  const content = (
151
+ <MaterialUploadProvider uploadMaterial={uploadMaterial}>
147
152
  <FileUploadStatusProvider onUploadingChange={onUploadingChange}>
148
153
  <ResumeSearchProvider resumeSearch={resumeSearch}>
149
154
  <ContentSearchProvider contentSearch={contentSearch}>
@@ -159,6 +164,7 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
159
164
  </ContentSearchProvider>
160
165
  </ResumeSearchProvider>
161
166
  </FileUploadStatusProvider>
167
+ </MaterialUploadProvider>
162
168
  )
163
169
 
164
170
  return wrapProvider ? <SkoponFormProvider>{content}</SkoponFormProvider> : content
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  FileExcelOutlined,
3
+ FileImageOutlined,
3
4
  FileOutlined,
4
5
  FileTextOutlined,
5
- PictureOutlined,
6
6
  SoundOutlined,
7
7
  VideoCameraOutlined,
8
8
  } from '@ant-design/icons'
@@ -11,7 +11,7 @@ import type { FormFilePlaceholderIcon } from '../types/index'
11
11
  const PLACEHOLDER_ICON_COMPONENTS = {
12
12
  video: VideoCameraOutlined,
13
13
  audio: SoundOutlined,
14
- image: PictureOutlined,
14
+ image: FileImageOutlined,
15
15
  file: FileOutlined,
16
16
  spreadsheet: FileExcelOutlined,
17
17
  document: FileTextOutlined,
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export type {
4
4
  A2uiComponentNode,
5
5
  A2uiSurfaceDoc,
6
6
  FormBlock,
7
+ FormAssetOption,
7
8
  FormBlockCondition,
8
9
  FormBlockOption,
9
10
  FormBlockType,
@@ -15,6 +16,7 @@ export type {
15
16
  FormFilePlaceholderIcon,
16
17
  FormJsonSchema,
17
18
  FormMediaSize,
19
+ FormModule,
18
20
  FormResumeFilter,
19
21
  FormSchema,
20
22
  SubmitMode,