@skopon-cool/form-sdk 0.1.3 → 0.1.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 (73) hide show
  1. package/README.md +2 -0
  2. package/dist/adapter/a2uiAdapter.d.ts +0 -1
  3. package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
  4. package/dist/adapter/formSchema.d.ts.map +1 -1
  5. package/dist/blocks/case_multiselect/adapter.d.ts +10 -0
  6. package/dist/blocks/case_multiselect/adapter.d.ts.map +1 -0
  7. package/dist/blocks/case_multiselect/index.d.ts +3 -0
  8. package/dist/blocks/case_multiselect/index.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/registry.d.ts +8 -0
  12. package/dist/blocks/registry.d.ts.map +1 -0
  13. package/dist/blocks/resume_multiselect/adapter.d.ts +10 -0
  14. package/dist/blocks/resume_multiselect/adapter.d.ts.map +1 -0
  15. package/dist/blocks/resume_multiselect/catalog.d.ts +2 -0
  16. package/dist/blocks/resume_multiselect/catalog.d.ts.map +1 -0
  17. package/dist/blocks/resume_multiselect/index.d.ts +3 -0
  18. package/dist/blocks/resume_multiselect/index.d.ts.map +1 -0
  19. package/dist/blocks/types.d.ts +27 -0
  20. package/dist/blocks/types.d.ts.map +1 -0
  21. package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
  22. package/dist/catalog/caseSearchContext.d.ts +28 -0
  23. package/dist/catalog/caseSearchContext.d.ts.map +1 -0
  24. package/dist/catalog/resumeSearchContext.d.ts +39 -0
  25. package/dist/catalog/resumeSearchContext.d.ts.map +1 -0
  26. package/dist/catalog/skoponCaseSelect.d.ts +2 -0
  27. package/dist/catalog/skoponCaseSelect.d.ts.map +1 -0
  28. package/dist/catalog/skoponResumeSelect.d.ts +2 -0
  29. package/dist/catalog/skoponResumeSelect.d.ts.map +1 -0
  30. package/dist/components/AskUserFormCard.d.ts +7 -1
  31. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  32. package/dist/components/SkoponA2uiStreamRenderer.d.ts +7 -1
  33. package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -1
  34. package/dist/components/SkoponFormRenderer.d.ts +6 -0
  35. package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
  36. package/dist/form-sdk.css +1 -1
  37. package/dist/index.d.ts +5 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1374 -889
  40. package/dist/submit/buildCurlStatement.d.ts +1 -1
  41. package/dist/submit/buildCurlStatement.d.ts.map +1 -1
  42. package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -1
  43. package/dist/types/index.d.ts +33 -2
  44. package/dist/types/index.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/adapter/a2uiAdapter.test.ts +71 -0
  47. package/src/adapter/a2uiAdapter.ts +41 -4
  48. package/src/adapter/formSchema.ts +102 -19
  49. package/src/blocks/case_multiselect/adapter.ts +90 -0
  50. package/src/blocks/case_multiselect/index.ts +14 -0
  51. package/src/blocks/case_singleselect/catalog.ts +1 -0
  52. package/src/blocks/registry.ts +34 -0
  53. package/src/blocks/resume_multiselect/adapter.ts +57 -0
  54. package/src/blocks/resume_multiselect/catalog.ts +1 -0
  55. package/src/blocks/resume_multiselect/index.ts +14 -0
  56. package/src/blocks/types.ts +34 -0
  57. package/src/catalog/a2uiCustomCatalog.tsx +6 -0
  58. package/src/catalog/caseSearchContext.tsx +46 -0
  59. package/src/catalog/resumeSearchContext.tsx +58 -0
  60. package/src/catalog/skoponCaseSelect.tsx +240 -0
  61. package/src/catalog/skoponResumeSelect.tsx +293 -0
  62. package/src/components/AskUserFormCard.tsx +13 -1
  63. package/src/components/SkoponA2uiStreamRenderer.test.ts +18 -2
  64. package/src/components/SkoponA2uiStreamRenderer.test.tsx +26 -2
  65. package/src/components/SkoponA2uiStreamRenderer.tsx +71 -27
  66. package/src/components/SkoponFormRenderer.tsx +32 -10
  67. package/src/index.ts +23 -0
  68. package/src/styles/a2ui-preview.css +4 -4
  69. package/src/styles/index.css +191 -0
  70. package/src/submit/buildCurlStatement.ts +2 -23
  71. package/src/submit/intersectPayloadBlocksWithForm.ts +14 -2
  72. package/src/submit/submit.test.ts +34 -3
  73. package/src/types/index.ts +37 -0
@@ -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
+ }
@@ -26,6 +26,10 @@ import {
26
26
  formatFileAcceptSummary,
27
27
  } from '../adapter/formFileAccept'
28
28
  import { buildMediaListClassName, normalizeMediaSize } from '../adapter/formMedia'
29
+ import {
30
+ getRegisteredCatalogComponentNames,
31
+ getRegisteredCatalogComponents,
32
+ } from '../blocks/registry'
29
33
  import FilePlaceholderIcon from '../icons/FilePlaceholderIcon'
30
34
  import { useA2uiPreviewMode } from './a2uiPreviewContext'
31
35
  import {
@@ -53,6 +57,7 @@ const SKOPON_COMPONENT_NAMES = new Set([
53
57
  'FileUpload',
54
58
  'TextField',
55
59
  'Text',
60
+ ...getRegisteredCatalogComponentNames(),
56
61
  ])
57
62
 
58
63
  const NON_MARKDOWN_TEXT_VARIANTS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'caption'])
@@ -691,6 +696,7 @@ export function buildSkoponCatalog(): Catalog<ReactComponentImplementation> {
691
696
  FileUploadImpl,
692
697
  TextFieldImpl,
693
698
  TextImpl,
699
+ ...getRegisteredCatalogComponents(),
694
700
  ]
695
701
  const functions = [...basicCatalog.functions.values()]
696
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,58 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+ import type { FormResumeFilter } from '../types/index'
3
+
4
+ export interface ResumeSearchWorkItem {
5
+ title: string
6
+ coverUrl?: string | null
7
+ link?: string
8
+ }
9
+
10
+ export interface ResumeSearchItem {
11
+ resumeUniqueId: string
12
+ resumeId?: number
13
+ agentId?: number
14
+ name: string
15
+ avatarUrl?: string | null
16
+ description?: string
17
+ satisfaction?: number
18
+ published?: number
19
+ worksCount?: number
20
+ personality?: string[]
21
+ specialties?: string[]
22
+ traits?: string[]
23
+ works?: ResumeSearchWorkItem[]
24
+ }
25
+
26
+ export interface ResumeSearchParams {
27
+ filter: FormResumeFilter
28
+ page: number
29
+ }
30
+
31
+ export interface ResumeSearchResult {
32
+ list: ResumeSearchItem[]
33
+ total: number
34
+ page: number
35
+ pageSize: number
36
+ }
37
+
38
+ export type ResumeSearchFn = (params: ResumeSearchParams) => Promise<ResumeSearchResult>
39
+
40
+ const ResumeSearchContext = createContext<ResumeSearchFn | null>(null)
41
+
42
+ export function ResumeSearchProvider({
43
+ resumeSearch,
44
+ children,
45
+ }: {
46
+ resumeSearch: ResumeSearchFn | null | undefined
47
+ children: ReactNode
48
+ }) {
49
+ return (
50
+ <ResumeSearchContext.Provider value={resumeSearch ?? null}>
51
+ {children}
52
+ </ResumeSearchContext.Provider>
53
+ )
54
+ }
55
+
56
+ export function useResumeSearch(): ResumeSearchFn | null {
57
+ return useContext(ResumeSearchContext)
58
+ }
@@ -0,0 +1,240 @@
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 { asStringArray, 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 { flowUniqueIds: [], pageSize: 20 }
19
+ }
20
+ const rec = raw as Record<string, unknown>
21
+ const pageSizeRaw = typeof rec.pageSize === 'number' ? rec.pageSize : Number(rec.pageSize)
22
+ const pageSize =
23
+ Number.isFinite(pageSizeRaw) && pageSizeRaw >= 1 && pageSizeRaw <= 100
24
+ ? Math.floor(pageSizeRaw)
25
+ : 20
26
+ const agentKind =
27
+ typeof rec.agentKind === 'string' && rec.agentKind.trim()
28
+ ? rec.agentKind.trim()
29
+ : undefined
30
+ const agentUniqueId =
31
+ typeof rec.agentUniqueId === 'string' && rec.agentUniqueId.trim()
32
+ ? rec.agentUniqueId.trim()
33
+ : undefined
34
+ const flowType =
35
+ typeof rec.flowType === 'string' && rec.flowType.trim()
36
+ ? rec.flowType.trim()
37
+ : undefined
38
+ const category =
39
+ typeof rec.category === 'string' && rec.category.trim()
40
+ ? rec.category.trim()
41
+ : undefined
42
+ const flowUniqueIds = Array.isArray(rec.flowUniqueIds)
43
+ ? rec.flowUniqueIds.map(String).filter(Boolean)
44
+ : []
45
+ return { agentKind, agentUniqueId, flowType, category, flowUniqueIds, pageSize }
46
+ }
47
+
48
+ function isCaseFilterReady(filter: FormCaseFilter): boolean {
49
+ const hasFlowIds = Boolean(filter.flowUniqueIds?.length)
50
+ const hasCategory = Boolean(filter.agentUniqueId && filter.flowType && filter.category)
51
+ return hasFlowIds || hasCategory
52
+ }
53
+
54
+ function filterKey(filter: FormCaseFilter): string {
55
+ return JSON.stringify(filter)
56
+ }
57
+
58
+ const SkoponCaseSelectApi = {
59
+ name: 'SkoponCaseSelect',
60
+ schema: z
61
+ .object({
62
+ label: z.any().optional(),
63
+ placeholder: z.any().optional(),
64
+ help: z.any().optional(),
65
+ enableRefresh: z.any().optional(),
66
+ caseFilter: z.any().optional(),
67
+ value: z.any().optional(),
68
+ })
69
+ .passthrough(),
70
+ }
71
+
72
+ function CaseCard({
73
+ item,
74
+ selected,
75
+ disabled,
76
+ onToggle,
77
+ }: {
78
+ item: CaseSearchItem
79
+ selected: boolean
80
+ disabled: boolean
81
+ onToggle: () => void
82
+ }) {
83
+ return (
84
+ <button
85
+ type="button"
86
+ className={`skopon-resume-select-card${selected ? ' skopon-resume-select-card--selected' : ''}`}
87
+ disabled={disabled}
88
+ onClick={onToggle}
89
+ >
90
+ <div className="skopon-resume-select-card-avatar">
91
+ <FileTextOutlined style={{ fontSize: 24, color: 'var(--color-primary, #1677ff)' }} />
92
+ </div>
93
+ <div className="skopon-resume-select-card-body">
94
+ <div className="skopon-resume-select-card-name">{item.name}</div>
95
+ {item.description ? (
96
+ <div className="skopon-resume-select-card-desc">{item.description}</div>
97
+ ) : null}
98
+ <div className="skopon-resume-select-card-meta">
99
+ {item.caseUniqueId}
100
+ {item.platform ? ` · ${item.platform}` : ''}
101
+ {item.link ? ` · ${item.link}` : ''}
102
+ </div>
103
+ </div>
104
+ </button>
105
+ )
106
+ }
107
+
108
+ function SkoponCaseSelectPreview({ context }: { context: ComponentContext }) {
109
+ const { interactive } = useA2uiPreviewMode()
110
+ const caseSearch = useCaseSearch()
111
+ const { value, setValue } = useSkoponBoundField(context)
112
+ const props = context.componentModel.properties as Record<string, unknown>
113
+
114
+ const label = readString(props.label)
115
+ const placeholder = readString(props.placeholder)
116
+ const help = readString(props.help)
117
+ const enableRefresh = props.enableRefresh !== false
118
+ const caseFilter = useMemo(() => readCaseFilter(props.caseFilter), [props.caseFilter])
119
+ const filterSig = filterKey(caseFilter)
120
+
121
+ const [page, setPage] = useState(1)
122
+ const [loading, setLoading] = useState(false)
123
+ const [fetchError, setFetchError] = useState('')
124
+ const [items, setItems] = useState<CaseSearchItem[]>([])
125
+ const [total, setTotal] = useState(0)
126
+ const [pageSize, setPageSize] = useState(caseFilter.pageSize ?? 20)
127
+ const requestSeqRef = useRef(0)
128
+
129
+ const selectedIds = asStringArray(value)
130
+
131
+ const emptyHint = useMemo(() => {
132
+ if (!caseSearch) return '未配置案例搜索服务'
133
+ if (!isCaseFilterReady(caseFilter)) return '未配置案例列表筛选条件'
134
+ if (fetchError) return fetchError
135
+ return placeholder || '暂无匹配的案例'
136
+ }, [caseSearch, caseFilter, fetchError, placeholder])
137
+
138
+ const fetchPage = useCallback(
139
+ async (targetPage: number) => {
140
+ const filter = readCaseFilter(JSON.parse(filterSig) as FormCaseFilter)
141
+ const seq = ++requestSeqRef.current
142
+ if (!caseSearch || !isCaseFilterReady(filter)) {
143
+ setItems([])
144
+ setTotal(0)
145
+ setFetchError('')
146
+ return
147
+ }
148
+ setLoading(true)
149
+ setFetchError('')
150
+ try {
151
+ const result = await caseSearch({ filter, page: targetPage })
152
+ if (seq !== requestSeqRef.current) return
153
+ setItems(result.list)
154
+ setTotal(result.total)
155
+ setPage(result.page)
156
+ setPageSize(result.pageSize)
157
+ } catch (err) {
158
+ if (seq !== requestSeqRef.current) return
159
+ setItems([])
160
+ setTotal(0)
161
+ setFetchError(err instanceof Error ? err.message : '加载案例列表失败')
162
+ } finally {
163
+ if (seq === requestSeqRef.current) setLoading(false)
164
+ }
165
+ },
166
+ [caseSearch, filterSig],
167
+ )
168
+
169
+ useEffect(() => {
170
+ setPage(1)
171
+ void fetchPage(1)
172
+ }, [filterSig, fetchPage])
173
+
174
+ const toggleSelect = (caseUniqueId: string) => {
175
+ if (!interactive) return
176
+ const next = selectedIds.includes(caseUniqueId)
177
+ ? selectedIds.filter((id) => id !== caseUniqueId)
178
+ : [...selectedIds, caseUniqueId]
179
+ setValue(next)
180
+ }
181
+
182
+ const handleRefresh = () => {
183
+ const filter = readCaseFilter(JSON.parse(filterSig) as FormCaseFilter)
184
+ const effectivePageSize = pageSize || filter.pageSize || 20
185
+ const totalPages = Math.max(1, Math.ceil(total / effectivePageSize))
186
+ const nextPage = page >= totalPages ? 1 : page + 1
187
+ void fetchPage(nextPage)
188
+ }
189
+
190
+ return (
191
+ <div className="form-block-preview skopon-resume-select">
192
+ {label ? <div className="form-block-preview-label">{label}</div> : null}
193
+ {loading && items.length === 0 ? (
194
+ <div className="skopon-resume-select-status">
195
+ <Spin size="small" /> 加载案例…
196
+ </div>
197
+ ) : null}
198
+ {!loading && items.length === 0 ? (
199
+ <div className="skopon-resume-select-empty">{emptyHint}</div>
200
+ ) : null}
201
+ {items.length > 0 ? (
202
+ <div className="skopon-resume-select-list">
203
+ {items.map((item) => (
204
+ <CaseCard
205
+ key={item.caseUniqueId}
206
+ item={item}
207
+ selected={selectedIds.includes(item.caseUniqueId)}
208
+ disabled={!interactive}
209
+ onToggle={() => toggleSelect(item.caseUniqueId)}
210
+ />
211
+ ))}
212
+ </div>
213
+ ) : null}
214
+ {enableRefresh && caseSearch && isCaseFilterReady(caseFilter) ? (
215
+ <div className="skopon-resume-select-actions">
216
+ <Button
217
+ type="default"
218
+ size="small"
219
+ icon={<ReloadOutlined />}
220
+ loading={loading}
221
+ disabled={!interactive}
222
+ onClick={handleRefresh}
223
+ >
224
+ 换一批
225
+ </Button>
226
+ </div>
227
+ ) : null}
228
+ {help ? (
229
+ <Typography.Text type="secondary" className="form-block-preview-help">
230
+ {help}
231
+ </Typography.Text>
232
+ ) : null}
233
+ </div>
234
+ )
235
+ }
236
+
237
+ export const SkoponCaseSelectImpl = createBinderlessComponentImplementation(
238
+ SkoponCaseSelectApi,
239
+ SkoponCaseSelectPreview,
240
+ )
@@ -0,0 +1,293 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import { Avatar, Button, Spin, Tag, Typography } from 'antd'
3
+ import { FileImageOutlined, ReloadOutlined, UserOutlined } 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 { FormResumeFilter } from '../types/index'
8
+ import { useA2uiPreviewMode } from './a2uiPreviewContext'
9
+ import { asStringArray, useSkoponBoundField } from './useSkoponBoundField'
10
+ import {
11
+ type ResumeSearchItem,
12
+ type ResumeSearchWorkItem,
13
+ useResumeSearch,
14
+ } from './resumeSearchContext'
15
+
16
+ function readString(value: unknown): string {
17
+ return typeof value === 'string' ? value : ''
18
+ }
19
+
20
+ function readResumeFilter(raw: unknown): FormResumeFilter {
21
+ if (!raw || typeof raw !== 'object') {
22
+ return { names: [], agentUniqueIds: [], resumeUniqueIds: [], pageSize: 20 }
23
+ }
24
+ const rec = raw as Record<string, unknown>
25
+ const names = Array.isArray(rec.names) ? rec.names.map(String).filter(Boolean) : []
26
+ const agentUniqueIds = Array.isArray(rec.agentUniqueIds)
27
+ ? rec.agentUniqueIds.map(String).filter(Boolean)
28
+ : []
29
+ const resumeUniqueIds = Array.isArray(rec.resumeUniqueIds)
30
+ ? rec.resumeUniqueIds.map(String).filter(Boolean)
31
+ : []
32
+ const pageSizeRaw = typeof rec.pageSize === 'number' ? rec.pageSize : Number(rec.pageSize)
33
+ const pageSize =
34
+ Number.isFinite(pageSizeRaw) && pageSizeRaw >= 1 && pageSizeRaw <= 100
35
+ ? Math.floor(pageSizeRaw)
36
+ : 20
37
+ return { names, agentUniqueIds, resumeUniqueIds, pageSize }
38
+ }
39
+
40
+ function filterKey(filter: FormResumeFilter): string {
41
+ return JSON.stringify(filter)
42
+ }
43
+
44
+ const SkoponResumeSelectApi = {
45
+ name: 'SkoponResumeSelect',
46
+ schema: z
47
+ .object({
48
+ label: z.any().optional(),
49
+ placeholder: z.any().optional(),
50
+ help: z.any().optional(),
51
+ enableRefresh: z.any().optional(),
52
+ resumeFilter: z.any().optional(),
53
+ value: z.any().optional(),
54
+ })
55
+ .passthrough(),
56
+ }
57
+
58
+ const MAX_TAG_COUNT = 3
59
+ const MAX_WORK_PREVIEW = 2
60
+
61
+ function renderTagRow(label: string, tags: string[] | undefined) {
62
+ const items = (tags ?? []).filter(Boolean)
63
+ const visible = items.slice(0, MAX_TAG_COUNT)
64
+ const overflow = items.length - visible.length
65
+ return (
66
+ <div className="skopon-resume-select-card-tags">
67
+ <span className="skopon-resume-select-card-tags-label">{label}</span>
68
+ {items.length > 0 ? (
69
+ <div className="skopon-resume-select-card-tags-list">
70
+ {visible.map((tag) => (
71
+ <Tag key={tag} className="skopon-resume-select-card-tag">
72
+ {tag}
73
+ </Tag>
74
+ ))}
75
+ {overflow > 0 ? (
76
+ <Tag className="skopon-resume-select-card-tag">+{overflow}</Tag>
77
+ ) : null}
78
+ </div>
79
+ ) : (
80
+ <span className="skopon-resume-select-card-tags-empty">暂无</span>
81
+ )}
82
+ </div>
83
+ )
84
+ }
85
+
86
+ function renderSatisfactionRow(satisfaction: number | undefined) {
87
+ return (
88
+ <div className="skopon-resume-select-card-field">
89
+ <span className="skopon-resume-select-card-tags-label">满意度</span>
90
+ <span className="skopon-resume-select-card-field-value">
91
+ {typeof satisfaction === 'number' ? `${satisfaction}%` : '暂无'}
92
+ </span>
93
+ </div>
94
+ )
95
+ }
96
+
97
+ function ResumeWorkPreview({ work }: { work: ResumeSearchWorkItem }) {
98
+ return (
99
+ <div className="skopon-resume-select-card-work">
100
+ <div className="skopon-resume-select-card-work-cover">
101
+ {work.coverUrl ? (
102
+ <img src={work.coverUrl} alt="" className="skopon-resume-select-card-work-img" />
103
+ ) : (
104
+ <FileImageOutlined className="skopon-resume-select-card-work-placeholder" />
105
+ )}
106
+ </div>
107
+ <div className="skopon-resume-select-card-work-title">{work.title || '未命名作品'}</div>
108
+ </div>
109
+ )
110
+ }
111
+
112
+ function ResumeCard({
113
+ item,
114
+ selected,
115
+ disabled,
116
+ onToggle,
117
+ }: {
118
+ item: ResumeSearchItem
119
+ selected: boolean
120
+ disabled: boolean
121
+ onToggle: () => void
122
+ }) {
123
+ const works = (item.works ?? []).slice(0, MAX_WORK_PREVIEW)
124
+
125
+ return (
126
+ <button
127
+ type="button"
128
+ className={`skopon-resume-select-card skopon-resume-select-card--profile${selected ? ' skopon-resume-select-card--selected' : ''}`}
129
+ disabled={disabled}
130
+ onClick={onToggle}
131
+ >
132
+ <Avatar
133
+ size={48}
134
+ src={item.avatarUrl || undefined}
135
+ icon={!item.avatarUrl ? <UserOutlined /> : undefined}
136
+ className="skopon-resume-select-card-avatar"
137
+ />
138
+ <div className="skopon-resume-select-card-name">{item.name || '未命名简历'}</div>
139
+ <div className="skopon-resume-select-card-body">
140
+ {renderSatisfactionRow(item.satisfaction)}
141
+ {renderTagRow('性格', item.personality)}
142
+ {renderTagRow('专长', item.specialties)}
143
+ {renderTagRow('擅长领域', item.traits)}
144
+ <div className="skopon-resume-select-card-works">
145
+ <span className="skopon-resume-select-card-works-label">过往作品</span>
146
+ {works.length > 0 ? (
147
+ <div className="skopon-resume-select-card-works-list">
148
+ {works.map((work, index) => (
149
+ <ResumeWorkPreview key={`${work.title}-${index}`} work={work} />
150
+ ))}
151
+ </div>
152
+ ) : (
153
+ <div className="skopon-resume-select-card-works-empty">暂无作品</div>
154
+ )}
155
+ </div>
156
+ </div>
157
+ </button>
158
+ )
159
+ }
160
+
161
+ function SkoponResumeSelectPreview({ context }: { context: ComponentContext }) {
162
+ const { interactive } = useA2uiPreviewMode()
163
+ const resumeSearch = useResumeSearch()
164
+ const { value, setValue } = useSkoponBoundField(context)
165
+ const props = context.componentModel.properties as Record<string, unknown>
166
+
167
+ const label = readString(props.label)
168
+ const placeholder = readString(props.placeholder)
169
+ const help = readString(props.help)
170
+ const enableRefresh = props.enableRefresh !== false
171
+ const resumeFilter = useMemo(() => readResumeFilter(props.resumeFilter), [props.resumeFilter])
172
+ const filterSig = filterKey(resumeFilter)
173
+
174
+ const [page, setPage] = useState(1)
175
+ const [loading, setLoading] = useState(false)
176
+ const [error, setError] = useState('')
177
+ const [items, setItems] = useState<ResumeSearchItem[]>([])
178
+ const [total, setTotal] = useState(0)
179
+ const [pageSize, setPageSize] = useState(resumeFilter.pageSize ?? 20)
180
+ const requestSeqRef = useRef(0)
181
+
182
+ const selectedIds = asStringArray(value)
183
+
184
+ const fetchPage = useCallback(
185
+ async (targetPage: number) => {
186
+ const filter = readResumeFilter(JSON.parse(filterSig) as FormResumeFilter)
187
+ const seq = ++requestSeqRef.current
188
+ if (!resumeSearch) {
189
+ setItems([])
190
+ setTotal(0)
191
+ setError('未配置简历搜索服务')
192
+ return
193
+ }
194
+ setLoading(true)
195
+ setError('')
196
+ try {
197
+ const result = await resumeSearch({ filter, page: targetPage })
198
+ if (seq !== requestSeqRef.current) return
199
+ setItems(result.list)
200
+ setTotal(result.total)
201
+ setPage(result.page)
202
+ setPageSize(result.pageSize)
203
+ } catch (err) {
204
+ if (seq !== requestSeqRef.current) return
205
+ setItems([])
206
+ setTotal(0)
207
+ setError(err instanceof Error ? err.message : '加载简历列表失败')
208
+ } finally {
209
+ if (seq === requestSeqRef.current) setLoading(false)
210
+ }
211
+ },
212
+ [resumeSearch, filterSig],
213
+ )
214
+
215
+ useEffect(() => {
216
+ setPage(1)
217
+ void fetchPage(1)
218
+ }, [filterSig, fetchPage])
219
+
220
+ const toggleSelect = (resumeUniqueId: string) => {
221
+ if (!interactive) return
222
+ const next = selectedIds.includes(resumeUniqueId)
223
+ ? selectedIds.filter((id) => id !== resumeUniqueId)
224
+ : [...selectedIds, resumeUniqueId]
225
+ setValue(next)
226
+ }
227
+
228
+ const handleRefresh = () => {
229
+ const filter = readResumeFilter(JSON.parse(filterSig) as FormResumeFilter)
230
+ const effectivePageSize = pageSize || filter.pageSize || 20
231
+ const totalPages = Math.max(1, Math.ceil(total / effectivePageSize))
232
+ const nextPage = page >= totalPages ? 1 : page + 1
233
+ void fetchPage(nextPage)
234
+ }
235
+
236
+ const emptyLabel = placeholder || '暂无匹配的简历'
237
+
238
+ return (
239
+ <div className="form-block-preview skopon-resume-select">
240
+ {label ? <div className="form-block-preview-label">{label}</div> : null}
241
+ {loading && items.length === 0 ? (
242
+ <div className="skopon-resume-select-status">
243
+ <Spin size="small" /> 加载简历…
244
+ </div>
245
+ ) : null}
246
+ {!loading && error ? (
247
+ <Typography.Text type="danger" className="skopon-resume-select-status">
248
+ {error}
249
+ </Typography.Text>
250
+ ) : null}
251
+ {!loading && !error && items.length === 0 ? (
252
+ <div className="skopon-resume-select-empty">{emptyLabel}</div>
253
+ ) : null}
254
+ {items.length > 0 ? (
255
+ <div className="skopon-resume-select-list">
256
+ {items.map((item) => (
257
+ <ResumeCard
258
+ key={item.resumeUniqueId}
259
+ item={item}
260
+ selected={selectedIds.includes(item.resumeUniqueId)}
261
+ disabled={!interactive}
262
+ onToggle={() => toggleSelect(item.resumeUniqueId)}
263
+ />
264
+ ))}
265
+ </div>
266
+ ) : null}
267
+ {enableRefresh && resumeSearch ? (
268
+ <div className="skopon-resume-select-actions">
269
+ <Button
270
+ type="default"
271
+ size="small"
272
+ icon={<ReloadOutlined />}
273
+ loading={loading}
274
+ disabled={!interactive}
275
+ onClick={handleRefresh}
276
+ >
277
+ 换一批
278
+ </Button>
279
+ </div>
280
+ ) : null}
281
+ {help ? (
282
+ <Typography.Text type="secondary" className="form-block-preview-help">
283
+ {help}
284
+ </Typography.Text>
285
+ ) : null}
286
+ </div>
287
+ )
288
+ }
289
+
290
+ export const SkoponResumeSelectImpl = createBinderlessComponentImplementation(
291
+ SkoponResumeSelectApi,
292
+ SkoponResumeSelectPreview,
293
+ )