@skopon-cool/form-sdk 0.1.3 → 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.
- 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_singleselect/adapter.d.ts +10 -0
- package/dist/blocks/case_singleselect/adapter.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/case_singleselect/index.d.ts +3 -0
- package/dist/blocks/case_singleselect/index.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 +30 -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 +1290 -845
- package/dist/types/index.d.ts +26 -2
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapter/a2uiAdapter.test.ts +48 -0
- package/src/adapter/a2uiAdapter.ts +41 -1
- package/src/adapter/formSchema.ts +65 -2
- package/src/blocks/case_singleselect/adapter.ts +74 -0
- package/src/blocks/case_singleselect/catalog.ts +1 -0
- package/src/blocks/case_singleselect/index.ts +14 -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 +48 -0
- package/src/catalog/skoponCaseSelect.tsx +215 -0
- package/src/catalog/skoponResumeSelect.tsx +227 -0
- package/src/components/AskUserFormCard.tsx +10 -0
- package/src/components/SkoponA2uiStreamRenderer.test.ts +18 -2
- package/src/components/SkoponA2uiStreamRenderer.test.tsx +26 -2
- package/src/components/SkoponA2uiStreamRenderer.tsx +60 -23
- package/src/components/SkoponFormRenderer.tsx +32 -10
- package/src/index.ts +23 -0
- package/src/styles/index.css +60 -0
- package/src/types/index.ts +30 -0
|
@@ -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
|
+
)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { Avatar, Button, Spin, Typography } from 'antd'
|
|
3
|
+
import { 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
|
+
useResumeSearch,
|
|
13
|
+
} from './resumeSearchContext'
|
|
14
|
+
|
|
15
|
+
function readString(value: unknown): string {
|
|
16
|
+
return typeof value === 'string' ? value : ''
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readResumeFilter(raw: unknown): FormResumeFilter {
|
|
20
|
+
if (!raw || typeof raw !== 'object') {
|
|
21
|
+
return { names: [], agentUniqueIds: [], resumeUniqueIds: [], pageSize: 20 }
|
|
22
|
+
}
|
|
23
|
+
const rec = raw as Record<string, unknown>
|
|
24
|
+
const names = Array.isArray(rec.names) ? rec.names.map(String).filter(Boolean) : []
|
|
25
|
+
const agentUniqueIds = Array.isArray(rec.agentUniqueIds)
|
|
26
|
+
? rec.agentUniqueIds.map(String).filter(Boolean)
|
|
27
|
+
: []
|
|
28
|
+
const resumeUniqueIds = Array.isArray(rec.resumeUniqueIds)
|
|
29
|
+
? rec.resumeUniqueIds.map(String).filter(Boolean)
|
|
30
|
+
: []
|
|
31
|
+
const pageSizeRaw = typeof rec.pageSize === 'number' ? rec.pageSize : Number(rec.pageSize)
|
|
32
|
+
const pageSize =
|
|
33
|
+
Number.isFinite(pageSizeRaw) && pageSizeRaw >= 1 && pageSizeRaw <= 100
|
|
34
|
+
? Math.floor(pageSizeRaw)
|
|
35
|
+
: 20
|
|
36
|
+
return { names, agentUniqueIds, resumeUniqueIds, pageSize }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function filterKey(filter: FormResumeFilter): string {
|
|
40
|
+
return JSON.stringify(filter)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SkoponResumeSelectApi = {
|
|
44
|
+
name: 'SkoponResumeSelect',
|
|
45
|
+
schema: z
|
|
46
|
+
.object({
|
|
47
|
+
label: z.any().optional(),
|
|
48
|
+
placeholder: z.any().optional(),
|
|
49
|
+
help: z.any().optional(),
|
|
50
|
+
enableRefresh: z.any().optional(),
|
|
51
|
+
resumeFilter: z.any().optional(),
|
|
52
|
+
value: z.any().optional(),
|
|
53
|
+
})
|
|
54
|
+
.passthrough(),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function ResumeCard({
|
|
58
|
+
item,
|
|
59
|
+
selected,
|
|
60
|
+
disabled,
|
|
61
|
+
onToggle,
|
|
62
|
+
}: {
|
|
63
|
+
item: ResumeSearchItem
|
|
64
|
+
selected: boolean
|
|
65
|
+
disabled: boolean
|
|
66
|
+
onToggle: () => void
|
|
67
|
+
}) {
|
|
68
|
+
return (
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
className={`skopon-resume-select-card${selected ? ' skopon-resume-select-card--selected' : ''}`}
|
|
72
|
+
disabled={disabled}
|
|
73
|
+
onClick={onToggle}
|
|
74
|
+
>
|
|
75
|
+
<Avatar
|
|
76
|
+
size={48}
|
|
77
|
+
src={item.avatarUrl || undefined}
|
|
78
|
+
icon={!item.avatarUrl ? <UserOutlined /> : undefined}
|
|
79
|
+
className="skopon-resume-select-card-avatar"
|
|
80
|
+
/>
|
|
81
|
+
<div className="skopon-resume-select-card-body">
|
|
82
|
+
<div className="skopon-resume-select-card-name">{item.name}</div>
|
|
83
|
+
{item.description ? (
|
|
84
|
+
<div className="skopon-resume-select-card-desc">{item.description}</div>
|
|
85
|
+
) : null}
|
|
86
|
+
<div className="skopon-resume-select-card-meta">
|
|
87
|
+
{item.resumeUniqueId}
|
|
88
|
+
{typeof item.satisfaction === 'number' ? ` · 满意度 ${item.satisfaction}%` : ''}
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</button>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function SkoponResumeSelectPreview({ context }: { context: ComponentContext }) {
|
|
96
|
+
const { interactive } = useA2uiPreviewMode()
|
|
97
|
+
const resumeSearch = useResumeSearch()
|
|
98
|
+
const { value, setValue } = useSkoponBoundField(context)
|
|
99
|
+
const props = context.componentModel.properties as Record<string, unknown>
|
|
100
|
+
|
|
101
|
+
const label = readString(props.label)
|
|
102
|
+
const placeholder = readString(props.placeholder)
|
|
103
|
+
const help = readString(props.help)
|
|
104
|
+
const enableRefresh = props.enableRefresh !== false
|
|
105
|
+
const resumeFilter = useMemo(() => readResumeFilter(props.resumeFilter), [props.resumeFilter])
|
|
106
|
+
const filterSig = filterKey(resumeFilter)
|
|
107
|
+
|
|
108
|
+
const [page, setPage] = useState(1)
|
|
109
|
+
const [loading, setLoading] = useState(false)
|
|
110
|
+
const [error, setError] = useState('')
|
|
111
|
+
const [items, setItems] = useState<ResumeSearchItem[]>([])
|
|
112
|
+
const [total, setTotal] = useState(0)
|
|
113
|
+
const [pageSize, setPageSize] = useState(resumeFilter.pageSize ?? 20)
|
|
114
|
+
const requestSeqRef = useRef(0)
|
|
115
|
+
|
|
116
|
+
const selectedIds = asStringArray(value)
|
|
117
|
+
|
|
118
|
+
const fetchPage = useCallback(
|
|
119
|
+
async (targetPage: number) => {
|
|
120
|
+
const filter = readResumeFilter(JSON.parse(filterSig) as FormResumeFilter)
|
|
121
|
+
const seq = ++requestSeqRef.current
|
|
122
|
+
if (!resumeSearch) {
|
|
123
|
+
setItems([])
|
|
124
|
+
setTotal(0)
|
|
125
|
+
setError('未配置简历搜索服务')
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
setLoading(true)
|
|
129
|
+
setError('')
|
|
130
|
+
try {
|
|
131
|
+
const result = await resumeSearch({ filter, page: targetPage })
|
|
132
|
+
if (seq !== requestSeqRef.current) return
|
|
133
|
+
setItems(result.list)
|
|
134
|
+
setTotal(result.total)
|
|
135
|
+
setPage(result.page)
|
|
136
|
+
setPageSize(result.pageSize)
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (seq !== requestSeqRef.current) return
|
|
139
|
+
setItems([])
|
|
140
|
+
setTotal(0)
|
|
141
|
+
setError(err instanceof Error ? err.message : '加载简历列表失败')
|
|
142
|
+
} finally {
|
|
143
|
+
if (seq === requestSeqRef.current) setLoading(false)
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
[resumeSearch, filterSig],
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
setPage(1)
|
|
151
|
+
void fetchPage(1)
|
|
152
|
+
}, [filterSig, fetchPage])
|
|
153
|
+
|
|
154
|
+
const toggleSelect = (resumeUniqueId: string) => {
|
|
155
|
+
if (!interactive) return
|
|
156
|
+
const next = selectedIds.includes(resumeUniqueId)
|
|
157
|
+
? selectedIds.filter((id) => id !== resumeUniqueId)
|
|
158
|
+
: [...selectedIds, resumeUniqueId]
|
|
159
|
+
setValue(next)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const handleRefresh = () => {
|
|
163
|
+
const filter = readResumeFilter(JSON.parse(filterSig) as FormResumeFilter)
|
|
164
|
+
const effectivePageSize = pageSize || filter.pageSize || 20
|
|
165
|
+
const totalPages = Math.max(1, Math.ceil(total / effectivePageSize))
|
|
166
|
+
const nextPage = page >= totalPages ? 1 : page + 1
|
|
167
|
+
void fetchPage(nextPage)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const emptyLabel = placeholder || '暂无匹配的简历'
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div className="form-block-preview skopon-resume-select">
|
|
174
|
+
{label ? <div className="form-block-preview-label">{label}</div> : null}
|
|
175
|
+
{loading && items.length === 0 ? (
|
|
176
|
+
<div className="skopon-resume-select-status">
|
|
177
|
+
<Spin size="small" /> 加载简历…
|
|
178
|
+
</div>
|
|
179
|
+
) : null}
|
|
180
|
+
{!loading && error ? (
|
|
181
|
+
<Typography.Text type="danger" className="skopon-resume-select-status">
|
|
182
|
+
{error}
|
|
183
|
+
</Typography.Text>
|
|
184
|
+
) : null}
|
|
185
|
+
{!loading && !error && items.length === 0 ? (
|
|
186
|
+
<div className="skopon-resume-select-empty">{emptyLabel}</div>
|
|
187
|
+
) : null}
|
|
188
|
+
{items.length > 0 ? (
|
|
189
|
+
<div className="skopon-resume-select-list">
|
|
190
|
+
{items.map((item) => (
|
|
191
|
+
<ResumeCard
|
|
192
|
+
key={item.resumeUniqueId}
|
|
193
|
+
item={item}
|
|
194
|
+
selected={selectedIds.includes(item.resumeUniqueId)}
|
|
195
|
+
disabled={!interactive}
|
|
196
|
+
onToggle={() => toggleSelect(item.resumeUniqueId)}
|
|
197
|
+
/>
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
) : null}
|
|
201
|
+
{enableRefresh && resumeSearch ? (
|
|
202
|
+
<div className="skopon-resume-select-actions">
|
|
203
|
+
<Button
|
|
204
|
+
type="default"
|
|
205
|
+
size="small"
|
|
206
|
+
icon={<ReloadOutlined />}
|
|
207
|
+
loading={loading}
|
|
208
|
+
disabled={!interactive}
|
|
209
|
+
onClick={handleRefresh}
|
|
210
|
+
>
|
|
211
|
+
换一批
|
|
212
|
+
</Button>
|
|
213
|
+
</div>
|
|
214
|
+
) : null}
|
|
215
|
+
{help ? (
|
|
216
|
+
<Typography.Text type="secondary" className="form-block-preview-help">
|
|
217
|
+
{help}
|
|
218
|
+
</Typography.Text>
|
|
219
|
+
) : null}
|
|
220
|
+
</div>
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export const SkoponResumeSelectImpl = createBinderlessComponentImplementation(
|
|
225
|
+
SkoponResumeSelectApi,
|
|
226
|
+
SkoponResumeSelectPreview,
|
|
227
|
+
)
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
payloadHasInputBlocks,
|
|
13
13
|
} from '../submit/intersectPayloadBlocksWithForm'
|
|
14
14
|
import { copyTextToClipboard, submitFormJson } from '../submit/submitFormJson'
|
|
15
|
+
import type { CaseSearchFn } from '../catalog/caseSearchContext'
|
|
16
|
+
import type { ResumeSearchFn } from '../catalog/resumeSearchContext'
|
|
15
17
|
import SkoponFormRenderer, { type SkoponFormRendererRef } from './SkoponFormRenderer'
|
|
16
18
|
import CurlSubmitBlock from './CurlSubmitBlock'
|
|
17
19
|
|
|
@@ -37,6 +39,10 @@ export interface AskUserFormCardProps {
|
|
|
37
39
|
/** 需 useCallback 稳定引用,避免重复拉取 */
|
|
38
40
|
fetchFormDetail: (ref: { formUniqueId: string }) => Promise<FormDetailResult>
|
|
39
41
|
onNotify?: (type: 'success' | 'error', message: string) => void
|
|
42
|
+
/** 简历多选组件:注入搜索函数 */
|
|
43
|
+
resumeSearch?: ResumeSearchFn | null
|
|
44
|
+
/** 案例单选组件:注入搜索函数 */
|
|
45
|
+
caseSearch?: CaseSearchFn | null
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
export default function AskUserFormCard({
|
|
@@ -46,6 +52,8 @@ export default function AskUserFormCard({
|
|
|
46
52
|
submitMode = 'curl',
|
|
47
53
|
fetchFormDetail,
|
|
48
54
|
onNotify,
|
|
55
|
+
resumeSearch = null,
|
|
56
|
+
caseSearch = null,
|
|
49
57
|
}: AskUserFormCardProps) {
|
|
50
58
|
const [formDef, setFormDef] = useState<FormSchema | null>(null)
|
|
51
59
|
const [formDisabled, setFormDisabled] = useState(false)
|
|
@@ -213,6 +221,8 @@ export default function AskUserFormCard({
|
|
|
213
221
|
surfaceId={surfaceId}
|
|
214
222
|
fieldNames={fieldNames}
|
|
215
223
|
interactive
|
|
224
|
+
resumeSearch={resumeSearch}
|
|
225
|
+
caseSearch={caseSearch}
|
|
216
226
|
/>
|
|
217
227
|
|
|
218
228
|
<div className="ask-user-form-actions">
|
|
@@ -11,12 +11,12 @@ function createProcessor(): MessageProcessor<ReactComponentImplementation> {
|
|
|
11
11
|
return new MessageProcessor([buildSkoponCatalog()])
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
function buildStreamMessages(surfaceId: string): A2uiMessage[] {
|
|
14
|
+
function buildStreamMessages(surfaceId: string, label = '姓名'): A2uiMessage[] {
|
|
15
15
|
const doc = blocksToA2ui(
|
|
16
16
|
{
|
|
17
17
|
title: '',
|
|
18
18
|
description: '',
|
|
19
|
-
blocks: [{ id: 'b-name', type: 'text', name: 'name', label
|
|
19
|
+
blocks: [{ id: 'b-name', type: 'text', name: 'name', label }],
|
|
20
20
|
},
|
|
21
21
|
{ includeHeader: false },
|
|
22
22
|
)
|
|
@@ -59,4 +59,20 @@ describe('SkoponA2uiStreamRenderer stream lifecycle', () => {
|
|
|
59
59
|
rebuilt.processMessages(messages.slice(0, 1))
|
|
60
60
|
expect(rebuilt.model.surfacesMap.size).toBeLessThanOrEqual(countAfterFull)
|
|
61
61
|
})
|
|
62
|
+
|
|
63
|
+
it('rebuilds processor when message count is unchanged but content replaced', () => {
|
|
64
|
+
const surfaceId = 'stream-same-len'
|
|
65
|
+
const messagesA = buildStreamMessages(surfaceId, '姓名')
|
|
66
|
+
const messagesB = buildStreamMessages(surfaceId, '昵称')
|
|
67
|
+
expect(messagesA.length).toBe(messagesB.length)
|
|
68
|
+
|
|
69
|
+
const processor = createProcessor()
|
|
70
|
+
processor.processMessages(messagesA)
|
|
71
|
+
expect(processor.model.surfacesMap.size).toBeGreaterThan(0)
|
|
72
|
+
|
|
73
|
+
const rebuilt = createProcessor()
|
|
74
|
+
rebuilt.processMessages(messagesB)
|
|
75
|
+
expect(rebuilt.model.surfacesMap.size).toBeGreaterThan(0)
|
|
76
|
+
expect(JSON.stringify(messagesA)).not.toBe(JSON.stringify(messagesB))
|
|
77
|
+
})
|
|
62
78
|
})
|
|
@@ -18,12 +18,12 @@ import { blocksToA2ui, surfaceDocToMessages } from '../adapter/a2uiAdapter'
|
|
|
18
18
|
import { SKOPON_CATALOG_ID } from '../catalog/a2uiCustomCatalog'
|
|
19
19
|
import SkoponA2uiStreamRenderer from './SkoponA2uiStreamRenderer'
|
|
20
20
|
|
|
21
|
-
function buildStreamMessages(surfaceId: string): A2uiMessage[] {
|
|
21
|
+
function buildStreamMessages(surfaceId: string, label = '姓名'): A2uiMessage[] {
|
|
22
22
|
const doc = blocksToA2ui(
|
|
23
23
|
{
|
|
24
24
|
title: '',
|
|
25
25
|
description: '',
|
|
26
|
-
blocks: [{ id: 'b-name', type: 'text', name: 'name', label
|
|
26
|
+
blocks: [{ id: 'b-name', type: 'text', name: 'name', label }],
|
|
27
27
|
},
|
|
28
28
|
{ includeHeader: false },
|
|
29
29
|
)
|
|
@@ -76,4 +76,28 @@ describe('SkoponA2uiStreamRenderer component', () => {
|
|
|
76
76
|
expect(getByText('姓名')).toBeTruthy()
|
|
77
77
|
expect(queryByText('stream-empty')).toBeNull()
|
|
78
78
|
})
|
|
79
|
+
|
|
80
|
+
it('reprocesses messages when stream content is replaced at same length', () => {
|
|
81
|
+
const surfaceId = 'stream-replace'
|
|
82
|
+
const messagesA = buildStreamMessages(surfaceId, '姓名')
|
|
83
|
+
const messagesB = buildStreamMessages(surfaceId, '昵称')
|
|
84
|
+
expect(messagesA.length).toBe(messagesB.length)
|
|
85
|
+
|
|
86
|
+
const { rerender, getByText, queryByText } = render(
|
|
87
|
+
createElement(SkoponA2uiStreamRenderer, {
|
|
88
|
+
messages: messagesA,
|
|
89
|
+
surfaceId,
|
|
90
|
+
}),
|
|
91
|
+
)
|
|
92
|
+
expect(getByText('姓名')).toBeTruthy()
|
|
93
|
+
|
|
94
|
+
rerender(
|
|
95
|
+
createElement(SkoponA2uiStreamRenderer, {
|
|
96
|
+
messages: messagesB,
|
|
97
|
+
surfaceId,
|
|
98
|
+
}),
|
|
99
|
+
)
|
|
100
|
+
expect(getByText('昵称')).toBeTruthy()
|
|
101
|
+
expect(queryByText('姓名')).toBeNull()
|
|
102
|
+
})
|
|
79
103
|
})
|