@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.
- package/README.md +2 -0
- package/dist/adapter/a2uiAdapter.d.ts +0 -1
- package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
- package/dist/adapter/formSchema.d.ts.map +1 -1
- package/dist/blocks/case_multiselect/adapter.d.ts +10 -0
- package/dist/blocks/case_multiselect/adapter.d.ts.map +1 -0
- package/dist/blocks/case_multiselect/index.d.ts +3 -0
- package/dist/blocks/case_multiselect/index.d.ts.map +1 -0
- package/dist/blocks/case_singleselect/catalog.d.ts +2 -0
- package/dist/blocks/case_singleselect/catalog.d.ts.map +1 -0
- package/dist/blocks/registry.d.ts +8 -0
- package/dist/blocks/registry.d.ts.map +1 -0
- package/dist/blocks/resume_multiselect/adapter.d.ts +10 -0
- package/dist/blocks/resume_multiselect/adapter.d.ts.map +1 -0
- package/dist/blocks/resume_multiselect/catalog.d.ts +2 -0
- package/dist/blocks/resume_multiselect/catalog.d.ts.map +1 -0
- package/dist/blocks/resume_multiselect/index.d.ts +3 -0
- package/dist/blocks/resume_multiselect/index.d.ts.map +1 -0
- package/dist/blocks/types.d.ts +27 -0
- package/dist/blocks/types.d.ts.map +1 -0
- package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
- package/dist/catalog/caseSearchContext.d.ts +28 -0
- package/dist/catalog/caseSearchContext.d.ts.map +1 -0
- package/dist/catalog/resumeSearchContext.d.ts +39 -0
- package/dist/catalog/resumeSearchContext.d.ts.map +1 -0
- package/dist/catalog/skoponCaseSelect.d.ts +2 -0
- package/dist/catalog/skoponCaseSelect.d.ts.map +1 -0
- package/dist/catalog/skoponResumeSelect.d.ts +2 -0
- package/dist/catalog/skoponResumeSelect.d.ts.map +1 -0
- package/dist/components/AskUserFormCard.d.ts +7 -1
- package/dist/components/AskUserFormCard.d.ts.map +1 -1
- package/dist/components/SkoponA2uiStreamRenderer.d.ts +7 -1
- package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -1
- package/dist/components/SkoponFormRenderer.d.ts +6 -0
- package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
- package/dist/form-sdk.css +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1374 -889
- package/dist/submit/buildCurlStatement.d.ts +1 -1
- package/dist/submit/buildCurlStatement.d.ts.map +1 -1
- package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -1
- package/dist/types/index.d.ts +33 -2
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapter/a2uiAdapter.test.ts +71 -0
- package/src/adapter/a2uiAdapter.ts +41 -4
- package/src/adapter/formSchema.ts +102 -19
- package/src/blocks/case_multiselect/adapter.ts +90 -0
- package/src/blocks/case_multiselect/index.ts +14 -0
- package/src/blocks/case_singleselect/catalog.ts +1 -0
- package/src/blocks/registry.ts +34 -0
- package/src/blocks/resume_multiselect/adapter.ts +57 -0
- package/src/blocks/resume_multiselect/catalog.ts +1 -0
- package/src/blocks/resume_multiselect/index.ts +14 -0
- package/src/blocks/types.ts +34 -0
- package/src/catalog/a2uiCustomCatalog.tsx +6 -0
- package/src/catalog/caseSearchContext.tsx +46 -0
- package/src/catalog/resumeSearchContext.tsx +58 -0
- package/src/catalog/skoponCaseSelect.tsx +240 -0
- package/src/catalog/skoponResumeSelect.tsx +293 -0
- package/src/components/AskUserFormCard.tsx +13 -1
- package/src/components/SkoponA2uiStreamRenderer.test.ts +18 -2
- package/src/components/SkoponA2uiStreamRenderer.test.tsx +26 -2
- package/src/components/SkoponA2uiStreamRenderer.tsx +71 -27
- package/src/components/SkoponFormRenderer.tsx +32 -10
- package/src/index.ts +23 -0
- package/src/styles/a2ui-preview.css +4 -4
- package/src/styles/index.css +191 -0
- package/src/submit/buildCurlStatement.ts +2 -23
- package/src/submit/intersectPayloadBlocksWithForm.ts +14 -2
- package/src/submit/submit.test.ts +34 -3
- 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
|
+
)
|