@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.
- package/dist/adapter/a2uiAdapter.d.ts +1 -1
- package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
- package/dist/adapter/formAssetOptions.d.ts +3 -0
- package/dist/adapter/formAssetOptions.d.ts.map +1 -0
- package/dist/adapter/formSchema.d.ts.map +1 -1
- package/dist/adapter/resolveSurface.d.ts +1 -1
- package/dist/adapter/resolveSurface.d.ts.map +1 -1
- package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
- package/dist/components/AskUserFormCard.d.ts +2 -0
- package/dist/components/AskUserFormCard.d.ts.map +1 -1
- package/dist/form-sdk.css +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2018 -1797
- package/dist/submit/buildAnswerAgentBody.d.ts.map +1 -1
- package/dist/submit/intersectPayloadBlocksWithForm.d.ts +11 -2
- package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -1
- package/dist/types/index.d.ts +39 -1
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapter/a2uiAdapter.test.ts +16 -0
- package/src/adapter/a2uiAdapter.ts +66 -2
- package/src/adapter/formAssetOptions.ts +19 -0
- package/src/adapter/formSchema.ts +60 -0
- package/src/adapter/resolveSurface.ts +4 -1
- package/src/catalog/a2uiCustomCatalog.tsx +216 -13
- package/src/catalog/fileUploadPreview.test.tsx +73 -0
- package/src/catalog/moduleTabs.test.tsx +59 -0
- package/src/components/AskUserFormCard.tsx +28 -9
- package/src/components/AskUserFormCard.upload.test.tsx +66 -0
- package/src/icons/FilePlaceholderIcon.tsx +2 -2
- package/src/index.ts +2 -0
- package/src/styles/index.css +295 -0
- package/src/submit/buildAnswerAgentBody.ts +2 -0
- package/src/submit/intersectPayloadBlocksWithForm.ts +52 -3
- package/src/submit/submit.test.ts +54 -0
- package/src/types/index.ts +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
|
-
|
|
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
|
-
{
|
|
734
|
-
|
|
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
|
-
|
|
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,
|