@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.
- package/dist/adapter/a2uiAdapter.d.ts +1 -1
- package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
- package/dist/adapter/formAssetOptions.d.ts +3 -0
- package/dist/adapter/formAssetOptions.d.ts.map +1 -0
- package/dist/adapter/formSchema.d.ts.map +1 -1
- package/dist/adapter/resolveSurface.d.ts +1 -1
- package/dist/adapter/resolveSurface.d.ts.map +1 -1
- package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
- package/dist/catalog/materialUploadContext.d.ts +24 -0
- package/dist/catalog/materialUploadContext.d.ts.map +1 -0
- package/dist/components/AskUserFormCard.d.ts +5 -0
- package/dist/components/AskUserFormCard.d.ts.map +1 -1
- package/dist/components/SkoponFormRenderer.d.ts +3 -0
- package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
- package/dist/form-sdk.css +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2170 -1928
- package/dist/submit/buildAnswerAgentBody.d.ts.map +1 -1
- package/dist/submit/intersectPayloadBlocksWithForm.d.ts +11 -2
- package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -1
- package/dist/types/index.d.ts +35 -1
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapter/a2uiAdapter.test.ts +4 -0
- package/src/adapter/a2uiAdapter.ts +58 -2
- package/src/adapter/formAssetOptions.ts +19 -0
- package/src/adapter/formSchema.ts +54 -0
- package/src/adapter/resolveSurface.ts +4 -1
- package/src/catalog/a2uiCustomCatalog.tsx +296 -46
- package/src/catalog/fileUploadPreview.test.tsx +77 -11
- package/src/catalog/materialUploadContext.tsx +43 -0
- package/src/catalog/moduleTabs.test.tsx +59 -0
- package/src/components/AskUserFormCard.tsx +33 -9
- package/src/components/AskUserFormCard.upload.test.tsx +66 -0
- package/src/components/SkoponFormRenderer.tsx +6 -0
- package/src/icons/FilePlaceholderIcon.tsx +2 -2
- package/src/index.ts +2 -0
- package/src/styles/index.css +295 -0
- package/src/submit/buildAnswerAgentBody.ts +2 -0
- package/src/submit/intersectPayloadBlocksWithForm.ts +52 -3
- package/src/submit/submit.test.ts +54 -0
- 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('
|
|
115
|
-
const
|
|
116
|
-
|
|
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({
|
|
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: ['
|
|
165
|
+
attachments: [{ asset_id: 'mat_second', source: 'upload' }],
|
|
136
166
|
})
|
|
137
|
-
await act(async () => first.resolve({
|
|
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
|
-
'
|
|
142
|
-
'
|
|
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<{
|
|
192
|
+
return new Promise<{ assetId: string; name: string }>((resolve) => {
|
|
163
193
|
signal?.addEventListener('abort', () => {
|
|
164
|
-
resolve({
|
|
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
|
-
|
|
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:
|
|
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,
|