@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
@@ -8,9 +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,
15
+ Image,
16
+ Modal,
14
17
  Radio,
15
18
  Select,
16
19
  Switch,
@@ -29,6 +32,7 @@ import {
29
32
  } from '../adapter/formFileAccept'
30
33
  import { buildMediaListClassName, normalizeMediaSize } from '../adapter/formMedia'
31
34
  import { normalizeFormOptions } from '../adapter/formOptions'
35
+ import { normalizeAssetOptions } from '../adapter/formAssetOptions'
32
36
  import {
33
37
  getRegisteredCatalogComponentNames,
34
38
  getRegisteredCatalogComponents,
@@ -56,6 +60,7 @@ export const SKOPON_CATALOG_ID = 'skopon-form-v0_9'
56
60
  const SKOPON_COMPONENT_NAMES = new Set([
57
61
  'SkoponMedia',
58
62
  'SkoponSelect',
63
+ 'SkoponModulePanel',
59
64
  'Image',
60
65
  'Video',
61
66
  'AudioPlayer',
@@ -279,6 +284,58 @@ const SkoponSelectImpl = createBinderlessComponentImplementation(
279
284
  SkoponSelectPreview,
280
285
  )
281
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
+
282
339
  function ChoicePickerPreview({ context }: { context: ComponentContext }) {
283
340
  const { interactive } = useA2uiPreviewMode()
284
341
  const { value, setValue, setValueSilent, fieldName } = useSkoponBoundField(context, 'value', {
@@ -545,8 +602,21 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
545
602
  changeAction: 'change',
546
603
  })
547
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)
548
608
  const label = readString(props.label)
549
609
  const accept = readString(props.accept)
610
+ const help = readString(props.help)
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))
618
+ const exampleUrls = asStringArray(props.fileExampleUrls)
619
+ .filter((url) => /^https?:\/\//i.test(url))
550
620
  const fileAcceptTypes = Array.isArray(props.fileAcceptTypes)
551
621
  ? props.fileAcceptTypes.map(String)
552
622
  : undefined
@@ -561,18 +631,29 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
561
631
  )
562
632
  ? (props.filePlaceholderIcon as FormFilePlaceholderIcon)
563
633
  : 'document'
634
+ const compact = props.compact === true
564
635
  const minCount = typeof props.minCount === 'number' ? Math.max(0, props.minCount) : 0
565
636
  const maxCount = typeof props.maxCount === 'number' ? props.maxCount : 1
566
637
  const hasMaxLimit = maxCount > 0
638
+ const sourceLabel = sourceMode === 'asset_only'
639
+ ? '素材 · 仅选择'
640
+ : sourceMode === 'upload_or_asset'
641
+ ? '物料或素材'
642
+ : '物料 · 仅上传'
567
643
  const initialUrls = normalizeFileUrls(value)
568
644
  const externalUidRef = useRef(0)
569
645
  const mountedRef = useRef(true)
570
646
  const controllersRef = useRef(new Map<string, AbortController>())
571
647
  const setValueRef = useRef(setValue)
572
648
  setValueRef.current = setValue
573
- const [items, setItems] = useState<FileUploadListItem[]>(() =>
574
- initialUrls.map((url) => fileItemFromUrl(url, `external-${externalUidRef.current++}`)),
575
- )
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
+ ])
576
657
  const itemsRef = useRef(items)
577
658
  itemsRef.current = items
578
659
 
@@ -636,14 +717,15 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
636
717
 
637
718
  const startUpload = useCallback(
638
719
  (file: File & { uid?: string }) => {
639
- if (!interactive) return
640
- 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
641
723
 
642
724
  const uid = `${file.uid ?? 'file'}-${externalUidRef.current++}`
643
725
  const controller = new AbortController()
644
726
  controllersRef.current.set(uid, controller)
645
727
  commitItems(
646
- (current) => [...current, { uid, name: file.name, status: 'uploading' }],
728
+ (current) => [...current.filter((item) => item.status !== 'error'), { uid, name: file.name, status: 'uploading' }],
647
729
  false,
648
730
  )
649
731
  const endUpload = beginUpload()
@@ -677,7 +759,7 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
677
759
  endUpload()
678
760
  })
679
761
  },
680
- [beginUpload, commitItems, hasMaxLimit, interactive, maxCount],
762
+ [beginUpload, commitItems, hasMaxLimit, interactive, maxCount, sourceMode],
681
763
  )
682
764
 
683
765
  const handleRemove = useCallback(
@@ -690,7 +772,7 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
690
772
  )
691
773
 
692
774
  const uploadedCount = items.filter((item) => item.status === 'done').length
693
- const atMaxLimit = hasMaxLimit && items.length >= maxCount
775
+ const atMaxLimit = hasMaxLimit && items.filter((item) => item.status !== 'error').length >= maxCount
694
776
  const belowMinCount = uploadedCount < minCount
695
777
  const latestError = [...items].reverse().find((item) => item.error)?.error
696
778
  const fileList: UploadFile[] = items.map((item) => ({
@@ -700,17 +782,131 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
700
782
  url: item.url,
701
783
  response: item.error,
702
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
+ }
703
877
 
704
878
  return (
705
879
  <div className="form-block-preview" data-skopon-field={fieldName || undefined}>
706
880
  {label ? <div className="form-block-preview-label">{label}</div> : 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}
889
+ {exampleUrls.length ? (
890
+ <div className="form-file-examples">
891
+ <Typography.Text type="secondary">参考示例(不作为上传素材提交)</Typography.Text>
892
+ <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', margin: '8px 0' }}>
893
+ <Image.PreviewGroup>
894
+ {exampleUrls.map((url, index) => (
895
+ <Image key={url} src={url} alt={`${label || '素材'}示例 ${index + 1}`} width={120} height={120} style={{ objectFit: 'contain' }} />
896
+ ))}
897
+ </Image.PreviewGroup>
898
+ </div>
899
+ </div>
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' ?
707
903
  <Upload.Dragger
708
- disabled={!interactive}
709
- openFileDialogOnClick={!atMaxLimit}
904
+ disabled={!interactive || reviewStatus === 'passed'}
905
+ openFileDialogOnClick={reviewStatus !== 'passed' && !atMaxLimit}
710
906
  accept={buildFileAcceptAttribute(fileAcceptTypes, fileAcceptExtensions)}
711
907
  className="form-file-upload-preview"
712
908
  multiple={!hasMaxLimit || maxCount > 1}
713
- showUploadList={interactive && items.length > 0}
909
+ showUploadList={interactive && items.length > 0 ? { showRemoveIcon: reviewStatus !== 'passed' } : false}
714
910
  fileList={interactive ? fileList : []}
715
911
  beforeUpload={(file) => {
716
912
  if (!interactive) return Upload.LIST_IGNORE
@@ -718,6 +914,7 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
718
914
  return Upload.LIST_IGNORE
719
915
  }}
720
916
  onRemove={(file) => {
917
+ if (reviewStatus === 'passed') return false
721
918
  handleRemove(file.uid)
722
919
  return true
723
920
  }}
@@ -727,11 +924,15 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
727
924
  </p>
728
925
  <p className="form-file-upload-preview-text">点击或拖拽文件到此处上传</p>
729
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>}
730
931
  <Typography.Text type="secondary" className="form-block-preview-help">
731
932
  允许:{acceptSummary ?? '全部类型'}
732
933
  {minCount > 0 ? `;至少 ${minCount} 个文件` : ''}
733
- {hasMaxLimit ? `;最多 ${maxCount} 个文件` : ''}
734
- ;选择后自动上传至 CDN
934
+ {maxCount > 1 ? `;最多 ${maxCount} 个文件` : ''}
935
+ {sourceMode === 'asset_only' ? ';从推荐素材中选择' : ';选择文件后自动上传至 CDN'}
735
936
  {belowMinCount && interactive ? `(当前 ${uploadedCount} 个,未达下限)` : ''}
736
937
  </Typography.Text>
737
938
  {latestError ? (
@@ -739,6 +940,7 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
739
940
  {latestError}
740
941
  </Typography.Text>
741
942
  ) : null}
943
+ {assetPicker}
742
944
  </div>
743
945
  )
744
946
  }
@@ -906,6 +1108,7 @@ export function buildSkoponCatalog(): Catalog<ReactComponentImplementation> {
906
1108
  ...baseComponents,
907
1109
  SkoponMediaImpl,
908
1110
  SkoponSelectImpl,
1111
+ SkoponModulePanelImpl,
909
1112
  ImageImpl,
910
1113
  VideoImpl,
911
1114
  AudioPlayerImpl,
@@ -56,6 +56,12 @@ function renderFileSurface(options: {
56
56
  defaultValue?: string | string[]
57
57
  legacyRawValue?: string
58
58
  onUploadingChange?: (uploading: boolean) => void
59
+ fileExampleUrls?: string[]
60
+ fileReviewCriteria?: string
61
+ fileReviewStatus?: 'passed' | 'failed'
62
+ fileReviewMessage?: string
63
+ fileRejectedUrls?: string[]
64
+ help?: string
59
65
  } = {}) {
60
66
  const doc = blocksToA2ui(
61
67
  {
@@ -70,6 +76,12 @@ function renderFileSurface(options: {
70
76
  fileMinCount: options.minCount ?? 0,
71
77
  fileMaxCount: options.maxCount ?? 1,
72
78
  defaultValue: options.defaultValue,
79
+ fileExampleUrls: options.fileExampleUrls,
80
+ fileReviewCriteria: options.fileReviewCriteria,
81
+ fileReviewStatus: options.fileReviewStatus,
82
+ fileReviewMessage: options.fileReviewMessage,
83
+ fileRejectedUrls: options.fileRejectedUrls,
84
+ help: options.help,
73
85
  },
74
86
  ],
75
87
  },
@@ -94,6 +106,39 @@ function renderFileSurface(options: {
94
106
  }
95
107
 
96
108
  describe('FileUploadPreview', () => {
109
+ it('shows descriptions, criteria and examples without submitting example URLs', () => {
110
+ const { getByText, getByAltText, ref } = renderFileSurface({
111
+ help: '请上传一张人物图',
112
+ fileReviewCriteria: '五官清晰',
113
+ fileExampleUrls: ['https://cdn.example.com/example.jpg'],
114
+ })
115
+ expect(getByText('请上传一张人物图')).toBeTruthy()
116
+ expect(getByText('素材要求:五官清晰')).toBeTruthy()
117
+ expect(getByAltText('附件示例 1').getAttribute('src')).toBe('https://cdn.example.com/example.jpg')
118
+ expect(ref.current?.getValues()).toEqual({ attachments: [] })
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
+ })
97
142
  it('stores successful CDN URLs in user selection order', async () => {
98
143
  const first = deferred<{ url: string }>()
99
144
  const second = deferred<{ url: string }>()
@@ -175,3 +220,31 @@ describe('FileUploadPreview', () => {
175
220
  expect(getByText('legacy file.pdf')).toBeTruthy()
176
221
  })
177
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,