@skopon-cool/form-sdk 0.6.3 → 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 +2002 -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 +35 -1
  19. package/dist/types/index.d.ts.map +1 -1
  20. package/package.json +1 -1
  21. package/src/adapter/a2uiAdapter.test.ts +4 -0
  22. package/src/adapter/a2uiAdapter.ts +58 -2
  23. package/src/adapter/formAssetOptions.ts +19 -0
  24. package/src/adapter/formSchema.ts +54 -0
  25. package/src/adapter/resolveSurface.ts +4 -1
  26. package/src/catalog/a2uiCustomCatalog.tsx +199 -15
  27. package/src/catalog/fileUploadPreview.test.tsx +56 -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 +37 -0
@@ -8,10 +8,12 @@ import {
8
8
  } from '@a2ui/react/v0_9'
9
9
  import {
10
10
  Checkbox,
11
+ Button,
11
12
  DatePicker,
12
13
  Input,
13
14
  InputNumber,
14
15
  Image,
16
+ Modal,
15
17
  Radio,
16
18
  Select,
17
19
  Switch,
@@ -30,6 +32,7 @@ import {
30
32
  } from '../adapter/formFileAccept'
31
33
  import { buildMediaListClassName, normalizeMediaSize } from '../adapter/formMedia'
32
34
  import { normalizeFormOptions } from '../adapter/formOptions'
35
+ import { normalizeAssetOptions } from '../adapter/formAssetOptions'
33
36
  import {
34
37
  getRegisteredCatalogComponentNames,
35
38
  getRegisteredCatalogComponents,
@@ -57,6 +60,7 @@ export const SKOPON_CATALOG_ID = 'skopon-form-v0_9'
57
60
  const SKOPON_COMPONENT_NAMES = new Set([
58
61
  'SkoponMedia',
59
62
  'SkoponSelect',
63
+ 'SkoponModulePanel',
60
64
  'Image',
61
65
  'Video',
62
66
  'AudioPlayer',
@@ -280,6 +284,58 @@ const SkoponSelectImpl = createBinderlessComponentImplementation(
280
284
  SkoponSelectPreview,
281
285
  )
282
286
 
287
+ const SkoponModulePanelImpl = createBinderlessComponentImplementation(
288
+ {
289
+ name: 'SkoponModulePanel',
290
+ schema: z.object({
291
+ children: z.array(z.string()).optional(),
292
+ description: z.any().optional(),
293
+ reviewCriteria: z.any().optional(),
294
+ exampleUrls: z.any().optional(),
295
+ }).passthrough(),
296
+ },
297
+ ({ context, buildChild }) => {
298
+ const props = context.componentModel.properties as Record<string, unknown>
299
+ const children = Array.isArray(props.children) ? props.children.map(String) : []
300
+ const description = readString(props.description).trim()
301
+ const requirementsTitle = readString(props.requirementsTitle).trim() || '本类素材要求'
302
+ const reviewCriteria = readString(props.reviewCriteria).trim()
303
+ const exampleUrls = Array.isArray(props.exampleUrls)
304
+ ? props.exampleUrls.map(String).map((url) => url.trim()).filter(Boolean)
305
+ : []
306
+ return (
307
+ <section className="skopon-module-panel">
308
+ {description ? <Typography.Paragraph type="secondary" className="skopon-module-panel__description">{description}</Typography.Paragraph> : null}
309
+ <div className="skopon-module-panel__grid">
310
+ {children.map((id) => <div className="skopon-module-panel__field" key={id}>{buildChild(id)}</div>)}
311
+ </div>
312
+ {reviewCriteria || exampleUrls.length > 0 ? (
313
+ <aside className="skopon-module-panel__guide">
314
+ {reviewCriteria ? (
315
+ <div className="skopon-module-panel__requirements">
316
+ <Typography.Text strong>{requirementsTitle}</Typography.Text>
317
+ <Typography.Paragraph type="secondary">{reviewCriteria}</Typography.Paragraph>
318
+ </div>
319
+ ) : null}
320
+ {exampleUrls.length > 0 ? (
321
+ <div className="skopon-module-panel__examples">
322
+ <Typography.Text strong>参考示例</Typography.Text>
323
+ <div className="skopon-module-panel__example-list">
324
+ <Image.PreviewGroup>
325
+ {exampleUrls.map((url, index) => (
326
+ <Image key={`${url}-${index}`} src={url} alt={`参考示例 ${index + 1}`} width={88} height={88} style={{ objectFit: 'cover' }} />
327
+ ))}
328
+ </Image.PreviewGroup>
329
+ </div>
330
+ </div>
331
+ ) : null}
332
+ </aside>
333
+ ) : null}
334
+ </section>
335
+ )
336
+ },
337
+ )
338
+
283
339
  function ChoicePickerPreview({ context }: { context: ComponentContext }) {
284
340
  const { interactive } = useA2uiPreviewMode()
285
341
  const { value, setValue, setValueSilent, fieldName } = useSkoponBoundField(context, 'value', {
@@ -546,10 +602,19 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
546
602
  changeAction: 'change',
547
603
  })
548
604
  const props = context.componentModel.properties as Record<string, unknown>
605
+ const sourceMode = props.fileSourceMode === 'asset_only' || props.fileSourceMode === 'upload_or_asset' ? props.fileSourceMode : 'upload_required'
606
+ const assetOptions = normalizeAssetOptions(props.fileAssetOptions)
607
+ const [assetPickerOpen, setAssetPickerOpen] = useState(false)
549
608
  const label = readString(props.label)
550
609
  const accept = readString(props.accept)
551
610
  const help = readString(props.help)
552
611
  const reviewCriteria = readString(props.fileReviewCriteria)
612
+ const reviewStatus = props.fileReviewStatus === 'passed' || props.fileReviewStatus === 'failed'
613
+ ? props.fileReviewStatus
614
+ : undefined
615
+ const reviewMessage = readString(props.fileReviewMessage)
616
+ const rejectedUrls = asStringArray(props.fileRejectedUrls)
617
+ .filter((url) => /^https?:\/\//i.test(url))
553
618
  const exampleUrls = asStringArray(props.fileExampleUrls)
554
619
  .filter((url) => /^https?:\/\//i.test(url))
555
620
  const fileAcceptTypes = Array.isArray(props.fileAcceptTypes)
@@ -566,18 +631,29 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
566
631
  )
567
632
  ? (props.filePlaceholderIcon as FormFilePlaceholderIcon)
568
633
  : 'document'
634
+ const compact = props.compact === true
569
635
  const minCount = typeof props.minCount === 'number' ? Math.max(0, props.minCount) : 0
570
636
  const maxCount = typeof props.maxCount === 'number' ? props.maxCount : 1
571
637
  const hasMaxLimit = maxCount > 0
638
+ const sourceLabel = sourceMode === 'asset_only'
639
+ ? '素材 · 仅选择'
640
+ : sourceMode === 'upload_or_asset'
641
+ ? '物料或素材'
642
+ : '物料 · 仅上传'
572
643
  const initialUrls = normalizeFileUrls(value)
573
644
  const externalUidRef = useRef(0)
574
645
  const mountedRef = useRef(true)
575
646
  const controllersRef = useRef(new Map<string, AbortController>())
576
647
  const setValueRef = useRef(setValue)
577
648
  setValueRef.current = setValue
578
- const [items, setItems] = useState<FileUploadListItem[]>(() =>
579
- initialUrls.map((url) => fileItemFromUrl(url, `external-${externalUidRef.current++}`)),
580
- )
649
+ const [items, setItems] = useState<FileUploadListItem[]>(() => [
650
+ ...initialUrls.map((url) => fileItemFromUrl(url, `external-${externalUidRef.current++}`)),
651
+ ...rejectedUrls.map((url) => ({
652
+ ...fileItemFromUrl(url, `rejected-${externalUidRef.current++}`),
653
+ status: 'error' as const,
654
+ error: reviewMessage || '审核不合格,请重新上传',
655
+ })),
656
+ ])
581
657
  const itemsRef = useRef(items)
582
658
  itemsRef.current = items
583
659
 
@@ -641,14 +717,15 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
641
717
 
642
718
  const startUpload = useCallback(
643
719
  (file: File & { uid?: string }) => {
644
- if (!interactive) return
645
- if (hasMaxLimit && itemsRef.current.length >= maxCount) return
720
+ if (!interactive || sourceMode === 'asset_only') return
721
+ const activeCount = itemsRef.current.filter((item) => item.status !== 'error').length
722
+ if (hasMaxLimit && activeCount >= maxCount) return
646
723
 
647
724
  const uid = `${file.uid ?? 'file'}-${externalUidRef.current++}`
648
725
  const controller = new AbortController()
649
726
  controllersRef.current.set(uid, controller)
650
727
  commitItems(
651
- (current) => [...current, { uid, name: file.name, status: 'uploading' }],
728
+ (current) => [...current.filter((item) => item.status !== 'error'), { uid, name: file.name, status: 'uploading' }],
652
729
  false,
653
730
  )
654
731
  const endUpload = beginUpload()
@@ -682,7 +759,7 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
682
759
  endUpload()
683
760
  })
684
761
  },
685
- [beginUpload, commitItems, hasMaxLimit, interactive, maxCount],
762
+ [beginUpload, commitItems, hasMaxLimit, interactive, maxCount, sourceMode],
686
763
  )
687
764
 
688
765
  const handleRemove = useCallback(
@@ -695,7 +772,7 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
695
772
  )
696
773
 
697
774
  const uploadedCount = items.filter((item) => item.status === 'done').length
698
- const atMaxLimit = hasMaxLimit && items.length >= maxCount
775
+ const atMaxLimit = hasMaxLimit && items.filter((item) => item.status !== 'error').length >= maxCount
699
776
  const belowMinCount = uploadedCount < minCount
700
777
  const latestError = [...items].reverse().find((item) => item.error)?.error
701
778
  const fileList: UploadFile[] = items.map((item) => ({
@@ -705,12 +782,110 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
705
782
  url: item.url,
706
783
  response: item.error,
707
784
  }))
785
+ const assetPicker = <Modal title={`选择推荐素材 · ${label || '素材位置'}`} open={assetPickerOpen} footer={null} width={860} onCancel={() => setAssetPickerOpen(false)}>
786
+ {assetOptions.length ? <div className="skopon-asset-picker">
787
+ {assetOptions.map((asset) => {
788
+ const isImage = asset.assetType === 'image' || Boolean(asset.previewUrl && asset.previewUrl !== asset.url)
789
+ const chooseAsset = () => {
790
+ if (!interactive || reviewStatus === 'passed') return
791
+ if (hasMaxLimit && maxCount > 1 && atMaxLimit) return
792
+ if (maxCount === 1) {
793
+ for (const controller of controllersRef.current.values()) controller.abort()
794
+ controllersRef.current.clear()
795
+ }
796
+ commitItems((current) => {
797
+ const retained = maxCount === 1 ? [] : current.filter((item) => item.status !== 'error' && item.url !== asset.url)
798
+ return [...retained, { uid: `asset-${asset.assetId}-${externalUidRef.current++}`, name: asset.label, status: 'done', url: asset.url }]
799
+ }, true)
800
+ setAssetPickerOpen(false)
801
+ }
802
+ return <article key={asset.assetId} className="skopon-asset-picker__card">
803
+ <div className="skopon-asset-picker__media">
804
+ {isImage
805
+ ? <Image src={asset.previewUrl || asset.url} alt={asset.label} preview={{ mask: '放大预览' }} />
806
+ : <div className="skopon-asset-picker__placeholder"><FilePlaceholderIcon type={asset.assetType === 'video' ? 'video' : asset.assetType === 'audio' ? 'audio' : 'document'} size={36} /></div>}
807
+ </div>
808
+ <Typography.Text className="skopon-asset-picker__name" title={asset.label}>{asset.label}</Typography.Text>
809
+ <Button type="primary" size="small" block aria-label={`选择素材 ${asset.label}`} disabled={!interactive || reviewStatus === 'passed' || (hasMaxLimit && maxCount > 1 && atMaxLimit)} onClick={chooseAsset}>使用此素材</Button>
810
+ </article>
811
+ })}
812
+ </div> : <Typography.Paragraph type="secondary">此位置暂无可选素材,请先在素材库为该位置打标。</Typography.Paragraph>}
813
+ </Modal>
814
+
815
+ if (compact) {
816
+ const displayItem = items.find((item) => item.status === 'error') ?? items[0]
817
+ const displayUrl = displayItem?.url
818
+ const isImage = fileAcceptTypes?.includes('image/*') ?? placeholderIcon === 'image'
819
+ const state = reviewStatus === 'passed'
820
+ ? 'passed'
821
+ : reviewStatus === 'failed'
822
+ ? 'failed'
823
+ : displayItem?.status === 'uploading'
824
+ ? 'uploading'
825
+ : displayItem?.status === 'done'
826
+ ? 'pending'
827
+ : 'empty'
828
+ const stateText = state === 'passed'
829
+ ? '审核通过'
830
+ : state === 'failed'
831
+ ? (reviewMessage || '审核不合格,请替换')
832
+ : state === 'uploading'
833
+ ? '正在上传…'
834
+ : state === 'pending'
835
+ ? '等待审核'
836
+ : `${acceptSummary ?? '文件'}${maxCount > 1 ? `,最多 ${maxCount} 个` : ''}`
837
+ const canChoose = interactive && state !== 'passed' && state !== 'uploading'
838
+
839
+ return (
840
+ <><article className={`skopon-compact-upload skopon-compact-upload--${state}`} data-skopon-field={fieldName || undefined}>
841
+ <div className="skopon-compact-upload__thumb">
842
+ {displayUrl && isImage ? (
843
+ <img src={displayUrl} alt={label || '已上传素材'} />
844
+ ) : (
845
+ <FilePlaceholderIcon type={placeholderIcon} size={30} />
846
+ )}
847
+ {state !== 'empty' ? <span className="skopon-compact-upload__state-mark" aria-hidden>
848
+ {state === 'passed' ? '✓' : state === 'failed' ? '!' : state === 'uploading' ? '…' : '•'}
849
+ </span> : null}
850
+ </div>
851
+ <div className="skopon-compact-upload__meta">
852
+ <strong>{label || '上传素材'}</strong>
853
+ <span className={`skopon-compact-upload__source skopon-compact-upload__source--${sourceMode}`}>{sourceLabel}</span>
854
+ <span className="skopon-compact-upload__status">{stateText}</span>
855
+ <div className="skopon-file-source-actions">
856
+ {canChoose && sourceMode !== 'asset_only' ? (
857
+ <Upload
858
+ accept={buildFileAcceptAttribute(fileAcceptTypes, fileAcceptExtensions)}
859
+ multiple={!hasMaxLimit || maxCount > 1}
860
+ showUploadList={false}
861
+ beforeUpload={(file) => {
862
+ if (displayItem?.status === 'done') handleRemove(displayItem.uid)
863
+ startUpload(file)
864
+ return Upload.LIST_IGNORE
865
+ }}
866
+ >
867
+ <Button size="small">上传</Button>
868
+ </Upload>
869
+ ) : null}
870
+ {canChoose && sourceMode !== 'upload_required' ? <Button size="small" onClick={() => setAssetPickerOpen(true)}>选择素材</Button> : null}
871
+ </div>
872
+ {latestError && state !== 'failed' ? <span className="skopon-compact-upload__error">{latestError}</span> : null}
873
+ </div>
874
+ </article>{assetPicker}</>
875
+ )
876
+ }
708
877
 
709
878
  return (
710
879
  <div className="form-block-preview" data-skopon-field={fieldName || undefined}>
711
880
  {label ? <div className="form-block-preview-label">{label}</div> : null}
712
- {help ? <Typography.Paragraph type="secondary">{help}</Typography.Paragraph> : null}
713
- {reviewCriteria ? <Typography.Paragraph type="secondary">素材要求:{reviewCriteria}</Typography.Paragraph> : null}
881
+ {reviewStatus === 'passed' ? (
882
+ <Typography.Paragraph style={{ color: '#389e0d', marginBottom: 8 }}>✅ 审核通过,已保留,无需重新上传</Typography.Paragraph>
883
+ ) : null}
884
+ {reviewStatus === 'failed' ? (
885
+ <Typography.Paragraph type="danger" style={{ marginBottom: 8 }}>❌ 审核不合格:{reviewMessage || '请重新上传'}</Typography.Paragraph>
886
+ ) : null}
887
+ {help ? <Typography.Paragraph type="secondary" style={{ whiteSpace: 'pre-line' }}>{help}</Typography.Paragraph> : null}
888
+ {reviewCriteria ? <Typography.Paragraph type="secondary" style={{ whiteSpace: 'pre-line' }}>素材要求:{reviewCriteria}</Typography.Paragraph> : null}
714
889
  {exampleUrls.length ? (
715
890
  <div className="form-file-examples">
716
891
  <Typography.Text type="secondary">参考示例(不作为上传素材提交)</Typography.Text>
@@ -723,13 +898,15 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
723
898
  </div>
724
899
  </div>
725
900
  ) : null}
901
+ {sourceMode !== 'upload_required' ? <div className="skopon-file-source-actions"><Button disabled={!interactive || reviewStatus === 'passed'} onClick={() => setAssetPickerOpen(true)}>选择素材</Button></div> : null}
902
+ {sourceMode !== 'asset_only' ?
726
903
  <Upload.Dragger
727
- disabled={!interactive}
728
- openFileDialogOnClick={!atMaxLimit}
904
+ disabled={!interactive || reviewStatus === 'passed'}
905
+ openFileDialogOnClick={reviewStatus !== 'passed' && !atMaxLimit}
729
906
  accept={buildFileAcceptAttribute(fileAcceptTypes, fileAcceptExtensions)}
730
907
  className="form-file-upload-preview"
731
908
  multiple={!hasMaxLimit || maxCount > 1}
732
- showUploadList={interactive && items.length > 0}
909
+ showUploadList={interactive && items.length > 0 ? { showRemoveIcon: reviewStatus !== 'passed' } : false}
733
910
  fileList={interactive ? fileList : []}
734
911
  beforeUpload={(file) => {
735
912
  if (!interactive) return Upload.LIST_IGNORE
@@ -737,6 +914,7 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
737
914
  return Upload.LIST_IGNORE
738
915
  }}
739
916
  onRemove={(file) => {
917
+ if (reviewStatus === 'passed') return false
740
918
  handleRemove(file.uid)
741
919
  return true
742
920
  }}
@@ -746,11 +924,15 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
746
924
  </p>
747
925
  <p className="form-file-upload-preview-text">点击或拖拽文件到此处上传</p>
748
926
  </Upload.Dragger>
927
+ : <div className="skopon-selected-assets">{items.map((item) => <div key={item.uid} className="skopon-selected-assets__row">
928
+ {item.url ? <a href={item.url} target="_blank" rel="noreferrer">{item.name}</a> : <span>{item.name}</span>}
929
+ {interactive && reviewStatus !== 'passed' ? <Button size="small" type="text" onClick={() => handleRemove(item.uid)}>移除</Button> : null}
930
+ </div>)}</div>}
749
931
  <Typography.Text type="secondary" className="form-block-preview-help">
750
932
  允许:{acceptSummary ?? '全部类型'}
751
933
  {minCount > 0 ? `;至少 ${minCount} 个文件` : ''}
752
- {hasMaxLimit ? `;最多 ${maxCount} 个文件` : ''}
753
- ;选择后自动上传至 CDN
934
+ {maxCount > 1 ? `;最多 ${maxCount} 个文件` : ''}
935
+ {sourceMode === 'asset_only' ? ';从推荐素材中选择' : ';选择文件后自动上传至 CDN'}
754
936
  {belowMinCount && interactive ? `(当前 ${uploadedCount} 个,未达下限)` : ''}
755
937
  </Typography.Text>
756
938
  {latestError ? (
@@ -758,6 +940,7 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
758
940
  {latestError}
759
941
  </Typography.Text>
760
942
  ) : null}
943
+ {assetPicker}
761
944
  </div>
762
945
  )
763
946
  }
@@ -925,6 +1108,7 @@ export function buildSkoponCatalog(): Catalog<ReactComponentImplementation> {
925
1108
  ...baseComponents,
926
1109
  SkoponMediaImpl,
927
1110
  SkoponSelectImpl,
1111
+ SkoponModulePanelImpl,
928
1112
  ImageImpl,
929
1113
  VideoImpl,
930
1114
  AudioPlayerImpl,
@@ -58,6 +58,9 @@ 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
62
65
  } = {}) {
63
66
  const doc = blocksToA2ui(
@@ -75,6 +78,9 @@ function renderFileSurface(options: {
75
78
  defaultValue: options.defaultValue,
76
79
  fileExampleUrls: options.fileExampleUrls,
77
80
  fileReviewCriteria: options.fileReviewCriteria,
81
+ fileReviewStatus: options.fileReviewStatus,
82
+ fileReviewMessage: options.fileReviewMessage,
83
+ fileRejectedUrls: options.fileRejectedUrls,
78
84
  help: options.help,
79
85
  },
80
86
  ],
@@ -111,6 +117,28 @@ describe('FileUploadPreview', () => {
111
117
  expect(getByAltText('附件示例 1').getAttribute('src')).toBe('https://cdn.example.com/example.jpg')
112
118
  expect(ref.current?.getValues()).toEqual({ attachments: [] })
113
119
  })
120
+ it('shows a rejected uploaded file and reason without treating its old URL as a valid answer', () => {
121
+ const { getByText, container, ref } = renderFileSurface({
122
+ minCount: 1,
123
+ fileReviewStatus: 'failed',
124
+ fileReviewMessage: '场景中检测到人物',
125
+ fileRejectedUrls: ['https://cdn.example.com/rejected-scene.jpg'],
126
+ })
127
+ expect(getByText('❌ 审核不合格:场景中检测到人物')).toBeTruthy()
128
+ expect(container.textContent).toContain('rejected-scene.jpg')
129
+ expect(ref.current?.getValues()).toEqual({ attachments: [] })
130
+ })
131
+
132
+ it('shows and locks a retained approved upload', () => {
133
+ const { getByText, container, ref } = renderFileSurface({
134
+ defaultValue: ['https://cdn.example.com/approved.jpg'],
135
+ fileReviewStatus: 'passed',
136
+ })
137
+ expect(getByText('✅ 审核通过,已保留,无需重新上传')).toBeTruthy()
138
+ expect(container.textContent).toContain('approved.jpg')
139
+ expect(container.querySelector('input[type="file"]')?.hasAttribute('disabled')).toBe(true)
140
+ expect(ref.current?.getValues()).toEqual({ attachments: ['https://cdn.example.com/approved.jpg'] })
141
+ })
114
142
  it('stores successful CDN URLs in user selection order', async () => {
115
143
  const first = deferred<{ url: string }>()
116
144
  const second = deferred<{ url: string }>()
@@ -192,3 +220,31 @@ describe('FileUploadPreview', () => {
192
220
  expect(getByText('legacy file.pdf')).toBeTruthy()
193
221
  })
194
222
  })
223
+
224
+ describe('Footage selection in generic material forms', () => {
225
+ function renderAssetField(mode: 'asset_only' | 'upload_or_asset') {
226
+ const ref = createRef<SkoponFormRendererRef>()
227
+ const doc = blocksToA2ui({ blocks: [{
228
+ id: 'asset-field', type: 'file', name: 'scene', label: '场景', fileSourceMode: mode,
229
+ fileMaxCount: 1, fileAssetOptions: [{ assetId: '17', label: '山景', assetType: 'image', url: 'https://cdn.test/scene.jpg' }],
230
+ }] }, { includeHeader: false })
231
+ return { ref, ...render(createElement(SkoponFormRenderer, { ref, doc, surfaceId: 'asset-test', fieldNames: ['scene'] })) }
232
+ }
233
+
234
+ it('asset-only fields choose from Footage and submit the URL without uploading', async () => {
235
+ const view = renderAssetField('asset_only')
236
+ expect(view.container.querySelector('input[type="file"]')).toBeNull()
237
+ fireEvent.click(view.getByRole('button', { name: '选择素材' }))
238
+ fireEvent.click(await view.findByRole('button', { name: '选择素材 山景' }))
239
+ await waitFor(() => expect(view.ref.current?.getValues().scene).toEqual(['https://cdn.test/scene.jpg']))
240
+ expect(uploadMock).not.toHaveBeenCalled()
241
+ })
242
+
243
+ it('mixed fields retain both upload and library selection actions', async () => {
244
+ const view = renderAssetField('upload_or_asset')
245
+ expect(view.container.querySelector('input[type="file"]')).not.toBeNull()
246
+ fireEvent.click(view.getByRole('button', { name: '选择素材' }))
247
+ fireEvent.click(await view.findByRole('button', { name: '选择素材 山景' }))
248
+ await waitFor(() => expect(view.ref.current?.getValues().scene).toEqual(['https://cdn.test/scene.jpg']))
249
+ })
250
+ })
@@ -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
+ })
@@ -61,6 +61,8 @@ export interface AskUserFormCardProps {
61
61
  payload: unknown
62
62
  /** 可选;无则跳过拉取 vt_forms,仅用 payload blocks fallback */
63
63
  formUniqueId?: string | null
64
+ /** 开放卡使用 latest;已提交/失效卡使用 snapshot,冻结发卡时外观。 */
65
+ formDefinitionPolicy?: 'latest' | 'snapshot'
64
66
  /**
65
67
  * ask_source_unique_id(Flow/QA 业务 id)。
66
68
  * 有值且 sessionStorage.auth_user.realtime_ws_url 存在、且渲染出表单控件时按卡建连。
@@ -90,6 +92,7 @@ interface AskUserFormCardBodyProps extends AskUserFormCardProps {
90
92
  function AskUserFormCardBody({
91
93
  payload,
92
94
  formUniqueId,
95
+ formDefinitionPolicy = 'latest',
93
96
  askUserMessageId,
94
97
  callbackUrl,
95
98
  submitMode = 'curl',
@@ -147,7 +150,7 @@ function AskUserFormCardBody({
147
150
 
148
151
  useEffect(() => {
149
152
  let cancelled = false
150
- if (!resolvedFormUniqueId) {
153
+ if (!resolvedFormUniqueId || formDefinitionPolicy === 'snapshot') {
151
154
  setLoading(false)
152
155
  setFormDisabled(false)
153
156
  setFormDef(null)
@@ -178,18 +181,30 @@ function AskUserFormCardBody({
178
181
  return () => {
179
182
  cancelled = true
180
183
  }
181
- }, [resolvedFormUniqueId, fetchFormDetail])
184
+ }, [resolvedFormUniqueId, fetchFormDetail, formDefinitionPolicy])
182
185
 
183
186
  const payloadDef = useMemo(() => parsePayloadBlocksJson(payload), [payload])
184
187
 
188
+ // Older hibot servers omitted schema when persisting card_data.payload.
189
+ // Runtime source mode is unique to the dynamic material collection payload,
190
+ // so retain its snapshot blocks in that compatibility case as well.
191
+ const dynamicMaterialForm = payloadDef?.schema === 'skopon.material-form/v2' || Boolean(
192
+ payloadDef?.rawBlocks?.some((block) =>
193
+ block.type === 'file' &&
194
+ ['upload_required', 'upload_or_asset', 'asset_only'].includes(String(block.fileSourceMode)),
195
+ ),
196
+ )
197
+
185
198
  const intersection = useMemo(() => {
186
199
  if (!payloadDef) return null
187
- return intersectPayloadBlocksWithForm(payloadDef, formDef ?? undefined)
188
- }, [payloadDef, formDef])
200
+ return intersectPayloadBlocksWithForm(payloadDef, formDef ?? undefined, {
201
+ definitionPolicy: formDefinitionPolicy,
202
+ })
203
+ }, [payloadDef, formDef, formDefinitionPolicy])
189
204
 
190
205
  const matchedBlocks = intersection?.matchedBlocks ?? []
191
206
  const usePayloadFallback =
192
- Boolean(payloadDef && payloadHasInputBlocks(payloadDef) && matchedBlocks.length === 0)
207
+ Boolean(payloadDef && payloadHasInputBlocks(payloadDef) && (dynamicMaterialForm || matchedBlocks.length === 0))
193
208
 
194
209
  const renderBlocks = useMemo(() => {
195
210
  if (usePayloadFallback && payloadDef) {
@@ -224,16 +239,17 @@ function AskUserFormCardBody({
224
239
  [renderBlocks],
225
240
  )
226
241
 
227
- const surfaceTitle = intersection?.title ?? payloadDef?.title ?? ''
228
- const surfaceDescription = intersection?.description ?? payloadDef?.description ?? ''
242
+ const surfaceTitle = dynamicMaterialForm ? payloadDef?.title ?? '' : intersection?.title ?? payloadDef?.title ?? ''
243
+ const surfaceDescription = dynamicMaterialForm ? payloadDef?.description ?? '' : intersection?.description ?? payloadDef?.description ?? ''
244
+ const surfaceModules = dynamicMaterialForm ? payloadDef?.modules : intersection?.modules ?? payloadDef?.modules
229
245
 
230
246
  const surfaceDoc = useMemo(() => {
231
247
  if (renderBlocks.length === 0) return null
232
248
  return buildAskUserSurface(
233
- { title: surfaceTitle, description: surfaceDescription },
249
+ { title: surfaceTitle, description: surfaceDescription, modules: surfaceModules },
234
250
  renderBlocks,
235
251
  )
236
- }, [surfaceTitle, surfaceDescription, renderBlocks])
252
+ }, [surfaceTitle, surfaceDescription, surfaceModules, renderBlocks])
237
253
 
238
254
  const docKey = useMemo(() => {
239
255
  if (!surfaceDoc) return ''
@@ -370,8 +386,10 @@ function AskUserFormCardBody({
370
386
  const effectiveForm =
371
387
  renderBlocks.length > 0
372
388
  ? {
389
+ ...(dynamicMaterialForm ? { schema: 'skopon.material-form/v2' as const } : {}),
373
390
  title: surfaceTitle,
374
391
  description: surfaceDescription,
392
+ modules: surfaceModules,
375
393
  blocks: renderBlocks,
376
394
  }
377
395
  : (payloadDef ?? undefined)
@@ -411,6 +429,7 @@ function AskUserFormCardBody({
411
429
  renderBlocks,
412
430
  surfaceTitle,
413
431
  surfaceDescription,
432
+ surfaceModules,
414
433
  extraValues,
415
434
  reportSnapshot,
416
435
  runtime,
@@ -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
+ })