@skopon-cool/form-sdk 0.1.1 → 0.1.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 (93) hide show
  1. package/README.md +49 -11
  2. package/dist/adapter/a2uiAdapter.d.ts +0 -1
  3. package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
  4. package/dist/adapter/formFileAccept.d.ts.map +1 -1
  5. package/dist/adapter/formSchema.d.ts +1 -0
  6. package/dist/adapter/formSchema.d.ts.map +1 -1
  7. package/dist/blocks/case_singleselect/adapter.d.ts +10 -0
  8. package/dist/blocks/case_singleselect/adapter.d.ts.map +1 -0
  9. package/dist/blocks/case_singleselect/catalog.d.ts +2 -0
  10. package/dist/blocks/case_singleselect/catalog.d.ts.map +1 -0
  11. package/dist/blocks/case_singleselect/index.d.ts +3 -0
  12. package/dist/blocks/case_singleselect/index.d.ts.map +1 -0
  13. package/dist/blocks/registry.d.ts +8 -0
  14. package/dist/blocks/registry.d.ts.map +1 -0
  15. package/dist/blocks/resume_multiselect/adapter.d.ts +10 -0
  16. package/dist/blocks/resume_multiselect/adapter.d.ts.map +1 -0
  17. package/dist/blocks/resume_multiselect/catalog.d.ts +2 -0
  18. package/dist/blocks/resume_multiselect/catalog.d.ts.map +1 -0
  19. package/dist/blocks/resume_multiselect/index.d.ts +3 -0
  20. package/dist/blocks/resume_multiselect/index.d.ts.map +1 -0
  21. package/dist/blocks/types.d.ts +27 -0
  22. package/dist/blocks/types.d.ts.map +1 -0
  23. package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
  24. package/dist/catalog/caseSearchContext.d.ts +28 -0
  25. package/dist/catalog/caseSearchContext.d.ts.map +1 -0
  26. package/dist/catalog/resumeSearchContext.d.ts +30 -0
  27. package/dist/catalog/resumeSearchContext.d.ts.map +1 -0
  28. package/dist/catalog/skoponCaseSelect.d.ts +2 -0
  29. package/dist/catalog/skoponCaseSelect.d.ts.map +1 -0
  30. package/dist/catalog/skoponResumeSelect.d.ts +2 -0
  31. package/dist/catalog/skoponResumeSelect.d.ts.map +1 -0
  32. package/dist/catalog/useSkoponBoundField.d.ts +2 -0
  33. package/dist/catalog/useSkoponBoundField.d.ts.map +1 -1
  34. package/dist/client/formClient.d.ts.map +1 -1
  35. package/dist/components/AskUserFormCard.d.ts +10 -2
  36. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  37. package/dist/components/SkoponA2uiStreamRenderer.d.ts +7 -1
  38. package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -1
  39. package/dist/components/SkoponA2uiStreamRenderer.test.d.ts +2 -0
  40. package/dist/components/SkoponA2uiStreamRenderer.test.d.ts.map +1 -0
  41. package/dist/components/SkoponFormRenderer.d.ts +6 -0
  42. package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
  43. package/dist/form-sdk.css +1 -1
  44. package/dist/index.d.ts +7 -2
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +1390 -710
  47. package/dist/submit/buildCurlStatement.d.ts +8 -0
  48. package/dist/submit/buildCurlStatement.d.ts.map +1 -1
  49. package/dist/submit/intersectPayloadBlocksWithForm.d.ts +26 -0
  50. package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -0
  51. package/dist/submit/submitFormJson.d.ts.map +1 -1
  52. package/dist/types/index.d.ts +26 -2
  53. package/dist/types/index.d.ts.map +1 -1
  54. package/package.json +11 -6
  55. package/src/adapter/a2uiAdapter.test.ts +116 -0
  56. package/src/adapter/a2uiAdapter.ts +48 -4
  57. package/src/adapter/formFileAccept.test.ts +53 -0
  58. package/src/adapter/formFileAccept.ts +11 -2
  59. package/src/adapter/formSchema.test.ts +35 -0
  60. package/src/adapter/formSchema.ts +70 -3
  61. package/src/blocks/case_singleselect/adapter.ts +74 -0
  62. package/src/blocks/case_singleselect/catalog.ts +1 -0
  63. package/src/blocks/case_singleselect/index.ts +14 -0
  64. package/src/blocks/registry.ts +34 -0
  65. package/src/blocks/resume_multiselect/adapter.ts +57 -0
  66. package/src/blocks/resume_multiselect/catalog.ts +1 -0
  67. package/src/blocks/resume_multiselect/index.ts +14 -0
  68. package/src/blocks/types.ts +34 -0
  69. package/src/catalog/a2uiCustomCatalog.tsx +34 -5
  70. package/src/catalog/caseSearchContext.tsx +46 -0
  71. package/src/catalog/resumeSearchContext.tsx +48 -0
  72. package/src/catalog/skoponCaseSelect.tsx +215 -0
  73. package/src/catalog/skoponResumeSelect.tsx +227 -0
  74. package/src/catalog/textFieldPreview.test.tsx +1 -1
  75. package/src/catalog/useSkoponBoundField.test.ts +62 -0
  76. package/src/catalog/useSkoponBoundField.ts +10 -1
  77. package/src/client/formClient.test.ts +83 -0
  78. package/src/client/formClient.ts +10 -2
  79. package/src/components/AskUserFormCard.tsx +146 -58
  80. package/src/components/SkoponA2uiStreamRenderer.test.ts +78 -0
  81. package/src/components/SkoponA2uiStreamRenderer.test.tsx +103 -0
  82. package/src/components/SkoponA2uiStreamRenderer.tsx +141 -23
  83. package/src/components/SkoponFormRenderer.tsx +42 -17
  84. package/src/index.ts +34 -2
  85. package/src/styles/index.css +65 -0
  86. package/src/submit/buildCurlStatement.ts +49 -0
  87. package/src/submit/intersectPayloadBlocksWithForm.ts +175 -0
  88. package/src/submit/submit.test.ts +170 -10
  89. package/src/submit/submitFormJson.ts +20 -1
  90. package/src/types/index.ts +30 -0
  91. package/dist/submit/intersectPayloadWithForm.d.ts +0 -17
  92. package/dist/submit/intersectPayloadWithForm.d.ts.map +0 -1
  93. package/src/submit/intersectPayloadWithForm.ts +0 -54
@@ -0,0 +1,74 @@
1
+ import type { A2uiComponentNode, FormBlock } from '../../types/index'
2
+ import type { BlockAdapterContext, BlockToComponentResult } from '../types'
3
+
4
+ function asLiteral(value: unknown): string | undefined {
5
+ return typeof value === 'string' ? value : undefined
6
+ }
7
+
8
+ export function caseSingleselectToComponent(
9
+ block: FormBlock,
10
+ ctx: BlockAdapterContext,
11
+ ): BlockToComponentResult {
12
+ const { id, label, path, withData } = ctx
13
+ return withData({
14
+ id,
15
+ component: 'SkoponCaseSelect',
16
+ label,
17
+ placeholder: block.placeholder ?? '',
18
+ help: block.help ?? '',
19
+ enableRefresh: block.caseEnableRefresh !== false,
20
+ caseFilter: {
21
+ agentKind: block.caseFilter?.agentKind,
22
+ agentUniqueId: block.caseFilter?.agentUniqueId,
23
+ flowId: block.caseFilter?.flowId,
24
+ pageSize: block.caseFilter?.pageSize ?? 20,
25
+ },
26
+ value: { path },
27
+ })
28
+ }
29
+
30
+ export function caseSingleselectFromComponent(
31
+ node: A2uiComponentNode,
32
+ base: (type: 'case_singleselect') => FormBlock,
33
+ helpers: {
34
+ asLiteral: (value: unknown) => string | undefined
35
+ nameFromValue: (value: unknown) => string
36
+ },
37
+ ): FormBlock {
38
+ const name = helpers.nameFromValue(node.value)
39
+ const block: FormBlock = {
40
+ ...base('case_singleselect'),
41
+ name,
42
+ caseEnableRefresh: node.enableRefresh !== false,
43
+ }
44
+ const placeholder = helpers.asLiteral(node.placeholder)
45
+ if (placeholder) block.placeholder = placeholder
46
+ const help = helpers.asLiteral(node.help)
47
+ if (help) block.help = help
48
+ if (node.caseFilter && typeof node.caseFilter === 'object') {
49
+ const cf = node.caseFilter as Record<string, unknown>
50
+ const flowIdRaw = typeof cf.flowId === 'number' ? cf.flowId : Number(cf.flowId)
51
+ const pageSizeRaw = typeof cf.pageSize === 'number' ? cf.pageSize : Number(cf.pageSize)
52
+ const agentKind =
53
+ typeof cf.agentKind === 'string' && cf.agentKind.trim()
54
+ ? cf.agentKind.trim()
55
+ : undefined
56
+ const agentUniqueId =
57
+ typeof cf.agentUniqueId === 'string' && cf.agentUniqueId.trim()
58
+ ? cf.agentUniqueId.trim()
59
+ : undefined
60
+ block.caseFilter = {
61
+ agentKind,
62
+ agentUniqueId,
63
+ flowId:
64
+ Number.isFinite(flowIdRaw) && flowIdRaw > 0 ? Math.floor(flowIdRaw) : undefined,
65
+ pageSize:
66
+ Number.isFinite(pageSizeRaw) && pageSizeRaw >= 1 && pageSizeRaw <= 100
67
+ ? Math.floor(pageSizeRaw)
68
+ : 20,
69
+ }
70
+ }
71
+ return block
72
+ }
73
+
74
+ export { asLiteral }
@@ -0,0 +1 @@
1
+ export { SkoponCaseSelectImpl } from '../../catalog/skoponCaseSelect'
@@ -0,0 +1,14 @@
1
+ import type { BlockAdapterPlugin } from '../types'
2
+ import {
3
+ caseSingleselectFromComponent,
4
+ caseSingleselectToComponent,
5
+ } from './adapter'
6
+ import { SkoponCaseSelectImpl } from './catalog'
7
+
8
+ export const caseSingleselectAdapter: BlockAdapterPlugin = {
9
+ type: 'case_singleselect',
10
+ componentName: 'SkoponCaseSelect',
11
+ toComponent: caseSingleselectToComponent,
12
+ fromComponent: caseSingleselectFromComponent,
13
+ catalogComponents: [SkoponCaseSelectImpl],
14
+ }
@@ -0,0 +1,34 @@
1
+ import type { FormBlockType } from '../types/index'
2
+ import type { BlockAdapterPlugin } from './types'
3
+ import { caseSingleselectAdapter } from './case_singleselect'
4
+ import { resumeMultiselectAdapter } from './resume_multiselect'
5
+
6
+ const adapters: BlockAdapterPlugin[] = [resumeMultiselectAdapter, caseSingleselectAdapter]
7
+
8
+ const adapterByType = new Map<FormBlockType, BlockAdapterPlugin>(
9
+ adapters.map((adapter) => [adapter.type, adapter]),
10
+ )
11
+
12
+ const adapterByComponent = new Map<string, BlockAdapterPlugin>(
13
+ adapters.map((adapter) => [adapter.componentName, adapter]),
14
+ )
15
+
16
+ export function getBlockTypeAdapter(type: FormBlockType): BlockAdapterPlugin | undefined {
17
+ return adapterByType.get(type)
18
+ }
19
+
20
+ export function getComponentAdapter(componentName: string): BlockAdapterPlugin | undefined {
21
+ return adapterByComponent.get(componentName)
22
+ }
23
+
24
+ export function getAllBlockAdapters(): BlockAdapterPlugin[] {
25
+ return adapters
26
+ }
27
+
28
+ export function getRegisteredCatalogComponents() {
29
+ return adapters.flatMap((adapter) => adapter.catalogComponents)
30
+ }
31
+
32
+ export function getRegisteredCatalogComponentNames(): string[] {
33
+ return adapters.map((adapter) => adapter.componentName)
34
+ }
@@ -0,0 +1,57 @@
1
+ import type { A2uiComponentNode, FormBlock } from '../../types/index'
2
+ import type { BlockAdapterContext, BlockToComponentResult } from '../types'
3
+
4
+ function asLiteral(value: unknown): string | undefined {
5
+ return typeof value === 'string' ? value : undefined
6
+ }
7
+
8
+ export function resumeMultiselectToComponent(
9
+ block: FormBlock,
10
+ ctx: BlockAdapterContext,
11
+ ): BlockToComponentResult {
12
+ const { id, label, path, withData } = ctx
13
+ return withData({
14
+ id,
15
+ component: 'SkoponResumeSelect',
16
+ label,
17
+ placeholder: block.placeholder ?? '',
18
+ help: block.help ?? '',
19
+ enableRefresh: block.resumeEnableRefresh !== false,
20
+ resumeFilter: {
21
+ names: block.resumeFilter?.names ?? [],
22
+ agentUniqueIds: block.resumeFilter?.agentUniqueIds ?? [],
23
+ resumeUniqueIds: block.resumeFilter?.resumeUniqueIds ?? [],
24
+ pageSize: block.resumeFilter?.pageSize ?? 20,
25
+ },
26
+ value: { path },
27
+ })
28
+ }
29
+
30
+ export function resumeMultiselectFromComponent(
31
+ node: A2uiComponentNode,
32
+ base: (type: 'resume_multiselect') => FormBlock,
33
+ helpers: { asLiteral: (value: unknown) => string | undefined; nameFromValue: (value: unknown) => string },
34
+ ): FormBlock {
35
+ const name = helpers.nameFromValue(node.value)
36
+ const block: FormBlock = {
37
+ ...base('resume_multiselect'),
38
+ name,
39
+ resumeEnableRefresh: node.enableRefresh !== false,
40
+ }
41
+ const placeholder = helpers.asLiteral(node.placeholder)
42
+ if (placeholder) block.placeholder = placeholder
43
+ const help = helpers.asLiteral(node.help)
44
+ if (help) block.help = help
45
+ if (node.resumeFilter && typeof node.resumeFilter === 'object') {
46
+ const rf = node.resumeFilter as Record<string, unknown>
47
+ block.resumeFilter = {
48
+ names: Array.isArray(rf.names) ? rf.names.map(String) : [],
49
+ agentUniqueIds: Array.isArray(rf.agentUniqueIds) ? rf.agentUniqueIds.map(String) : [],
50
+ resumeUniqueIds: Array.isArray(rf.resumeUniqueIds) ? rf.resumeUniqueIds.map(String) : [],
51
+ pageSize: typeof rf.pageSize === 'number' ? rf.pageSize : 20,
52
+ }
53
+ }
54
+ return block
55
+ }
56
+
57
+ export { asLiteral }
@@ -0,0 +1 @@
1
+ export { SkoponResumeSelectImpl } from '../../catalog/skoponResumeSelect'
@@ -0,0 +1,14 @@
1
+ import type { BlockAdapterPlugin } from '../types'
2
+ import {
3
+ resumeMultiselectFromComponent,
4
+ resumeMultiselectToComponent,
5
+ } from './adapter'
6
+ import { SkoponResumeSelectImpl } from './catalog'
7
+
8
+ export const resumeMultiselectAdapter: BlockAdapterPlugin = {
9
+ type: 'resume_multiselect',
10
+ componentName: 'SkoponResumeSelect',
11
+ toComponent: resumeMultiselectToComponent,
12
+ fromComponent: resumeMultiselectFromComponent,
13
+ catalogComponents: [SkoponResumeSelectImpl],
14
+ }
@@ -0,0 +1,34 @@
1
+ import type {
2
+ A2uiComponentNode,
3
+ FormBlock,
4
+ FormBlockType,
5
+ } from '../types/index'
6
+ import type { ReactComponentImplementation } from '@a2ui/react/v0_9'
7
+
8
+ export interface BlockAdapterContext {
9
+ id: string
10
+ label: string
11
+ name: string
12
+ path: string | undefined
13
+ withData: (node: A2uiComponentNode) => {
14
+ node: A2uiComponentNode
15
+ dataKey?: string
16
+ dataValue?: unknown
17
+ }
18
+ }
19
+
20
+ export type BlockToComponentResult =
21
+ | { node: A2uiComponentNode | null }
22
+ | ReturnType<BlockAdapterContext['withData']>
23
+
24
+ export interface BlockAdapterPlugin {
25
+ type: FormBlockType
26
+ componentName: string
27
+ toComponent: (block: FormBlock, ctx: BlockAdapterContext) => BlockToComponentResult
28
+ fromComponent: (
29
+ node: A2uiComponentNode,
30
+ base: (type: FormBlockType) => FormBlock,
31
+ helpers: { asLiteral: (value: unknown) => string | undefined; nameFromValue: (value: unknown) => string },
32
+ ) => FormBlock | null
33
+ catalogComponents: ReactComponentImplementation[]
34
+ }
@@ -20,16 +20,22 @@ import {
20
20
  } from 'antd'
21
21
  import dayjs, { type Dayjs } from 'dayjs'
22
22
  import type { FormFilePlaceholderIcon, FormMediaSize } from '../types/index'
23
+ import { coerceToggleValue } from '../adapter/formSchema'
23
24
  import {
24
25
  buildFileAcceptAttribute,
25
26
  formatFileAcceptSummary,
26
27
  } from '../adapter/formFileAccept'
27
28
  import { buildMediaListClassName, normalizeMediaSize } from '../adapter/formMedia'
29
+ import {
30
+ getRegisteredCatalogComponentNames,
31
+ getRegisteredCatalogComponents,
32
+ } from '../blocks/registry'
28
33
  import FilePlaceholderIcon from '../icons/FilePlaceholderIcon'
29
34
  import { useA2uiPreviewMode } from './a2uiPreviewContext'
30
35
  import {
31
36
  asOptionalString,
32
37
  asStringArray,
38
+ readBoundFieldValue,
33
39
  useSkoponBoundField,
34
40
  } from './useSkoponBoundField'
35
41
 
@@ -51,6 +57,7 @@ const SKOPON_COMPONENT_NAMES = new Set([
51
57
  'FileUpload',
52
58
  'TextField',
53
59
  'Text',
60
+ ...getRegisteredCatalogComponentNames(),
54
61
  ])
55
62
 
56
63
  const NON_MARKDOWN_TEXT_VARIANTS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'caption'])
@@ -359,7 +366,7 @@ function ToggleSwitchPreview({ context }: { context: ComponentContext }) {
359
366
  <Switch
360
367
  disabled={!interactive}
361
368
  className="form-block-preview-control"
362
- checked={value === true}
369
+ checked={coerceToggleValue(value)}
363
370
  onChange={(checked) => setValue(checked)}
364
371
  />
365
372
  </div>
@@ -495,14 +502,18 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
495
502
  )
496
503
  ? (props.filePlaceholderIcon as FormFilePlaceholderIcon)
497
504
  : 'document'
505
+ const minCount = typeof props.minCount === 'number' ? Math.max(0, props.minCount) : 0
498
506
  const maxCount = typeof props.maxCount === 'number' ? props.maxCount : 1
507
+ const hasMaxLimit = maxCount > 0
499
508
  const selectedNames = asStringArray(value)
509
+ const atMaxLimit = hasMaxLimit && selectedNames.length >= maxCount
510
+ const belowMinCount = selectedNames.length < minCount
500
511
 
501
512
  return (
502
513
  <div className="form-block-preview">
503
514
  {label ? <div className="form-block-preview-label">{label}</div> : null}
504
515
  <Upload.Dragger
505
- disabled={!interactive}
516
+ disabled={!interactive || atMaxLimit}
506
517
  accept={buildFileAcceptAttribute(fileAcceptTypes, fileAcceptExtensions)}
507
518
  className="form-file-upload-preview"
508
519
  showUploadList={interactive && selectedNames.length > 0}
@@ -517,12 +528,24 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
517
528
  }
518
529
  beforeUpload={(file) => {
519
530
  if (!interactive) return Upload.LIST_IGNORE
520
- const next = [...selectedNames, file.name].slice(0, Math.max(1, maxCount))
531
+ const currentNames = asStringArray(readBoundFieldValue(context))
532
+ if (hasMaxLimit && currentNames.length >= maxCount) return Upload.LIST_IGNORE
533
+ const next = hasMaxLimit
534
+ ? [...currentNames, file.name].slice(0, maxCount)
535
+ : [...currentNames, file.name]
521
536
  setValue(next)
522
537
  return false
523
538
  }}
524
539
  onRemove={(file) => {
525
- setValue(selectedNames.filter((name) => name !== file.name))
540
+ const currentNames = asStringArray(readBoundFieldValue(context))
541
+ const indexMatch = /^(\d+)-/.exec(file.uid ?? '')
542
+ const index = indexMatch ? Number(indexMatch[1]) : -1
543
+ const next =
544
+ index >= 0 && index < currentNames.length
545
+ ? currentNames.filter((_, i) => i !== index)
546
+ : currentNames.filter((name) => name !== file.name)
547
+ if (next.length < minCount) return false
548
+ setValue(next)
526
549
  }}
527
550
  >
528
551
  <p className="form-file-upload-preview-icon">
@@ -532,6 +555,9 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
532
555
  </Upload.Dragger>
533
556
  <Typography.Text type="secondary" className="form-block-preview-help">
534
557
  允许:{acceptSummary ?? '全部类型'}
558
+ {minCount > 0 ? `;至少 ${minCount} 个文件` : ''}
559
+ {hasMaxLimit ? `;最多 ${maxCount} 个文件` : ''}
560
+ {belowMinCount && interactive ? `(当前 ${selectedNames.length} 个,未达下限)` : ''}
535
561
  </Typography.Text>
536
562
  </div>
537
563
  )
@@ -567,13 +593,15 @@ function TextFieldPreview({ context }: { context: ComponentContext }) {
567
593
  />
568
594
  )
569
595
  } else if (variant === 'number') {
596
+ const numericValue =
597
+ stringValue && Number.isFinite(Number(stringValue)) ? Number(stringValue) : undefined
570
598
  control = (
571
599
  <InputNumber
572
600
  disabled={disabled}
573
601
  className={controlClassName}
574
602
  style={{ width: '100%' }}
575
603
  placeholder={placeholder || '数字'}
576
- value={stringValue ? Number(stringValue) : undefined}
604
+ value={numericValue}
577
605
  onChange={(next) => setValue(next == null ? '' : String(next))}
578
606
  />
579
607
  )
@@ -668,6 +696,7 @@ export function buildSkoponCatalog(): Catalog<ReactComponentImplementation> {
668
696
  FileUploadImpl,
669
697
  TextFieldImpl,
670
698
  TextImpl,
699
+ ...getRegisteredCatalogComponents(),
671
700
  ]
672
701
  const functions = [...basicCatalog.functions.values()]
673
702
  return new Catalog(SKOPON_CATALOG_ID, components, functions, basicCatalog.themeSchema)
@@ -0,0 +1,46 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+ import type { FormCaseFilter } from '../types/index'
3
+
4
+ export interface CaseSearchItem {
5
+ caseUniqueId: string
6
+ caseId?: number
7
+ flowId?: number
8
+ name: string
9
+ link?: string
10
+ platform?: string | null
11
+ description?: string
12
+ }
13
+
14
+ export interface CaseSearchParams {
15
+ filter: FormCaseFilter
16
+ page: number
17
+ }
18
+
19
+ export interface CaseSearchResult {
20
+ list: CaseSearchItem[]
21
+ total: number
22
+ page: number
23
+ pageSize: number
24
+ }
25
+
26
+ export type CaseSearchFn = (params: CaseSearchParams) => Promise<CaseSearchResult>
27
+
28
+ const CaseSearchContext = createContext<CaseSearchFn | null>(null)
29
+
30
+ export function CaseSearchProvider({
31
+ caseSearch,
32
+ children,
33
+ }: {
34
+ caseSearch: CaseSearchFn | null | undefined
35
+ children: ReactNode
36
+ }) {
37
+ return (
38
+ <CaseSearchContext.Provider value={caseSearch ?? null}>
39
+ {children}
40
+ </CaseSearchContext.Provider>
41
+ )
42
+ }
43
+
44
+ export function useCaseSearch(): CaseSearchFn | null {
45
+ return useContext(CaseSearchContext)
46
+ }
@@ -0,0 +1,48 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+ import type { FormResumeFilter } from '../types/index'
3
+
4
+ export interface ResumeSearchItem {
5
+ resumeUniqueId: string
6
+ resumeId?: number
7
+ agentId?: number
8
+ name: string
9
+ avatarUrl?: string | null
10
+ description?: string
11
+ satisfaction?: number
12
+ published?: number
13
+ worksCount?: number
14
+ }
15
+
16
+ export interface ResumeSearchParams {
17
+ filter: FormResumeFilter
18
+ page: number
19
+ }
20
+
21
+ export interface ResumeSearchResult {
22
+ list: ResumeSearchItem[]
23
+ total: number
24
+ page: number
25
+ pageSize: number
26
+ }
27
+
28
+ export type ResumeSearchFn = (params: ResumeSearchParams) => Promise<ResumeSearchResult>
29
+
30
+ const ResumeSearchContext = createContext<ResumeSearchFn | null>(null)
31
+
32
+ export function ResumeSearchProvider({
33
+ resumeSearch,
34
+ children,
35
+ }: {
36
+ resumeSearch: ResumeSearchFn | null | undefined
37
+ children: ReactNode
38
+ }) {
39
+ return (
40
+ <ResumeSearchContext.Provider value={resumeSearch ?? null}>
41
+ {children}
42
+ </ResumeSearchContext.Provider>
43
+ )
44
+ }
45
+
46
+ export function useResumeSearch(): ResumeSearchFn | null {
47
+ return useContext(ResumeSearchContext)
48
+ }
@@ -0,0 +1,215 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import { Button, Spin, Typography } from 'antd'
3
+ import { FileTextOutlined, ReloadOutlined } from '@ant-design/icons'
4
+ import type { ComponentContext } from '@a2ui/web_core/v0_9'
5
+ import { createBinderlessComponentImplementation } from '@a2ui/react/v0_9'
6
+ import { z } from 'zod'
7
+ import type { FormCaseFilter } from '../types/index'
8
+ import { useA2uiPreviewMode } from './a2uiPreviewContext'
9
+ import { asOptionalString, useSkoponBoundField } from './useSkoponBoundField'
10
+ import { type CaseSearchItem, useCaseSearch } from './caseSearchContext'
11
+
12
+ function readString(value: unknown): string {
13
+ return typeof value === 'string' ? value : ''
14
+ }
15
+
16
+ function readCaseFilter(raw: unknown): FormCaseFilter {
17
+ if (!raw || typeof raw !== 'object') {
18
+ return { pageSize: 20 }
19
+ }
20
+ const rec = raw as Record<string, unknown>
21
+ const flowIdRaw = typeof rec.flowId === 'number' ? rec.flowId : Number(rec.flowId)
22
+ const flowId =
23
+ Number.isFinite(flowIdRaw) && flowIdRaw > 0 ? Math.floor(flowIdRaw) : undefined
24
+ const pageSizeRaw = typeof rec.pageSize === 'number' ? rec.pageSize : Number(rec.pageSize)
25
+ const pageSize =
26
+ Number.isFinite(pageSizeRaw) && pageSizeRaw >= 1 && pageSizeRaw <= 100
27
+ ? Math.floor(pageSizeRaw)
28
+ : 20
29
+ return { flowId, pageSize }
30
+ }
31
+
32
+ function filterKey(filter: FormCaseFilter): string {
33
+ return JSON.stringify(filter)
34
+ }
35
+
36
+ const SkoponCaseSelectApi = {
37
+ name: 'SkoponCaseSelect',
38
+ schema: z
39
+ .object({
40
+ label: z.any().optional(),
41
+ placeholder: z.any().optional(),
42
+ help: z.any().optional(),
43
+ enableRefresh: z.any().optional(),
44
+ caseFilter: z.any().optional(),
45
+ value: z.any().optional(),
46
+ })
47
+ .passthrough(),
48
+ }
49
+
50
+ function CaseCard({
51
+ item,
52
+ selected,
53
+ disabled,
54
+ onToggle,
55
+ }: {
56
+ item: CaseSearchItem
57
+ selected: boolean
58
+ disabled: boolean
59
+ onToggle: () => void
60
+ }) {
61
+ return (
62
+ <button
63
+ type="button"
64
+ className={`skopon-resume-select-card${selected ? ' skopon-resume-select-card--selected' : ''}`}
65
+ disabled={disabled}
66
+ onClick={onToggle}
67
+ >
68
+ <div className="skopon-resume-select-card-avatar">
69
+ <FileTextOutlined style={{ fontSize: 24, color: 'var(--color-primary, #1677ff)' }} />
70
+ </div>
71
+ <div className="skopon-resume-select-card-body">
72
+ <div className="skopon-resume-select-card-name">{item.name}</div>
73
+ {item.description ? (
74
+ <div className="skopon-resume-select-card-desc">{item.description}</div>
75
+ ) : null}
76
+ <div className="skopon-resume-select-card-meta">
77
+ {item.caseUniqueId}
78
+ {item.platform ? ` · ${item.platform}` : ''}
79
+ {item.link ? ` · ${item.link}` : ''}
80
+ </div>
81
+ </div>
82
+ </button>
83
+ )
84
+ }
85
+
86
+ function SkoponCaseSelectPreview({ context }: { context: ComponentContext }) {
87
+ const { interactive } = useA2uiPreviewMode()
88
+ const caseSearch = useCaseSearch()
89
+ const { value, setValue } = useSkoponBoundField(context)
90
+ const props = context.componentModel.properties as Record<string, unknown>
91
+
92
+ const label = readString(props.label)
93
+ const placeholder = readString(props.placeholder)
94
+ const help = readString(props.help)
95
+ const enableRefresh = props.enableRefresh !== false
96
+ const caseFilter = useMemo(() => readCaseFilter(props.caseFilter), [props.caseFilter])
97
+ const filterSig = filterKey(caseFilter)
98
+
99
+ const [page, setPage] = useState(1)
100
+ const [loading, setLoading] = useState(false)
101
+ const [fetchError, setFetchError] = useState('')
102
+ const [items, setItems] = useState<CaseSearchItem[]>([])
103
+ const [total, setTotal] = useState(0)
104
+ const [pageSize, setPageSize] = useState(caseFilter.pageSize ?? 20)
105
+ const requestSeqRef = useRef(0)
106
+
107
+ const selectedId = asOptionalString(value) ?? ''
108
+
109
+ const emptyHint = useMemo(() => {
110
+ if (!caseSearch) return '未配置案例搜索服务'
111
+ if (!caseFilter.flowId) return '未配置 Flow 筛选条件'
112
+ if (fetchError) return fetchError
113
+ return placeholder || '暂无匹配的案例'
114
+ }, [caseSearch, caseFilter.flowId, fetchError, placeholder])
115
+
116
+ const fetchPage = useCallback(
117
+ async (targetPage: number) => {
118
+ const filter = readCaseFilter(JSON.parse(filterSig) as FormCaseFilter)
119
+ const seq = ++requestSeqRef.current
120
+ if (!caseSearch || !filter.flowId) {
121
+ setItems([])
122
+ setTotal(0)
123
+ setFetchError('')
124
+ return
125
+ }
126
+ setLoading(true)
127
+ setFetchError('')
128
+ try {
129
+ const result = await caseSearch({ filter, page: targetPage })
130
+ if (seq !== requestSeqRef.current) return
131
+ setItems(result.list)
132
+ setTotal(result.total)
133
+ setPage(result.page)
134
+ setPageSize(result.pageSize)
135
+ } catch (err) {
136
+ if (seq !== requestSeqRef.current) return
137
+ setItems([])
138
+ setTotal(0)
139
+ setFetchError(err instanceof Error ? err.message : '加载案例列表失败')
140
+ } finally {
141
+ if (seq === requestSeqRef.current) setLoading(false)
142
+ }
143
+ },
144
+ [caseSearch, filterSig],
145
+ )
146
+
147
+ useEffect(() => {
148
+ setPage(1)
149
+ void fetchPage(1)
150
+ }, [filterSig, fetchPage])
151
+
152
+ const toggleSelect = (caseUniqueId: string) => {
153
+ if (!interactive) return
154
+ setValue(selectedId === caseUniqueId ? '' : caseUniqueId)
155
+ }
156
+
157
+ const handleRefresh = () => {
158
+ const filter = readCaseFilter(JSON.parse(filterSig) as FormCaseFilter)
159
+ const effectivePageSize = pageSize || filter.pageSize || 20
160
+ const totalPages = Math.max(1, Math.ceil(total / effectivePageSize))
161
+ const nextPage = page >= totalPages ? 1 : page + 1
162
+ void fetchPage(nextPage)
163
+ }
164
+
165
+ return (
166
+ <div className="form-block-preview skopon-resume-select">
167
+ {label ? <div className="form-block-preview-label">{label}</div> : null}
168
+ {loading && items.length === 0 ? (
169
+ <div className="skopon-resume-select-status">
170
+ <Spin size="small" /> 加载案例…
171
+ </div>
172
+ ) : null}
173
+ {!loading && items.length === 0 ? (
174
+ <div className="skopon-resume-select-empty">{emptyHint}</div>
175
+ ) : null}
176
+ {items.length > 0 ? (
177
+ <div className="skopon-resume-select-list">
178
+ {items.map((item) => (
179
+ <CaseCard
180
+ key={item.caseUniqueId}
181
+ item={item}
182
+ selected={selectedId === item.caseUniqueId}
183
+ disabled={!interactive}
184
+ onToggle={() => toggleSelect(item.caseUniqueId)}
185
+ />
186
+ ))}
187
+ </div>
188
+ ) : null}
189
+ {enableRefresh && caseSearch && caseFilter.flowId ? (
190
+ <div className="skopon-resume-select-actions">
191
+ <Button
192
+ type="default"
193
+ size="small"
194
+ icon={<ReloadOutlined />}
195
+ loading={loading}
196
+ disabled={!interactive}
197
+ onClick={handleRefresh}
198
+ >
199
+ 换一批
200
+ </Button>
201
+ </div>
202
+ ) : null}
203
+ {help ? (
204
+ <Typography.Text type="secondary" className="form-block-preview-help">
205
+ {help}
206
+ </Typography.Text>
207
+ ) : null}
208
+ </div>
209
+ )
210
+ }
211
+
212
+ export const SkoponCaseSelectImpl = createBinderlessComponentImplementation(
213
+ SkoponCaseSelectApi,
214
+ SkoponCaseSelectPreview,
215
+ )