@skopon-cool/form-sdk 0.6.2 → 0.6.4

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 (37) 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/components/AskUserFormCard.d.ts +2 -0
  10. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  11. package/dist/form-sdk.css +1 -1
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +2018 -1797
  15. package/dist/submit/buildAnswerAgentBody.d.ts.map +1 -1
  16. package/dist/submit/intersectPayloadBlocksWithForm.d.ts +11 -2
  17. package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -1
  18. package/dist/types/index.d.ts +39 -1
  19. package/dist/types/index.d.ts.map +1 -1
  20. package/package.json +1 -1
  21. package/src/adapter/a2uiAdapter.test.ts +16 -0
  22. package/src/adapter/a2uiAdapter.ts +66 -2
  23. package/src/adapter/formAssetOptions.ts +19 -0
  24. package/src/adapter/formSchema.ts +60 -0
  25. package/src/adapter/resolveSurface.ts +4 -1
  26. package/src/catalog/a2uiCustomCatalog.tsx +216 -13
  27. package/src/catalog/fileUploadPreview.test.tsx +73 -0
  28. package/src/catalog/moduleTabs.test.tsx +59 -0
  29. package/src/components/AskUserFormCard.tsx +28 -9
  30. package/src/components/AskUserFormCard.upload.test.tsx +66 -0
  31. package/src/icons/FilePlaceholderIcon.tsx +2 -2
  32. package/src/index.ts +2 -0
  33. package/src/styles/index.css +295 -0
  34. package/src/submit/buildAnswerAgentBody.ts +2 -0
  35. package/src/submit/intersectPayloadBlocksWithForm.ts +52 -3
  36. package/src/submit/submit.test.ts +54 -0
  37. package/src/types/index.ts +41 -0
@@ -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
+ })
@@ -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,
@@ -230,6 +230,210 @@
230
230
  justify-content: flex-end;
231
231
  }
232
232
 
233
+ .skopon-module-panel {
234
+ min-width: 0;
235
+ padding: var(--space-3) var(--space-2) var(--space-2);
236
+ }
237
+
238
+ .skopon-module-panel__description {
239
+ margin: 0 0 var(--space-3) !important;
240
+ }
241
+
242
+ .skopon-module-panel__grid {
243
+ display: grid;
244
+ grid-template-columns: repeat(2, minmax(0, 1fr));
245
+ gap: var(--space-3);
246
+ align-items: start;
247
+ }
248
+
249
+ .skopon-module-panel__field {
250
+ min-width: 0;
251
+ }
252
+
253
+ .skopon-module-panel__field .form-block-preview {
254
+ padding: 0;
255
+ }
256
+
257
+ .skopon-compact-upload {
258
+ display: grid;
259
+ grid-template-columns: 86px minmax(0, 1fr);
260
+ gap: 13px;
261
+ align-items: center;
262
+ min-width: 0;
263
+ padding: 12px;
264
+ border: 1px solid var(--color-border-subtle);
265
+ border-radius: 12px;
266
+ background: var(--color-surface);
267
+ }
268
+
269
+ .skopon-compact-upload--failed {
270
+ border-color: color-mix(in srgb, var(--color-error, #cf3030) 45%, var(--color-border-subtle));
271
+ background: color-mix(in srgb, var(--color-error, #cf3030) 7%, var(--color-surface));
272
+ }
273
+
274
+ .skopon-compact-upload__thumb {
275
+ position: relative;
276
+ display: grid;
277
+ place-items: center;
278
+ width: 86px;
279
+ height: 92px;
280
+ overflow: hidden;
281
+ border-radius: 9px;
282
+ color: var(--color-text-secondary);
283
+ background: linear-gradient(145deg, var(--color-bg-subtle), var(--color-border-subtle));
284
+ }
285
+
286
+ .skopon-compact-upload__thumb img {
287
+ width: 100%;
288
+ height: 100%;
289
+ object-fit: cover;
290
+ }
291
+
292
+ .skopon-compact-upload__state-mark {
293
+ position: absolute;
294
+ right: 6px;
295
+ bottom: 6px;
296
+ display: grid;
297
+ place-items: center;
298
+ width: 22px;
299
+ height: 22px;
300
+ border-radius: 999px;
301
+ color: var(--color-text-secondary);
302
+ background: color-mix(in srgb, var(--color-surface) 92%, transparent);
303
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.14);
304
+ font-size: 13px;
305
+ font-weight: 700;
306
+ }
307
+
308
+ .skopon-compact-upload--passed .skopon-compact-upload__state-mark,
309
+ .skopon-compact-upload--passed .skopon-compact-upload__status {
310
+ color: var(--color-success, #087a48);
311
+ }
312
+
313
+ .skopon-compact-upload--failed .skopon-compact-upload__state-mark,
314
+ .skopon-compact-upload--failed .skopon-compact-upload__status,
315
+ .skopon-compact-upload__error {
316
+ color: var(--color-error, #cf3030);
317
+ }
318
+
319
+ .skopon-compact-upload__meta {
320
+ display: flex;
321
+ flex-direction: column;
322
+ align-items: flex-start;
323
+ gap: 5px;
324
+ min-width: 0;
325
+ }
326
+
327
+ .skopon-compact-upload__meta > strong {
328
+ font-size: var(--font-size-sm);
329
+ font-weight: var(--font-weight-medium);
330
+ line-height: 1.4;
331
+ }
332
+
333
+ .skopon-compact-upload__status,
334
+ .skopon-compact-upload__error {
335
+ max-width: 100%;
336
+ overflow: hidden;
337
+ color: var(--color-text-muted);
338
+ font-size: var(--font-size-xs);
339
+ line-height: 1.4;
340
+ text-overflow: ellipsis;
341
+ }
342
+
343
+ .skopon-compact-upload__source {
344
+ display: inline-flex;
345
+ align-items: center;
346
+ min-height: 20px;
347
+ padding: 1px 7px;
348
+ border: 1px solid var(--color-border-subtle);
349
+ border-radius: 999px;
350
+ color: var(--color-text-secondary);
351
+ background: var(--color-bg-subtle);
352
+ font-size: 11px;
353
+ line-height: 1.4;
354
+ }
355
+
356
+ .skopon-compact-upload__source--asset_only {
357
+ color: var(--color-primary);
358
+ border-color: color-mix(in srgb, var(--color-primary) 24%, var(--color-border-subtle));
359
+ }
360
+
361
+ .skopon-compact-upload__source--upload_or_asset {
362
+ color: var(--color-text);
363
+ }
364
+
365
+ .skopon-compact-upload__meta .ant-btn {
366
+ min-height: 30px;
367
+ padding-inline: 11px;
368
+ border-radius: 8px;
369
+ }
370
+
371
+ .skopon-module-panel__guide {
372
+ display: flex;
373
+ align-items: center;
374
+ justify-content: space-between;
375
+ gap: var(--space-4);
376
+ margin-top: 14px;
377
+ padding: 13px 0 0;
378
+ border-top: 1px solid var(--color-border-subtle);
379
+ }
380
+
381
+ .skopon-module-panel__requirements .ant-typography {
382
+ margin-bottom: 0;
383
+ white-space: pre-line;
384
+ }
385
+
386
+ .skopon-module-panel__example-list {
387
+ display: flex;
388
+ flex-wrap: wrap;
389
+ gap: 6px;
390
+ margin-top: 4px;
391
+ }
392
+
393
+ .skopon-module-panel__example-list .ant-image,
394
+ .skopon-module-panel__example-list .ant-image-img {
395
+ width: 42px !important;
396
+ height: 42px !important;
397
+ border-radius: 7px;
398
+ }
399
+
400
+ .a2ui-surface.a2ui-container .a2ui-tabs-header.a2ui-tab-button {
401
+ min-height: 44px;
402
+ padding: 0 14px !important;
403
+ border-radius: 0 !important;
404
+ border-bottom: 2px solid transparent !important;
405
+ background: transparent !important;
406
+ color: var(--color-text-muted) !important;
407
+ font-size: var(--font-size-sm);
408
+ font-weight: 400;
409
+ }
410
+
411
+ .a2ui-surface.a2ui-container .a2ui-tabs-header.a2ui-tab-button.active {
412
+ border-bottom-color: var(--color-text) !important;
413
+ color: var(--color-text) !important;
414
+ font-weight: var(--font-weight-medium);
415
+ }
416
+
417
+ @media (max-width: 640px) {
418
+ .skopon-module-panel__grid {
419
+ grid-template-columns: repeat(2, minmax(0, 1fr));
420
+ }
421
+
422
+ .skopon-module-panel__guide {
423
+ align-items: flex-start;
424
+ flex-direction: column;
425
+ }
426
+
427
+ .skopon-compact-upload {
428
+ grid-template-columns: 72px minmax(0, 1fr);
429
+ }
430
+
431
+ .skopon-compact-upload__thumb {
432
+ width: 72px;
433
+ height: 82px;
434
+ }
435
+ }
436
+
233
437
  .ask-user-curl-card {
234
438
  display: flex;
235
439
  flex-direction: column;
@@ -617,3 +821,94 @@
617
821
  padding: 12px 0;
618
822
  color: var(--color-text-secondary, rgba(0, 0, 0, 0.45));
619
823
  }
824
+
825
+ .skopon-file-source-actions {
826
+ display: flex;
827
+ flex-wrap: wrap;
828
+ gap: 8px;
829
+ margin: 8px 0;
830
+ }
831
+
832
+ .skopon-asset-picker {
833
+ display: grid;
834
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
835
+ gap: 12px;
836
+ max-height: min(68vh, 620px);
837
+ overflow: auto;
838
+ padding: 2px;
839
+ }
840
+
841
+ .skopon-asset-picker__card {
842
+ display: flex;
843
+ flex-direction: column;
844
+ gap: 9px;
845
+ min-width: 0;
846
+ padding: 10px;
847
+ border: 1px solid #e5e7eb;
848
+ border-radius: 10px;
849
+ background: #fff;
850
+ transition: border-color 160ms ease, box-shadow 160ms ease;
851
+ }
852
+
853
+ .skopon-asset-picker__card:hover {
854
+ border-color: #91caff;
855
+ box-shadow: 0 2px 10px rgba(22, 119, 255, 0.1);
856
+ }
857
+
858
+ .skopon-asset-picker__media {
859
+ display: flex;
860
+ align-items: center;
861
+ justify-content: center;
862
+ width: 100%;
863
+ height: 150px;
864
+ overflow: hidden;
865
+ border-radius: 6px;
866
+ background: #f5f7fa;
867
+ }
868
+
869
+ .skopon-asset-picker__media .ant-image,
870
+ .skopon-asset-picker__media .ant-image-img {
871
+ display: block;
872
+ width: 100%;
873
+ height: 100%;
874
+ }
875
+
876
+ .skopon-asset-picker__media .ant-image-img {
877
+ object-fit: contain;
878
+ }
879
+
880
+ .skopon-asset-picker__placeholder {
881
+ display: flex;
882
+ justify-content: center;
883
+ align-items: center;
884
+ width: 100%;
885
+ height: 100%;
886
+ color: #8c8c8c;
887
+ }
888
+
889
+ .skopon-asset-picker__name {
890
+ overflow-wrap: anywhere;
891
+ min-height: 22px;
892
+ }
893
+
894
+ @media (max-width: 600px) {
895
+ .skopon-asset-picker {
896
+ grid-template-columns: repeat(2, minmax(0, 1fr));
897
+ gap: 8px;
898
+ }
899
+
900
+ .skopon-asset-picker__card {
901
+ padding: 8px;
902
+ }
903
+
904
+ .skopon-asset-picker__media {
905
+ height: 120px;
906
+ }
907
+ }
908
+
909
+ .skopon-selected-assets__row {
910
+ display: flex;
911
+ align-items: center;
912
+ justify-content: space-between;
913
+ gap: 8px;
914
+ }
@@ -24,8 +24,10 @@ function toBlocksJsonShape(
24
24
  return source
25
25
  }
26
26
  return {
27
+ ...(obj.schema === 'skopon.material-form/v2' ? { schema: obj.schema } : {}),
27
28
  title: typeof obj.title === 'string' ? obj.title : '',
28
29
  description: typeof obj.description === 'string' ? obj.description : '',
30
+ modules: Array.isArray(obj.modules) ? obj.modules : undefined,
29
31
  blocks: obj.blocks,
30
32
  }
31
33
  }
@@ -3,7 +3,8 @@ import { hasRenderableFormOptions } from '../adapter/formOptions'
3
3
  import type { FormBlock, FormSchema } from '../types/index'
4
4
  import { isDisplayBlockType, isInputBlockType } from '../types/index'
5
5
 
6
- export type PayloadBlocksDefinition = Pick<FormSchema, 'title' | 'description' | 'blocks'> & {
6
+ export type PayloadBlocksDefinition = Pick<FormSchema, 'title' | 'description' | 'modules' | 'blocks'> & {
7
+ schema?: 'skopon.material-form/v2'
7
8
  /**
8
9
  * ask 原始 blocks(normalize 前)。交集合并按此键集覆盖 form,
9
10
  * 避免 normalize 填入的空默认值误盖表单样式。
@@ -18,9 +19,24 @@ export interface PayloadBlocksIntersection {
18
19
  renderBlocks: FormBlock[]
19
20
  title: string
20
21
  description: string
22
+ modules?: FormSchema['modules']
21
23
  }
22
24
 
23
25
  const FORM_ANCHORED_KEYS = new Set(['type', 'name', 'id'])
26
+ const RUNTIME_SNAPSHOT_KEYS = new Set([
27
+ 'defaultValue',
28
+ 'fileReviewStatus',
29
+ 'fileReviewMessage',
30
+ 'fileRejectedUrls',
31
+ ])
32
+
33
+ export interface IntersectPayloadBlocksOptions {
34
+ /**
35
+ * snapshot:消息快照中的显式配置优先(兼容旧行为);
36
+ * latest:最新表单负责布局/文案/校验,只保留快照里的填写与审核运行态。
37
+ */
38
+ definitionPolicy?: 'snapshot' | 'latest'
39
+ }
24
40
 
25
41
  function isHiddenTextBlock(block: FormBlock): boolean {
26
42
  return block.type === 'text' && block.hidden === true
@@ -94,7 +110,27 @@ function mergeBlock(formBlock: FormBlock, rawPayloadBlock: FormBlock): FormBlock
94
110
  merged.type = formBlock.type
95
111
  merged.name = formBlock.name?.trim() || rawPayloadBlock.name
96
112
  merged.id = formBlock.id
97
- return normalizePayloadBlock(merged)
113
+ const normalized = normalizePayloadBlock(merged)
114
+ // 单 block normalize 没有 modules 上下文,会把合法 moduleId 当成悬空引用清掉。
115
+ normalized.moduleId = merged.moduleId
116
+ return normalized
117
+ }
118
+
119
+ function mergeBlockWithLatestDefinition(
120
+ formBlock: FormBlock,
121
+ rawPayloadBlock: FormBlock,
122
+ ): FormBlock {
123
+ const merged: FormBlock = { ...formBlock }
124
+ for (const key of Object.keys(rawPayloadBlock) as (keyof FormBlock)[]) {
125
+ if (!RUNTIME_SNAPSHOT_KEYS.has(key)) continue
126
+ if (!Object.prototype.hasOwnProperty.call(rawPayloadBlock, key)) continue
127
+ const value = rawPayloadBlock[key]
128
+ if (value === undefined) continue
129
+ Object.assign(merged, { [key]: value })
130
+ }
131
+ const normalized = normalizePayloadBlock(merged)
132
+ normalized.moduleId = merged.moduleId
133
+ return normalized
98
134
  }
99
135
 
100
136
  /** 解析 ask_user payload 为 blocksJson 形(title / description / blocks) */
@@ -108,12 +144,15 @@ export function parsePayloadBlocksJson(payload: unknown): PayloadBlocksDefinitio
108
144
  const normalized = normalizeFormDefinition({
109
145
  title: typeof obj.title === 'string' ? obj.title : '',
110
146
  description: typeof obj.description === 'string' ? obj.description : '',
147
+ modules: Array.isArray(obj.modules) ? obj.modules as FormSchema['modules'] : undefined,
111
148
  blocks: rawBlocks,
112
149
  jsonSchema: {},
113
150
  })
114
151
  return {
152
+ schema: obj.schema === 'skopon.material-form/v2' ? obj.schema : undefined,
115
153
  title: normalized.title,
116
154
  description: normalized.description,
155
+ modules: normalized.modules,
117
156
  blocks: normalized.blocks,
118
157
  rawBlocks,
119
158
  }
@@ -126,7 +165,9 @@ export function parsePayloadBlocksJson(payload: unknown): PayloadBlocksDefinitio
126
165
  export function intersectPayloadBlocksWithForm(
127
166
  payloadDef: PayloadBlocksDefinition,
128
167
  formDefinition: FormSchema | undefined,
168
+ options?: IntersectPayloadBlocksOptions,
129
169
  ): PayloadBlocksIntersection {
170
+ const definitionPolicy = options?.definitionPolicy ?? 'snapshot'
130
171
  const formByName = new Map<string, FormBlock>()
131
172
  for (const block of formDefinition?.blocks ?? []) {
132
173
  if (isInputBlockType(block.type) && block.name?.trim()) {
@@ -144,7 +185,11 @@ export function intersectPayloadBlocksWithForm(
144
185
  const formBlock = formByName.get(name)
145
186
  if (formBlock) {
146
187
  const rawPayloadBlock = resolveRawInputBlock(payloadDef, name, payloadBlock)
147
- matchedBlocks.push(mergeBlock(formBlock, rawPayloadBlock))
188
+ matchedBlocks.push(
189
+ definitionPolicy === 'latest'
190
+ ? mergeBlockWithLatestDefinition(formBlock, rawPayloadBlock)
191
+ : mergeBlock(formBlock, rawPayloadBlock),
192
+ )
148
193
  } else {
149
194
  extraBlocks.push(normalizePayloadBlock(payloadBlock))
150
195
  }
@@ -156,6 +201,10 @@ export function intersectPayloadBlocksWithForm(
156
201
  renderBlocks: buildRenderBlocks(payloadDef, matchedBlocks),
157
202
  title: payloadDef.title?.trim() || formDefinition?.title?.trim() || '',
158
203
  description: payloadDef.description?.trim() || formDefinition?.description?.trim() || '',
204
+ modules:
205
+ definitionPolicy === 'latest'
206
+ ? (formDefinition?.modules?.length ? formDefinition.modules : payloadDef.modules)
207
+ : (payloadDef.modules?.length ? payloadDef.modules : formDefinition?.modules),
159
208
  }
160
209
  }
161
210
 
@@ -262,6 +262,60 @@ describe('intersectPayloadBlocksWithForm', () => {
262
262
  expect(result.matchedBlocks[0]?.defaultValue).toBe('hello')
263
263
  })
264
264
 
265
+ it('uses latest form presentation while preserving snapshot runtime state', () => {
266
+ const form: FormSchema = {
267
+ title: 'Latest Form',
268
+ description: '',
269
+ modules: [{ id: 'latest-person', label: '人物(新版)' }],
270
+ blocks: [{
271
+ id: 'latest-file',
272
+ type: 'file',
273
+ name: 'image01',
274
+ label: '新版人物图',
275
+ moduleId: 'latest-person',
276
+ help: '新版说明',
277
+ fileReviewCriteria: '新版审核标准',
278
+ fileExampleUrls: ['https://example.com/latest.png'],
279
+ }],
280
+ jsonSchema: {},
281
+ }
282
+ const snapshot = parsePayloadBlocksJson({
283
+ title: '当前任务素材',
284
+ modules: [{ id: 'old-person', label: '人物(旧版)' }],
285
+ blocks: [{
286
+ id: 'old-file',
287
+ type: 'file',
288
+ name: 'image01',
289
+ label: '旧版人物图',
290
+ moduleId: 'old-person',
291
+ help: '旧版说明',
292
+ fileReviewCriteria: '旧版审核标准',
293
+ defaultValue: ['https://example.com/uploaded.png'],
294
+ fileReviewStatus: 'failed',
295
+ fileReviewMessage: '人物模糊',
296
+ fileRejectedUrls: ['https://example.com/rejected.png'],
297
+ }],
298
+ })!
299
+
300
+ const result = intersectPayloadBlocksWithForm(snapshot, form, {
301
+ definitionPolicy: 'latest',
302
+ })
303
+
304
+ expect(result.modules).toEqual(form.modules)
305
+ expect(result.matchedBlocks[0]).toMatchObject({
306
+ id: 'latest-file',
307
+ label: '新版人物图',
308
+ moduleId: 'latest-person',
309
+ help: '新版说明',
310
+ fileReviewCriteria: '新版审核标准',
311
+ fileExampleUrls: ['https://example.com/latest.png'],
312
+ defaultValue: ['https://example.com/uploaded.png'],
313
+ fileReviewStatus: 'failed',
314
+ fileReviewMessage: '人物模糊',
315
+ fileRejectedUrls: ['https://example.com/rejected.png'],
316
+ })
317
+ })
318
+
265
319
  it('keeps form caseFilter when ask omits caseFilter key', () => {
266
320
  const form: FormSchema = {
267
321
  title: 'Form',
@@ -20,6 +20,16 @@ export type A2uiComponentName =
20
20
  | 'SkoponSelect'
21
21
  | 'SkoponResumeSelect'
22
22
  | 'SkoponCaseSelect'
23
+ | 'SkoponModulePanel'
24
+
25
+ /** 表单分组模块。运行时以 Tab 展示,要求与示例统一放在模块底部。 */
26
+ export interface FormModule {
27
+ id: string
28
+ label: string
29
+ description?: string
30
+ reviewCriteria?: string
31
+ exampleUrls?: string[]
32
+ }
23
33
 
24
34
  export interface A2uiComponentNode {
25
35
  id: string
@@ -88,6 +98,14 @@ export interface FormBlockOption {
88
98
  label: string
89
99
  }
90
100
 
101
+ export interface FormAssetOption {
102
+ assetId: string
103
+ url: string
104
+ previewUrl?: string
105
+ label: string
106
+ assetType?: string
107
+ }
108
+
91
109
  export type FormConditionOperator = 'eq' | 'ne' | 'empty' | 'notEmpty'
92
110
 
93
111
  export interface FormBlockCondition {
@@ -161,6 +179,25 @@ export interface FormBlock {
161
179
  filePlaceholderIcon?: FormFilePlaceholderIcon
162
180
  fileMinCount?: number
163
181
  fileMaxCount?: number
182
+ /** 上传素材的示例图,仅展示,不参与提交。 */
183
+ fileExampleUrls?: string[]
184
+ /** 制作模板的逐字段审核标准;由工作流读取,不能使用提交值覆盖。 */
185
+ fileReviewCriteria?: string
186
+ /** 是否需要由工作流审核上传内容;素材库已审核素材可以关闭。 */
187
+ fileReviewRequired?: boolean
188
+ /** 物料采集来源模式,由 skopon-admin 环节配置。 */
189
+ fileSourceMode?: 'upload_required' | 'upload_or_asset' | 'asset_only'
190
+ /** 通用收集流从 Footage 为当前素材位查询的候选;仅提交选中的文件 URL。 */
191
+ fileAssetOptions?: FormAssetOption[]
192
+ /** 对应 Footage 同步素材位的限定 key。 */
193
+ assetSlotKey?: string
194
+ assetType?: 'image' | 'video' | 'audio' | 'text'
195
+ /** 运行时素材审核状态;用于返工表单回显,不作为用户提交值。 */
196
+ fileReviewStatus?: 'passed' | 'failed'
197
+ /** 运行时素材审核说明;failed 时通常为不合格原因。 */
198
+ fileReviewMessage?: string
199
+ /** 审核未通过的历史文件,仅作错误态回显,不计入有效提交。 */
200
+ fileRejectedUrls?: string[]
164
201
  defaultValue?: string | string[] | boolean
165
202
  /** 派生 JSON Schema 时写入 property.$comment */
166
203
  jsonSchemaDescription?: string
@@ -191,11 +228,14 @@ export interface FormBlock {
191
228
  /** 案例单选:列表筛选条件(持久化进 block json) */
192
229
  caseFilter?: FormCaseFilter
193
230
  condition?: FormBlockCondition
231
+ /** 所属模块;命中 FormSchema.modules.id 时在对应 Tab 中展示。 */
232
+ moduleId?: string
194
233
  }
195
234
 
196
235
  export interface FormSchema {
197
236
  title?: string
198
237
  description?: string
238
+ modules?: FormModule[]
199
239
  blocks: FormBlock[]
200
240
  jsonSchema: FormJsonSchema
201
241
  }
@@ -236,6 +276,7 @@ export function supportsDefaultValue(type: FormBlockType): boolean {
236
276
  export interface FormDefinitionPayload {
237
277
  title?: string
238
278
  description?: string
279
+ modules?: FormModule[]
239
280
  blocks?: FormBlock[]
240
281
  a2ui?: A2uiSurfaceDoc
241
282
  json_schema?: FormJsonSchema