@skopon-cool/form-sdk 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/adapter/a2uiAdapter.d.ts +1 -1
  2. package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
  3. package/dist/adapter/formAssetOptions.d.ts +3 -0
  4. package/dist/adapter/formAssetOptions.d.ts.map +1 -0
  5. package/dist/adapter/formSchema.d.ts.map +1 -1
  6. package/dist/adapter/resolveSurface.d.ts +1 -1
  7. package/dist/adapter/resolveSurface.d.ts.map +1 -1
  8. package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
  9. package/dist/catalog/materialUploadContext.d.ts +24 -0
  10. package/dist/catalog/materialUploadContext.d.ts.map +1 -0
  11. package/dist/components/AskUserFormCard.d.ts +5 -0
  12. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  13. package/dist/components/SkoponFormRenderer.d.ts +3 -0
  14. package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
  15. package/dist/form-sdk.css +1 -1
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +2170 -1928
  19. package/dist/submit/buildAnswerAgentBody.d.ts.map +1 -1
  20. package/dist/submit/intersectPayloadBlocksWithForm.d.ts +11 -2
  21. package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -1
  22. package/dist/types/index.d.ts +35 -1
  23. package/dist/types/index.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/src/adapter/a2uiAdapter.test.ts +4 -0
  26. package/src/adapter/a2uiAdapter.ts +58 -2
  27. package/src/adapter/formAssetOptions.ts +19 -0
  28. package/src/adapter/formSchema.ts +54 -0
  29. package/src/adapter/resolveSurface.ts +4 -1
  30. package/src/catalog/a2uiCustomCatalog.tsx +296 -46
  31. package/src/catalog/fileUploadPreview.test.tsx +77 -11
  32. package/src/catalog/materialUploadContext.tsx +43 -0
  33. package/src/catalog/moduleTabs.test.tsx +59 -0
  34. package/src/components/AskUserFormCard.tsx +33 -9
  35. package/src/components/AskUserFormCard.upload.test.tsx +66 -0
  36. package/src/components/SkoponFormRenderer.tsx +6 -0
  37. package/src/icons/FilePlaceholderIcon.tsx +2 -2
  38. package/src/index.ts +2 -0
  39. package/src/styles/index.css +295 -0
  40. package/src/submit/buildAnswerAgentBody.ts +2 -0
  41. package/src/submit/intersectPayloadBlocksWithForm.ts +52 -3
  42. package/src/submit/submit.test.ts +54 -0
  43. package/src/types/index.ts +37 -0
@@ -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,17 +32,16 @@ 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,
36
39
  } from '../blocks/registry'
37
40
  import FilePlaceholderIcon from '../icons/FilePlaceholderIcon'
38
- import {
39
- isUploadCancelledError,
40
- uploadFileToCdn,
41
- } from '../client/uploadClient'
41
+ import { isUploadCancelledError } from '../client/uploadClient'
42
42
  import { useA2uiPreviewMode } from './a2uiPreviewContext'
43
43
  import { useFileUploadStatus } from './fileUploadStatusContext'
44
+ import { useMaterialUpload } from './materialUploadContext'
44
45
  import {
45
46
  asOptionalString,
46
47
  asStringArray,
@@ -57,6 +58,7 @@ export const SKOPON_CATALOG_ID = 'skopon-form-v0_9'
57
58
  const SKOPON_COMPONENT_NAMES = new Set([
58
59
  'SkoponMedia',
59
60
  'SkoponSelect',
61
+ 'SkoponModulePanel',
60
62
  'Image',
61
63
  'Video',
62
64
  'AudioPlayer',
@@ -280,6 +282,58 @@ const SkoponSelectImpl = createBinderlessComponentImplementation(
280
282
  SkoponSelectPreview,
281
283
  )
282
284
 
285
+ const SkoponModulePanelImpl = createBinderlessComponentImplementation(
286
+ {
287
+ name: 'SkoponModulePanel',
288
+ schema: z.object({
289
+ children: z.array(z.string()).optional(),
290
+ description: z.any().optional(),
291
+ reviewCriteria: z.any().optional(),
292
+ exampleUrls: z.any().optional(),
293
+ }).passthrough(),
294
+ },
295
+ ({ context, buildChild }) => {
296
+ const props = context.componentModel.properties as Record<string, unknown>
297
+ const children = Array.isArray(props.children) ? props.children.map(String) : []
298
+ const description = readString(props.description).trim()
299
+ const requirementsTitle = readString(props.requirementsTitle).trim() || '本类素材要求'
300
+ const reviewCriteria = readString(props.reviewCriteria).trim()
301
+ const exampleUrls = Array.isArray(props.exampleUrls)
302
+ ? props.exampleUrls.map(String).map((url) => url.trim()).filter(Boolean)
303
+ : []
304
+ return (
305
+ <section className="skopon-module-panel">
306
+ {description ? <Typography.Paragraph type="secondary" className="skopon-module-panel__description">{description}</Typography.Paragraph> : null}
307
+ <div className="skopon-module-panel__grid">
308
+ {children.map((id) => <div className="skopon-module-panel__field" key={id}>{buildChild(id)}</div>)}
309
+ </div>
310
+ {reviewCriteria || exampleUrls.length > 0 ? (
311
+ <aside className="skopon-module-panel__guide">
312
+ {reviewCriteria ? (
313
+ <div className="skopon-module-panel__requirements">
314
+ <Typography.Text strong>{requirementsTitle}</Typography.Text>
315
+ <Typography.Paragraph type="secondary">{reviewCriteria}</Typography.Paragraph>
316
+ </div>
317
+ ) : null}
318
+ {exampleUrls.length > 0 ? (
319
+ <div className="skopon-module-panel__examples">
320
+ <Typography.Text strong>参考示例</Typography.Text>
321
+ <div className="skopon-module-panel__example-list">
322
+ <Image.PreviewGroup>
323
+ {exampleUrls.map((url, index) => (
324
+ <Image key={`${url}-${index}`} src={url} alt={`参考示例 ${index + 1}`} width={88} height={88} style={{ objectFit: 'cover' }} />
325
+ ))}
326
+ </Image.PreviewGroup>
327
+ </div>
328
+ </div>
329
+ ) : null}
330
+ </aside>
331
+ ) : null}
332
+ </section>
333
+ )
334
+ },
335
+ )
336
+
283
337
  function ChoicePickerPreview({ context }: { context: ComponentContext }) {
284
338
  const { interactive } = useA2uiPreviewMode()
285
339
  const { value, setValue, setValueSilent, fieldName } = useSkoponBoundField(context, 'value', {
@@ -517,11 +571,34 @@ interface FileUploadListItem {
517
571
  name: string
518
572
  status: 'uploading' | 'done' | 'error'
519
573
  url?: string
574
+ assetId?: string
575
+ source?: 'upload' | 'library_select'
520
576
  error?: string
521
577
  }
522
578
 
523
- function normalizeFileUrls(value: unknown): string[] {
524
- return asStringArray(value).map((url) => url.trim()).filter(Boolean)
579
+ interface FileValueRef {
580
+ assetId?: string
581
+ source?: 'upload' | 'library_select'
582
+ url?: string
583
+ }
584
+
585
+ function normalizeFileValues(value: unknown): FileValueRef[] {
586
+ const values = Array.isArray(value) ? value : value == null || value === '' ? [] : [value]
587
+ return values.reduce<FileValueRef[]>((result, entry) => {
588
+ if (typeof entry === 'string' && entry.trim()) {
589
+ result.push({ url: entry.trim() })
590
+ return result
591
+ }
592
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return result
593
+ const obj = entry as Record<string, unknown>
594
+ const assetId = String(obj.asset_id ?? obj.assetId ?? '').trim()
595
+ if (!assetId) return result
596
+ result.push({
597
+ assetId,
598
+ source: obj.source === 'library_select' ? 'library_select' as const : 'upload' as const,
599
+ })
600
+ return result
601
+ }, [])
525
602
  }
526
603
 
527
604
  function fileNameFromUrl(url: string): string {
@@ -541,15 +618,25 @@ function fileItemFromUrl(url: string, uid: string): FileUploadListItem {
541
618
  function FileUploadPreview({ context }: { context: ComponentContext }) {
542
619
  const { interactive } = useA2uiPreviewMode()
543
620
  const { beginUpload } = useFileUploadStatus()
621
+ const uploadMaterial = useMaterialUpload()
544
622
  const { value, setValue, setValueSilent, fieldName } = useSkoponBoundField(context, 'value', {
545
623
  component: 'FileUpload',
546
624
  changeAction: 'change',
547
625
  })
548
626
  const props = context.componentModel.properties as Record<string, unknown>
627
+ const sourceMode = props.fileSourceMode === 'asset_only' || props.fileSourceMode === 'upload_or_asset' ? props.fileSourceMode : 'upload_required'
628
+ const assetOptions = normalizeAssetOptions(props.fileAssetOptions)
629
+ const [assetPickerOpen, setAssetPickerOpen] = useState(false)
549
630
  const label = readString(props.label)
550
631
  const accept = readString(props.accept)
551
632
  const help = readString(props.help)
552
633
  const reviewCriteria = readString(props.fileReviewCriteria)
634
+ const reviewStatus = props.fileReviewStatus === 'passed' || props.fileReviewStatus === 'failed'
635
+ ? props.fileReviewStatus
636
+ : undefined
637
+ const reviewMessage = readString(props.fileReviewMessage)
638
+ const rejectedUrls = asStringArray(props.fileRejectedUrls)
639
+ .filter((url) => /^https?:\/\//i.test(url))
553
640
  const exampleUrls = asStringArray(props.fileExampleUrls)
554
641
  .filter((url) => /^https?:\/\//i.test(url))
555
642
  const fileAcceptTypes = Array.isArray(props.fileAcceptTypes)
@@ -566,34 +653,58 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
566
653
  )
567
654
  ? (props.filePlaceholderIcon as FormFilePlaceholderIcon)
568
655
  : 'document'
656
+ const compact = props.compact === true
569
657
  const minCount = typeof props.minCount === 'number' ? Math.max(0, props.minCount) : 0
570
658
  const maxCount = typeof props.maxCount === 'number' ? props.maxCount : 1
571
659
  const hasMaxLimit = maxCount > 0
572
- const initialUrls = normalizeFileUrls(value)
660
+ const sourceLabel = sourceMode === 'asset_only'
661
+ ? '素材 · 仅选择'
662
+ : sourceMode === 'upload_or_asset'
663
+ ? '物料或素材'
664
+ : '物料 · 仅上传'
665
+ const initialValues = normalizeFileValues(value)
573
666
  const externalUidRef = useRef(0)
574
667
  const mountedRef = useRef(true)
575
668
  const controllersRef = useRef(new Map<string, AbortController>())
576
669
  const setValueRef = useRef(setValue)
577
670
  setValueRef.current = setValue
578
- const [items, setItems] = useState<FileUploadListItem[]>(() =>
579
- initialUrls.map((url) => fileItemFromUrl(url, `external-${externalUidRef.current++}`)),
580
- )
671
+ const [items, setItems] = useState<FileUploadListItem[]>(() => [
672
+ ...initialValues.map((entry) => {
673
+ if (entry.url) return fileItemFromUrl(entry.url, `external-${externalUidRef.current++}`)
674
+ const option = assetOptions.find((item) => item.assetId === entry.assetId)
675
+ return {
676
+ uid: `asset-${entry.assetId}-${externalUidRef.current++}`,
677
+ name: option?.label || `素材 ${entry.assetId}`,
678
+ status: 'done' as const,
679
+ url: option?.previewUrl || option?.url,
680
+ assetId: entry.assetId,
681
+ source: entry.source,
682
+ }
683
+ }),
684
+ ...rejectedUrls.map((url) => ({
685
+ ...fileItemFromUrl(url, `rejected-${externalUidRef.current++}`),
686
+ status: 'error' as const,
687
+ error: reviewMessage || '审核不合格,请重新上传',
688
+ })),
689
+ ])
581
690
  const itemsRef = useRef(items)
582
691
  itemsRef.current = items
583
692
 
584
693
  const commitItems = useCallback(
585
694
  (
586
695
  updater: (current: FileUploadListItem[]) => FileUploadListItem[],
587
- writeUrls: boolean,
696
+ writeValues: boolean,
588
697
  ) => {
589
698
  const next = updater(itemsRef.current)
590
699
  itemsRef.current = next
591
700
  if (mountedRef.current) setItems(next)
592
- if (writeUrls) {
701
+ if (writeValues) {
593
702
  setValueRef.current(
594
703
  next
595
- .filter((item) => item.status === 'done' && item.url)
596
- .map((item) => item.url as string),
704
+ .filter((item) => item.status === 'done' && (item.assetId || item.url))
705
+ .map((item) => item.assetId
706
+ ? { asset_id: item.assetId, source: item.source || 'upload' }
707
+ : item.url as string),
597
708
  )
598
709
  }
599
710
  return next
@@ -611,27 +722,34 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
611
722
  }, [])
612
723
 
613
724
  useEffect(() => {
614
- const urls = normalizeFileUrls(value)
615
- const rawIsNormalizedArray =
616
- Array.isArray(value) &&
617
- value.length === urls.length &&
618
- value.every((entry, index) => typeof entry === 'string' && entry === urls[index])
619
- if (!rawIsNormalizedArray) setValueSilent(urls)
620
-
621
- const consumed = new Set<number>()
725
+ const normalized = normalizeFileValues(value)
726
+ if (!Array.isArray(value)) {
727
+ setValueSilent(normalized.map((entry) => entry.assetId
728
+ ? { asset_id: entry.assetId, source: entry.source || 'upload' }
729
+ : entry.url))
730
+ }
731
+ const expected = new Set(normalized.map((entry) => entry.assetId ? `asset:${entry.assetId}` : `url:${entry.url}`))
622
732
  commitItems(
623
733
  (current) => {
624
- const next = current.filter((item) => {
625
- if (item.status !== 'done' || !item.url) return true
626
- const index = urls.findIndex((url, i) => !consumed.has(i) && url === item.url)
627
- if (index < 0) return false
628
- consumed.add(index)
629
- return true
630
- })
631
- urls.forEach((url, index) => {
632
- if (!consumed.has(index)) {
633
- next.push(fileItemFromUrl(url, `external-${externalUidRef.current++}`))
734
+ const next = current.filter((item) =>
735
+ item.status !== 'done' || expected.has(item.assetId ? `asset:${item.assetId}` : `url:${item.url}`))
736
+ const present = new Set(next.map((item) => item.assetId ? `asset:${item.assetId}` : `url:${item.url}`))
737
+ normalized.forEach((entry) => {
738
+ const key = entry.assetId ? `asset:${entry.assetId}` : `url:${entry.url}`
739
+ if (present.has(key)) return
740
+ if (entry.url) {
741
+ next.push(fileItemFromUrl(entry.url, `external-${externalUidRef.current++}`))
742
+ return
634
743
  }
744
+ const option = assetOptions.find((item) => item.assetId === entry.assetId)
745
+ next.push({
746
+ uid: `asset-${entry.assetId}-${externalUidRef.current++}`,
747
+ name: option?.label || `素材 ${entry.assetId}`,
748
+ status: 'done',
749
+ url: option?.previewUrl || option?.url,
750
+ assetId: entry.assetId,
751
+ source: entry.source,
752
+ })
635
753
  })
636
754
  return next
637
755
  },
@@ -641,26 +759,44 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
641
759
 
642
760
  const startUpload = useCallback(
643
761
  (file: File & { uid?: string }) => {
644
- if (!interactive) return
645
- if (hasMaxLimit && itemsRef.current.length >= maxCount) return
762
+ if (!interactive || sourceMode === 'asset_only') return
763
+ const activeCount = itemsRef.current.filter((item) => item.status !== 'error').length
764
+ if (hasMaxLimit && activeCount >= maxCount) return
646
765
 
647
766
  const uid = `${file.uid ?? 'file'}-${externalUidRef.current++}`
648
767
  const controller = new AbortController()
649
768
  controllersRef.current.set(uid, controller)
650
769
  commitItems(
651
- (current) => [...current, { uid, name: file.name, status: 'uploading' }],
770
+ (current) => [...current.filter((item) => item.status !== 'error'), { uid, name: file.name, status: 'uploading' }],
652
771
  false,
653
772
  )
654
773
  const endUpload = beginUpload()
655
774
 
656
- void uploadFileToCdn(file, { signal: controller.signal })
775
+ const upload = uploadMaterial
776
+ ? uploadMaterial(file, {
777
+ fieldName,
778
+ moduleId: readString(props.moduleId) || undefined,
779
+ positionKey: fieldName || undefined,
780
+ assetSlotKey: readString(props.assetSlotKey) || undefined,
781
+ assetType: readString(props.assetType) || undefined,
782
+ }, { signal: controller.signal })
783
+ : Promise.reject(new Error('当前宿主未配置私有物料上传,请联系管理员'))
784
+ void upload
657
785
  .then((result) => {
658
786
  if (!mountedRef.current || controller.signal.aborted) return
659
787
  commitItems(
660
788
  (current) =>
661
789
  current.map((item) =>
662
790
  item.uid === uid
663
- ? { ...item, status: 'done', url: result.url, error: undefined }
791
+ ? {
792
+ ...item,
793
+ status: 'done',
794
+ name: result.name || item.name,
795
+ url: result.previewUrl,
796
+ assetId: result.assetId || undefined,
797
+ source: result.assetId ? 'upload' : undefined,
798
+ error: undefined,
799
+ }
664
800
  : item,
665
801
  ),
666
802
  true,
@@ -682,7 +818,7 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
682
818
  endUpload()
683
819
  })
684
820
  },
685
- [beginUpload, commitItems, hasMaxLimit, interactive, maxCount],
821
+ [beginUpload, commitItems, fieldName, hasMaxLimit, interactive, maxCount, props, sourceMode, uploadMaterial],
686
822
  )
687
823
 
688
824
  const handleRemove = useCallback(
@@ -695,7 +831,7 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
695
831
  )
696
832
 
697
833
  const uploadedCount = items.filter((item) => item.status === 'done').length
698
- const atMaxLimit = hasMaxLimit && items.length >= maxCount
834
+ const atMaxLimit = hasMaxLimit && items.filter((item) => item.status !== 'error').length >= maxCount
699
835
  const belowMinCount = uploadedCount < minCount
700
836
  const latestError = [...items].reverse().find((item) => item.error)?.error
701
837
  const fileList: UploadFile[] = items.map((item) => ({
@@ -705,12 +841,117 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
705
841
  url: item.url,
706
842
  response: item.error,
707
843
  }))
844
+ const assetPicker = <Modal title={`选择推荐素材 · ${label || '素材位置'}`} open={assetPickerOpen} footer={null} width={860} onCancel={() => setAssetPickerOpen(false)}>
845
+ {assetOptions.length ? <div className="skopon-asset-picker">
846
+ {assetOptions.map((asset) => {
847
+ const isImage = asset.assetType === 'image' || Boolean(asset.previewUrl && asset.previewUrl !== asset.url)
848
+ const chooseAsset = () => {
849
+ if (!interactive || reviewStatus === 'passed') return
850
+ if (hasMaxLimit && maxCount > 1 && atMaxLimit) return
851
+ if (maxCount === 1) {
852
+ for (const controller of controllersRef.current.values()) controller.abort()
853
+ controllersRef.current.clear()
854
+ }
855
+ commitItems((current) => {
856
+ const retained = maxCount === 1 ? [] : current.filter((item) => item.status !== 'error' && item.url !== asset.url)
857
+ return [...retained, {
858
+ uid: `asset-${asset.assetId}-${externalUidRef.current++}`,
859
+ name: asset.label,
860
+ status: 'done',
861
+ url: asset.previewUrl || asset.url,
862
+ assetId: asset.assetId,
863
+ source: 'library_select',
864
+ }]
865
+ }, true)
866
+ setAssetPickerOpen(false)
867
+ }
868
+ return <article key={asset.assetId} className="skopon-asset-picker__card">
869
+ <div className="skopon-asset-picker__media">
870
+ {isImage
871
+ ? <Image src={asset.previewUrl || asset.url} alt={asset.label} preview={{ mask: '放大预览' }} />
872
+ : <div className="skopon-asset-picker__placeholder"><FilePlaceholderIcon type={asset.assetType === 'video' ? 'video' : asset.assetType === 'audio' ? 'audio' : 'document'} size={36} /></div>}
873
+ </div>
874
+ <Typography.Text className="skopon-asset-picker__name" title={asset.label}>{asset.label}</Typography.Text>
875
+ <Button type="primary" size="small" block aria-label={`选择素材 ${asset.label}`} disabled={!interactive || reviewStatus === 'passed' || (hasMaxLimit && maxCount > 1 && atMaxLimit)} onClick={chooseAsset}>使用此素材</Button>
876
+ </article>
877
+ })}
878
+ </div> : <Typography.Paragraph type="secondary">此位置暂无可选素材,请先在素材库为该位置打标。</Typography.Paragraph>}
879
+ </Modal>
880
+
881
+ if (compact) {
882
+ const displayItem = items.find((item) => item.status === 'error') ?? items[0]
883
+ const displayUrl = displayItem?.url
884
+ const isImage = fileAcceptTypes?.includes('image/*') ?? placeholderIcon === 'image'
885
+ const state = reviewStatus === 'passed'
886
+ ? 'passed'
887
+ : reviewStatus === 'failed'
888
+ ? 'failed'
889
+ : displayItem?.status === 'uploading'
890
+ ? 'uploading'
891
+ : displayItem?.status === 'done'
892
+ ? 'pending'
893
+ : 'empty'
894
+ const stateText = state === 'passed'
895
+ ? '审核通过'
896
+ : state === 'failed'
897
+ ? (reviewMessage || '审核不合格,请替换')
898
+ : state === 'uploading'
899
+ ? '正在上传…'
900
+ : state === 'pending'
901
+ ? '等待审核'
902
+ : `${acceptSummary ?? '文件'}${maxCount > 1 ? `,最多 ${maxCount} 个` : ''}`
903
+ const canChoose = interactive && state !== 'passed' && state !== 'uploading'
904
+
905
+ return (
906
+ <><article className={`skopon-compact-upload skopon-compact-upload--${state}`} data-skopon-field={fieldName || undefined}>
907
+ <div className="skopon-compact-upload__thumb">
908
+ {displayUrl && isImage ? (
909
+ <img src={displayUrl} alt={label || '已上传素材'} />
910
+ ) : (
911
+ <FilePlaceholderIcon type={placeholderIcon} size={30} />
912
+ )}
913
+ {state !== 'empty' ? <span className="skopon-compact-upload__state-mark" aria-hidden>
914
+ {state === 'passed' ? '✓' : state === 'failed' ? '!' : state === 'uploading' ? '…' : '•'}
915
+ </span> : null}
916
+ </div>
917
+ <div className="skopon-compact-upload__meta">
918
+ <strong>{label || '上传素材'}</strong>
919
+ <span className={`skopon-compact-upload__source skopon-compact-upload__source--${sourceMode}`}>{sourceLabel}</span>
920
+ <span className="skopon-compact-upload__status">{stateText}</span>
921
+ <div className="skopon-file-source-actions">
922
+ {canChoose && sourceMode !== 'asset_only' ? (
923
+ <Upload
924
+ accept={buildFileAcceptAttribute(fileAcceptTypes, fileAcceptExtensions)}
925
+ multiple={!hasMaxLimit || maxCount > 1}
926
+ showUploadList={false}
927
+ beforeUpload={(file) => {
928
+ if (displayItem?.status === 'done') handleRemove(displayItem.uid)
929
+ startUpload(file)
930
+ return Upload.LIST_IGNORE
931
+ }}
932
+ >
933
+ <Button size="small">上传</Button>
934
+ </Upload>
935
+ ) : null}
936
+ {canChoose && sourceMode !== 'upload_required' ? <Button size="small" onClick={() => setAssetPickerOpen(true)}>选择素材</Button> : null}
937
+ </div>
938
+ {latestError && state !== 'failed' ? <span className="skopon-compact-upload__error">{latestError}</span> : null}
939
+ </div>
940
+ </article>{assetPicker}</>
941
+ )
942
+ }
708
943
 
709
944
  return (
710
945
  <div className="form-block-preview" data-skopon-field={fieldName || undefined}>
711
946
  {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}
947
+ {reviewStatus === 'passed' ? (
948
+ <Typography.Paragraph style={{ color: '#389e0d', marginBottom: 8 }}>✅ 审核通过,已保留,无需重新上传</Typography.Paragraph>
949
+ ) : null}
950
+ {reviewStatus === 'failed' ? (
951
+ <Typography.Paragraph type="danger" style={{ marginBottom: 8 }}>❌ 审核不合格:{reviewMessage || '请重新上传'}</Typography.Paragraph>
952
+ ) : null}
953
+ {help ? <Typography.Paragraph type="secondary" style={{ whiteSpace: 'pre-line' }}>{help}</Typography.Paragraph> : null}
954
+ {reviewCriteria ? <Typography.Paragraph type="secondary" style={{ whiteSpace: 'pre-line' }}>素材要求:{reviewCriteria}</Typography.Paragraph> : null}
714
955
  {exampleUrls.length ? (
715
956
  <div className="form-file-examples">
716
957
  <Typography.Text type="secondary">参考示例(不作为上传素材提交)</Typography.Text>
@@ -723,13 +964,15 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
723
964
  </div>
724
965
  </div>
725
966
  ) : null}
967
+ {sourceMode !== 'upload_required' ? <div className="skopon-file-source-actions"><Button disabled={!interactive || reviewStatus === 'passed'} onClick={() => setAssetPickerOpen(true)}>选择素材</Button></div> : null}
968
+ {sourceMode !== 'asset_only' ?
726
969
  <Upload.Dragger
727
- disabled={!interactive}
728
- openFileDialogOnClick={!atMaxLimit}
970
+ disabled={!interactive || reviewStatus === 'passed'}
971
+ openFileDialogOnClick={reviewStatus !== 'passed' && !atMaxLimit}
729
972
  accept={buildFileAcceptAttribute(fileAcceptTypes, fileAcceptExtensions)}
730
973
  className="form-file-upload-preview"
731
974
  multiple={!hasMaxLimit || maxCount > 1}
732
- showUploadList={interactive && items.length > 0}
975
+ showUploadList={interactive && items.length > 0 ? { showRemoveIcon: reviewStatus !== 'passed' } : false}
733
976
  fileList={interactive ? fileList : []}
734
977
  beforeUpload={(file) => {
735
978
  if (!interactive) return Upload.LIST_IGNORE
@@ -737,6 +980,7 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
737
980
  return Upload.LIST_IGNORE
738
981
  }}
739
982
  onRemove={(file) => {
983
+ if (reviewStatus === 'passed') return false
740
984
  handleRemove(file.uid)
741
985
  return true
742
986
  }}
@@ -746,11 +990,15 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
746
990
  </p>
747
991
  <p className="form-file-upload-preview-text">点击或拖拽文件到此处上传</p>
748
992
  </Upload.Dragger>
993
+ : <div className="skopon-selected-assets">{items.map((item) => <div key={item.uid} className="skopon-selected-assets__row">
994
+ {item.url ? <a href={item.url} target="_blank" rel="noreferrer">{item.name}</a> : <span>{item.name}</span>}
995
+ {interactive && reviewStatus !== 'passed' ? <Button size="small" type="text" onClick={() => handleRemove(item.uid)}>移除</Button> : null}
996
+ </div>)}</div>}
749
997
  <Typography.Text type="secondary" className="form-block-preview-help">
750
998
  允许:{acceptSummary ?? '全部类型'}
751
999
  {minCount > 0 ? `;至少 ${minCount} 个文件` : ''}
752
- {hasMaxLimit ? `;最多 ${maxCount} 个文件` : ''}
753
- ;选择后自动上传至 CDN
1000
+ {maxCount > 1 ? `;最多 ${maxCount} 个文件` : ''}
1001
+ {sourceMode === 'asset_only' ? ';从推荐素材中选择' : ';选择文件后自动上传至 CDN'}
754
1002
  {belowMinCount && interactive ? `(当前 ${uploadedCount} 个,未达下限)` : ''}
755
1003
  </Typography.Text>
756
1004
  {latestError ? (
@@ -758,6 +1006,7 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
758
1006
  {latestError}
759
1007
  </Typography.Text>
760
1008
  ) : null}
1009
+ {assetPicker}
761
1010
  </div>
762
1011
  )
763
1012
  }
@@ -925,6 +1174,7 @@ export function buildSkoponCatalog(): Catalog<ReactComponentImplementation> {
925
1174
  ...baseComponents,
926
1175
  SkoponMediaImpl,
927
1176
  SkoponSelectImpl,
1177
+ SkoponModulePanelImpl,
928
1178
  ImageImpl,
929
1179
  VideoImpl,
930
1180
  AudioPlayerImpl,