@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.
- package/README.md +49 -11
- package/dist/adapter/a2uiAdapter.d.ts +0 -1
- package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
- package/dist/adapter/formFileAccept.d.ts.map +1 -1
- package/dist/adapter/formSchema.d.ts +1 -0
- 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/catalog/useSkoponBoundField.d.ts +2 -0
- package/dist/catalog/useSkoponBoundField.d.ts.map +1 -1
- package/dist/client/formClient.d.ts.map +1 -1
- package/dist/components/AskUserFormCard.d.ts +10 -2
- 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/SkoponA2uiStreamRenderer.test.d.ts +2 -0
- package/dist/components/SkoponA2uiStreamRenderer.test.d.ts.map +1 -0
- 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 +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1390 -710
- package/dist/submit/buildCurlStatement.d.ts +8 -0
- package/dist/submit/buildCurlStatement.d.ts.map +1 -1
- package/dist/submit/intersectPayloadBlocksWithForm.d.ts +26 -0
- package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -0
- package/dist/submit/submitFormJson.d.ts.map +1 -1
- package/dist/types/index.d.ts +26 -2
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +11 -6
- package/src/adapter/a2uiAdapter.test.ts +116 -0
- package/src/adapter/a2uiAdapter.ts +48 -4
- package/src/adapter/formFileAccept.test.ts +53 -0
- package/src/adapter/formFileAccept.ts +11 -2
- package/src/adapter/formSchema.test.ts +35 -0
- package/src/adapter/formSchema.ts +70 -3
- 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 +34 -5
- 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/catalog/textFieldPreview.test.tsx +1 -1
- package/src/catalog/useSkoponBoundField.test.ts +62 -0
- package/src/catalog/useSkoponBoundField.ts +10 -1
- package/src/client/formClient.test.ts +83 -0
- package/src/client/formClient.ts +10 -2
- package/src/components/AskUserFormCard.tsx +146 -58
- package/src/components/SkoponA2uiStreamRenderer.test.ts +78 -0
- package/src/components/SkoponA2uiStreamRenderer.test.tsx +103 -0
- package/src/components/SkoponA2uiStreamRenderer.tsx +141 -23
- package/src/components/SkoponFormRenderer.tsx +42 -17
- package/src/index.ts +34 -2
- package/src/styles/index.css +65 -0
- package/src/submit/buildCurlStatement.ts +49 -0
- package/src/submit/intersectPayloadBlocksWithForm.ts +175 -0
- package/src/submit/submit.test.ts +170 -10
- package/src/submit/submitFormJson.ts +20 -1
- package/src/types/index.ts +30 -0
- package/dist/submit/intersectPayloadWithForm.d.ts +0 -17
- package/dist/submit/intersectPayloadWithForm.d.ts.map +0 -1
- package/src/submit/intersectPayloadWithForm.ts +0 -54
|
@@ -1,20 +1,48 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
-
import { Button, Spin, Tooltip } from 'antd'
|
|
2
|
+
import { Button, Spin, Tag, Tooltip } from 'antd'
|
|
3
3
|
import type { FormDetailResult, FormSchema, SubmitMode } from '../types/index'
|
|
4
4
|
import { buildAskUserSurface } from '../adapter/resolveSurface'
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { buildAskUserCurlStatement } from '../submit/buildCurlStatement'
|
|
6
|
+
import {
|
|
7
|
+
extractExtraBlockValues,
|
|
8
|
+
getPayloadInputFieldNames,
|
|
9
|
+
getPayloadRenderableBlocks,
|
|
10
|
+
intersectPayloadBlocksWithForm,
|
|
11
|
+
parsePayloadBlocksJson,
|
|
12
|
+
payloadHasInputBlocks,
|
|
13
|
+
} from '../submit/intersectPayloadBlocksWithForm'
|
|
7
14
|
import { copyTextToClipboard, submitFormJson } from '../submit/submitFormJson'
|
|
15
|
+
import type { CaseSearchFn } from '../catalog/caseSearchContext'
|
|
16
|
+
import type { ResumeSearchFn } from '../catalog/resumeSearchContext'
|
|
8
17
|
import SkoponFormRenderer, { type SkoponFormRendererRef } from './SkoponFormRenderer'
|
|
9
18
|
import CurlSubmitBlock from './CurlSubmitBlock'
|
|
10
19
|
|
|
20
|
+
function formatSubmitErrorMessage(status: number, body: unknown): string {
|
|
21
|
+
if (body && typeof body === 'object' && body !== null && 'message' in body) {
|
|
22
|
+
const msg = (body as { message: unknown }).message
|
|
23
|
+
if (typeof msg === 'string' && msg.trim()) {
|
|
24
|
+
return `提交失败 (${status}): ${msg.trim()}`
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (typeof body === 'string' && body.trim()) {
|
|
28
|
+
return `提交失败 (${status}): ${body.trim()}`
|
|
29
|
+
}
|
|
30
|
+
return `提交失败 (${status})`
|
|
31
|
+
}
|
|
32
|
+
|
|
11
33
|
export interface AskUserFormCardProps {
|
|
12
34
|
payload: unknown
|
|
13
|
-
|
|
35
|
+
/** 可选;无则跳过拉取 vt_forms,仅用 payload blocks fallback */
|
|
36
|
+
formUniqueId?: string | null
|
|
14
37
|
callbackUrl?: string | null
|
|
15
38
|
submitMode?: SubmitMode
|
|
39
|
+
/** 需 useCallback 稳定引用,避免重复拉取 */
|
|
16
40
|
fetchFormDetail: (ref: { formUniqueId: string }) => Promise<FormDetailResult>
|
|
17
41
|
onNotify?: (type: 'success' | 'error', message: string) => void
|
|
42
|
+
/** 简历多选组件:注入搜索函数 */
|
|
43
|
+
resumeSearch?: ResumeSearchFn | null
|
|
44
|
+
/** 案例单选组件:注入搜索函数 */
|
|
45
|
+
caseSearch?: CaseSearchFn | null
|
|
18
46
|
}
|
|
19
47
|
|
|
20
48
|
export default function AskUserFormCard({
|
|
@@ -24,26 +52,52 @@ export default function AskUserFormCard({
|
|
|
24
52
|
submitMode = 'curl',
|
|
25
53
|
fetchFormDetail,
|
|
26
54
|
onNotify,
|
|
55
|
+
resumeSearch = null,
|
|
56
|
+
caseSearch = null,
|
|
27
57
|
}: AskUserFormCardProps) {
|
|
28
58
|
const [formDef, setFormDef] = useState<FormSchema | null>(null)
|
|
29
59
|
const [formDisabled, setFormDisabled] = useState(false)
|
|
30
60
|
const [loading, setLoading] = useState(true)
|
|
61
|
+
const [submitting, setSubmitting] = useState(false)
|
|
31
62
|
const rendererRef = useRef<SkoponFormRendererRef>(null)
|
|
63
|
+
const mountedRef = useRef(true)
|
|
64
|
+
const onNotifyRef = useRef(onNotify)
|
|
65
|
+
onNotifyRef.current = onNotify
|
|
66
|
+
|
|
67
|
+
const resolvedFormUniqueId = formUniqueId?.trim() ?? ''
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
mountedRef.current = true
|
|
71
|
+
return () => {
|
|
72
|
+
mountedRef.current = false
|
|
73
|
+
}
|
|
74
|
+
}, [])
|
|
32
75
|
|
|
33
76
|
useEffect(() => {
|
|
34
77
|
let cancelled = false
|
|
78
|
+
if (!resolvedFormUniqueId) {
|
|
79
|
+
setLoading(false)
|
|
80
|
+
setFormDisabled(false)
|
|
81
|
+
setFormDef(null)
|
|
82
|
+
return () => {
|
|
83
|
+
cancelled = true
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
35
87
|
setLoading(true)
|
|
36
88
|
setFormDisabled(false)
|
|
37
|
-
fetchFormDetail({ formUniqueId })
|
|
89
|
+
fetchFormDetail({ formUniqueId: resolvedFormUniqueId })
|
|
38
90
|
.then((detail) => {
|
|
39
91
|
if (cancelled) return
|
|
40
92
|
setFormDisabled(Boolean(detail.disabled))
|
|
41
|
-
setFormDef(detail.
|
|
93
|
+
setFormDef(detail.formDefinition ?? null)
|
|
42
94
|
})
|
|
43
|
-
.catch(() => {
|
|
95
|
+
.catch((error) => {
|
|
44
96
|
if (!cancelled) {
|
|
45
97
|
setFormDisabled(false)
|
|
46
98
|
setFormDef(null)
|
|
99
|
+
const detail = error instanceof Error ? error.message : '获取表单定义失败'
|
|
100
|
+
onNotifyRef.current?.('error', detail)
|
|
47
101
|
}
|
|
48
102
|
})
|
|
49
103
|
.finally(() => {
|
|
@@ -52,25 +106,49 @@ export default function AskUserFormCard({
|
|
|
52
106
|
return () => {
|
|
53
107
|
cancelled = true
|
|
54
108
|
}
|
|
55
|
-
}, [
|
|
109
|
+
}, [resolvedFormUniqueId, fetchFormDetail])
|
|
56
110
|
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
111
|
+
const payloadDef = useMemo(() => parsePayloadBlocksJson(payload), [payload])
|
|
112
|
+
|
|
113
|
+
const intersection = useMemo(() => {
|
|
114
|
+
if (!payloadDef) return null
|
|
115
|
+
return intersectPayloadBlocksWithForm(payloadDef, formDef ?? undefined)
|
|
116
|
+
}, [payloadDef, formDef])
|
|
117
|
+
|
|
118
|
+
const matchedBlocks = intersection?.matchedBlocks ?? []
|
|
119
|
+
const usePayloadFallback =
|
|
120
|
+
Boolean(payloadDef && payloadHasInputBlocks(payloadDef) && matchedBlocks.length === 0)
|
|
61
121
|
|
|
62
|
-
const
|
|
63
|
-
()
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
[
|
|
122
|
+
const renderBlocks = useMemo(() => {
|
|
123
|
+
if (intersection && intersection.renderBlocks.length > 0) {
|
|
124
|
+
return intersection.renderBlocks
|
|
125
|
+
}
|
|
126
|
+
if (usePayloadFallback && payloadDef) return getPayloadRenderableBlocks(payloadDef)
|
|
127
|
+
return []
|
|
128
|
+
}, [intersection, usePayloadFallback, payloadDef])
|
|
129
|
+
|
|
130
|
+
const extraValues = useMemo(
|
|
131
|
+
() => (usePayloadFallback ? {} : extractExtraBlockValues(intersection?.extraBlocks ?? [])),
|
|
132
|
+
[usePayloadFallback, intersection],
|
|
68
133
|
)
|
|
69
134
|
|
|
135
|
+
const fieldNames = useMemo(() => {
|
|
136
|
+
if (usePayloadFallback && payloadDef) return getPayloadInputFieldNames(payloadDef)
|
|
137
|
+
return renderBlocks
|
|
138
|
+
.filter((block) => block.name?.trim())
|
|
139
|
+
.map((block) => block.name!.trim())
|
|
140
|
+
}, [usePayloadFallback, payloadDef, renderBlocks])
|
|
141
|
+
|
|
70
142
|
const surfaceDoc = useMemo(() => {
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
|
|
143
|
+
if (renderBlocks.length === 0) return null
|
|
144
|
+
const title = intersection?.title ?? payloadDef?.title ?? ''
|
|
145
|
+
const description = intersection?.description ?? payloadDef?.description ?? ''
|
|
146
|
+
return buildAskUserSurface({ title, description }, renderBlocks)
|
|
147
|
+
}, [intersection, payloadDef, renderBlocks])
|
|
148
|
+
|
|
149
|
+
const surfaceId = resolvedFormUniqueId
|
|
150
|
+
? `ask-user-${resolvedFormUniqueId}`
|
|
151
|
+
: 'ask-user-payload'
|
|
74
152
|
|
|
75
153
|
if (loading) {
|
|
76
154
|
return (
|
|
@@ -80,76 +158,86 @@ export default function AskUserFormCard({
|
|
|
80
158
|
)
|
|
81
159
|
}
|
|
82
160
|
|
|
83
|
-
if (
|
|
161
|
+
if (!payloadDef || renderBlocks.length === 0) {
|
|
84
162
|
return (
|
|
85
163
|
<CurlSubmitBlock
|
|
86
164
|
payload={payload}
|
|
87
165
|
callbackUrl={callbackUrl}
|
|
88
|
-
unpublishedFormId={formDisabled ?
|
|
166
|
+
unpublishedFormId={formDisabled && resolvedFormUniqueId ? resolvedFormUniqueId : undefined}
|
|
89
167
|
onNotify={onNotify}
|
|
90
168
|
/>
|
|
91
169
|
)
|
|
92
170
|
}
|
|
93
171
|
|
|
94
|
-
const hasRemainder = Object.keys(remainderPayload).length > 0
|
|
95
|
-
|
|
96
172
|
async function handleSubmit() {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
173
|
+
if (submitting) return
|
|
174
|
+
setSubmitting(true)
|
|
175
|
+
try {
|
|
176
|
+
const cardValues = rendererRef.current?.getValues(fieldNames) ?? {}
|
|
177
|
+
const submitBody = { ...cardValues, ...extraValues }
|
|
178
|
+
|
|
179
|
+
if (submitMode === 'post') {
|
|
180
|
+
const url = (callbackUrl ?? '').trim()
|
|
181
|
+
if (!url) {
|
|
182
|
+
onNotifyRef.current?.('error', 'callback_url 为空,无法提交')
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
const result = await submitFormJson(url, submitBody)
|
|
186
|
+
if (!mountedRef.current) return
|
|
106
187
|
if (result.ok) {
|
|
107
|
-
|
|
188
|
+
onNotifyRef.current?.('success', '提交成功')
|
|
108
189
|
} else {
|
|
109
|
-
|
|
190
|
+
onNotifyRef.current?.('error', formatSubmitErrorMessage(result.status, result.body))
|
|
110
191
|
}
|
|
111
|
-
|
|
112
|
-
const detail = error instanceof Error ? error.message : '提交失败'
|
|
113
|
-
onNotify?.('error', detail)
|
|
192
|
+
return
|
|
114
193
|
}
|
|
115
|
-
return
|
|
116
|
-
}
|
|
117
194
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
195
|
+
await copyTextToClipboard(
|
|
196
|
+
buildAskUserCurlStatement({ cardValues, extraValues, callbackUrl }),
|
|
197
|
+
)
|
|
198
|
+
if (!mountedRef.current) return
|
|
199
|
+
onNotifyRef.current?.('success', '已复制 curl 到剪贴板')
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (!mountedRef.current) return
|
|
202
|
+
const detail = error instanceof Error ? error.message : '复制失败'
|
|
203
|
+
onNotifyRef.current?.('error', detail)
|
|
204
|
+
} finally {
|
|
205
|
+
if (mountedRef.current) setSubmitting(false)
|
|
123
206
|
}
|
|
124
207
|
}
|
|
125
208
|
|
|
126
209
|
return (
|
|
127
210
|
<div className="ask-user-form-card">
|
|
211
|
+
{formDisabled && resolvedFormUniqueId ? (
|
|
212
|
+
<div className="ask-user-form-card-status">
|
|
213
|
+
<Tooltip title={resolvedFormUniqueId}>
|
|
214
|
+
<Tag className="ask-user-curl-unpublished-tag">卡片 ID 未发布</Tag>
|
|
215
|
+
</Tooltip>
|
|
216
|
+
</div>
|
|
217
|
+
) : null}
|
|
128
218
|
<SkoponFormRenderer
|
|
129
219
|
ref={rendererRef}
|
|
130
220
|
doc={surfaceDoc}
|
|
131
|
-
surfaceId={
|
|
221
|
+
surfaceId={surfaceId}
|
|
132
222
|
fieldNames={fieldNames}
|
|
133
223
|
interactive
|
|
224
|
+
resumeSearch={resumeSearch}
|
|
225
|
+
caseSearch={caseSearch}
|
|
134
226
|
/>
|
|
135
227
|
|
|
136
228
|
<div className="ask-user-form-actions">
|
|
137
229
|
<Tooltip title={submitMode === 'curl' ? '点击复制 curl' : '提交 JSON 到 callback_url'}>
|
|
138
|
-
<Button
|
|
230
|
+
<Button
|
|
231
|
+
type="primary"
|
|
232
|
+
size="small"
|
|
233
|
+
loading={submitting}
|
|
234
|
+
disabled={submitting}
|
|
235
|
+
onClick={() => void handleSubmit()}
|
|
236
|
+
>
|
|
139
237
|
提交
|
|
140
238
|
</Button>
|
|
141
239
|
</Tooltip>
|
|
142
240
|
</div>
|
|
143
|
-
|
|
144
|
-
{hasRemainder ? (
|
|
145
|
-
<CurlSubmitBlock
|
|
146
|
-
payload={remainderPayload}
|
|
147
|
-
callbackUrl={callbackUrl}
|
|
148
|
-
title="以下字段不在表单中,请使用 curl 提交"
|
|
149
|
-
incompleteFormId={formUniqueId}
|
|
150
|
-
onNotify={onNotify}
|
|
151
|
-
/>
|
|
152
|
-
) : null}
|
|
153
241
|
</div>
|
|
154
242
|
)
|
|
155
243
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest'
|
|
5
|
+
import { MessageProcessor, type A2uiMessage } from '@a2ui/web_core/v0_9'
|
|
6
|
+
import type { ReactComponentImplementation } from '@a2ui/react/v0_9'
|
|
7
|
+
import { blocksToA2ui, surfaceDocToMessages } from '../adapter/a2uiAdapter'
|
|
8
|
+
import { buildSkoponCatalog, SKOPON_CATALOG_ID } from '../catalog/a2uiCustomCatalog'
|
|
9
|
+
|
|
10
|
+
function createProcessor(): MessageProcessor<ReactComponentImplementation> {
|
|
11
|
+
return new MessageProcessor([buildSkoponCatalog()])
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function buildStreamMessages(surfaceId: string, label = '姓名'): A2uiMessage[] {
|
|
15
|
+
const doc = blocksToA2ui(
|
|
16
|
+
{
|
|
17
|
+
title: '',
|
|
18
|
+
description: '',
|
|
19
|
+
blocks: [{ id: 'b-name', type: 'text', name: 'name', label }],
|
|
20
|
+
},
|
|
21
|
+
{ includeHeader: false },
|
|
22
|
+
)
|
|
23
|
+
return surfaceDocToMessages(doc, { surfaceId, catalogId: SKOPON_CATALOG_ID }) as unknown as A2uiMessage[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 与 SkoponA2uiStreamRenderer 在 messages 清空时的 reset 行为一致。 */
|
|
27
|
+
function resetStreamProcessor(): MessageProcessor<ReactComponentImplementation> {
|
|
28
|
+
return createProcessor()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('SkoponA2uiStreamRenderer stream lifecycle', () => {
|
|
32
|
+
it('processes incremental messages into surfaces', () => {
|
|
33
|
+
const surfaceId = 'stream-test'
|
|
34
|
+
const messages = buildStreamMessages(surfaceId)
|
|
35
|
+
const processor = createProcessor()
|
|
36
|
+
processor.processMessages(messages)
|
|
37
|
+
expect(processor.model.surfacesMap.size).toBeGreaterThan(0)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('clears surfaces when stream messages are reset to empty', () => {
|
|
41
|
+
const surfaceId = 'stream-clear'
|
|
42
|
+
const messages = buildStreamMessages(surfaceId)
|
|
43
|
+
let processor = createProcessor()
|
|
44
|
+
processor.processMessages(messages)
|
|
45
|
+
expect(processor.model.surfacesMap.size).toBeGreaterThan(0)
|
|
46
|
+
|
|
47
|
+
processor = resetStreamProcessor()
|
|
48
|
+
expect(processor.model.surfacesMap.size).toBe(0)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('rebuilds processor when message count shrinks', () => {
|
|
52
|
+
const surfaceId = 'stream-shrink'
|
|
53
|
+
const messages = buildStreamMessages(surfaceId)
|
|
54
|
+
const processor = createProcessor()
|
|
55
|
+
processor.processMessages(messages)
|
|
56
|
+
const countAfterFull = processor.model.surfacesMap.size
|
|
57
|
+
|
|
58
|
+
const rebuilt = createProcessor()
|
|
59
|
+
rebuilt.processMessages(messages.slice(0, 1))
|
|
60
|
+
expect(rebuilt.model.surfacesMap.size).toBeLessThanOrEqual(countAfterFull)
|
|
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
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'
|
|
3
|
+
|
|
4
|
+
vi.mock('@a2ui/web_core/v0_9/basic_catalog', async (importOriginal) => {
|
|
5
|
+
const actual = await importOriginal<typeof import('@a2ui/web_core/v0_9/basic_catalog')>()
|
|
6
|
+
return { ...actual, injectBasicCatalogStyles: vi.fn() }
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
vi.mock('@a2ui/react/styles', () => ({
|
|
10
|
+
injectStyles: vi.fn(),
|
|
11
|
+
removeStyles: vi.fn(),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
import { createElement } from 'react'
|
|
15
|
+
import { render, cleanup } from '@testing-library/react'
|
|
16
|
+
import type { A2uiMessage } from '@a2ui/web_core/v0_9'
|
|
17
|
+
import { blocksToA2ui, surfaceDocToMessages } from '../adapter/a2uiAdapter'
|
|
18
|
+
import { SKOPON_CATALOG_ID } from '../catalog/a2uiCustomCatalog'
|
|
19
|
+
import SkoponA2uiStreamRenderer from './SkoponA2uiStreamRenderer'
|
|
20
|
+
|
|
21
|
+
function buildStreamMessages(surfaceId: string, label = '姓名'): A2uiMessage[] {
|
|
22
|
+
const doc = blocksToA2ui(
|
|
23
|
+
{
|
|
24
|
+
title: '',
|
|
25
|
+
description: '',
|
|
26
|
+
blocks: [{ id: 'b-name', type: 'text', name: 'name', label }],
|
|
27
|
+
},
|
|
28
|
+
{ includeHeader: false },
|
|
29
|
+
)
|
|
30
|
+
return surfaceDocToMessages(doc, { surfaceId, catalogId: SKOPON_CATALOG_ID }) as unknown as A2uiMessage[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
beforeAll(() => {
|
|
34
|
+
class ResizeObserverMock {
|
|
35
|
+
observe() {}
|
|
36
|
+
unobserve() {}
|
|
37
|
+
disconnect() {}
|
|
38
|
+
}
|
|
39
|
+
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('SkoponA2uiStreamRenderer component', () => {
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
cleanup()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('mounts with empty messages without infinite update loop', () => {
|
|
48
|
+
expect(() =>
|
|
49
|
+
render(
|
|
50
|
+
createElement(SkoponA2uiStreamRenderer, {
|
|
51
|
+
messages: [],
|
|
52
|
+
emptyHint: createElement('span', null, 'stream-empty'),
|
|
53
|
+
}),
|
|
54
|
+
),
|
|
55
|
+
).not.toThrow()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('reprocesses messages when surfaceId changes', () => {
|
|
59
|
+
const surfaceA = 'stream-a'
|
|
60
|
+
const surfaceB = 'stream-b'
|
|
61
|
+
const messages = buildStreamMessages(surfaceA)
|
|
62
|
+
const { rerender, getByText, queryByText } = render(
|
|
63
|
+
createElement(SkoponA2uiStreamRenderer, {
|
|
64
|
+
messages,
|
|
65
|
+
surfaceId: surfaceA,
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
expect(getByText('姓名')).toBeTruthy()
|
|
69
|
+
|
|
70
|
+
rerender(
|
|
71
|
+
createElement(SkoponA2uiStreamRenderer, {
|
|
72
|
+
messages,
|
|
73
|
+
surfaceId: surfaceB,
|
|
74
|
+
}),
|
|
75
|
+
)
|
|
76
|
+
expect(getByText('姓名')).toBeTruthy()
|
|
77
|
+
expect(queryByText('stream-empty')).toBeNull()
|
|
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
|
+
})
|
|
103
|
+
})
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { useEffect,
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
2
|
import { MessageProcessor, type A2uiMessage } from '@a2ui/web_core/v0_9'
|
|
3
3
|
import { injectBasicCatalogStyles } from '@a2ui/web_core/v0_9/basic_catalog'
|
|
4
4
|
import { A2uiSurface, type ReactComponentImplementation } from '@a2ui/react/v0_9'
|
|
5
5
|
import { injectStyles } from '@a2ui/react/styles'
|
|
6
6
|
import { buildSkoponCatalog } from '../catalog/a2uiCustomCatalog'
|
|
7
7
|
import { A2uiPreviewModeProvider } from '../catalog/a2uiPreviewContext'
|
|
8
|
+
import { CaseSearchProvider, type CaseSearchFn } from '../catalog/caseSearchContext'
|
|
9
|
+
import { ResumeSearchProvider, type ResumeSearchFn } from '../catalog/resumeSearchContext'
|
|
8
10
|
|
|
9
11
|
export interface SkoponA2uiStreamRendererProps {
|
|
10
12
|
/** 增量追加的 A2UI v0.9 消息 */
|
|
@@ -12,6 +14,14 @@ export interface SkoponA2uiStreamRendererProps {
|
|
|
12
14
|
surfaceId?: string
|
|
13
15
|
emptyHint?: ReactNode
|
|
14
16
|
interactive?: boolean
|
|
17
|
+
/** 简历多选组件:注入搜索函数 */
|
|
18
|
+
resumeSearch?: ResumeSearchFn | null
|
|
19
|
+
/** 案例单选组件:注入搜索函数 */
|
|
20
|
+
caseSearch?: CaseSearchFn | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createProcessor(): MessageProcessor<ReactComponentImplementation> {
|
|
24
|
+
return new MessageProcessor([buildSkoponCatalog()])
|
|
15
25
|
}
|
|
16
26
|
|
|
17
27
|
export default function SkoponA2uiStreamRenderer({
|
|
@@ -19,26 +29,66 @@ export default function SkoponA2uiStreamRenderer({
|
|
|
19
29
|
surfaceId = 'skopon-form-stream',
|
|
20
30
|
emptyHint = null,
|
|
21
31
|
interactive = true,
|
|
32
|
+
resumeSearch = null,
|
|
33
|
+
caseSearch = null,
|
|
22
34
|
}: SkoponA2uiStreamRendererProps) {
|
|
23
35
|
useEffect(() => {
|
|
24
36
|
injectStyles()
|
|
25
37
|
injectBasicCatalogStyles()
|
|
26
38
|
}, [])
|
|
27
39
|
|
|
28
|
-
const processorRef = useRef<MessageProcessor<ReactComponentImplementation
|
|
40
|
+
const processorRef = useRef<MessageProcessor<ReactComponentImplementation>>(createProcessor())
|
|
29
41
|
const processedCountRef = useRef(0)
|
|
42
|
+
const lastProcessedMessagesRef = useRef('')
|
|
43
|
+
const mountedRef = useRef(true)
|
|
44
|
+
const surfaceIdRef = useRef(surfaceId)
|
|
45
|
+
|
|
46
|
+
function commitProcessor(
|
|
47
|
+
next: MessageProcessor<ReactComponentImplementation>,
|
|
48
|
+
processedMessages: A2uiMessage[],
|
|
49
|
+
) {
|
|
50
|
+
processorRef.current = next
|
|
51
|
+
processedCountRef.current = processedMessages.length
|
|
52
|
+
lastProcessedMessagesRef.current = JSON.stringify(processedMessages)
|
|
53
|
+
if (!mountedRef.current) return
|
|
54
|
+
setProcessor(next)
|
|
55
|
+
setSurfaces(Array.from(next.model.surfacesMap.values()))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function processedPrefixChanged(messages: A2uiMessage[], prefixLen: number): boolean {
|
|
59
|
+
if (prefixLen <= 0) return false
|
|
60
|
+
const newPrefix = JSON.stringify(messages.slice(0, prefixLen))
|
|
61
|
+
const stored = lastProcessedMessagesRef.current
|
|
62
|
+
if (!stored) return true
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(stored) as A2uiMessage[]
|
|
65
|
+
return JSON.stringify(parsed.slice(0, prefixLen)) !== newPrefix
|
|
66
|
+
} catch {
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const [processor, setProcessor] = useState(() => processorRef.current)
|
|
72
|
+
const [surfaces, setSurfaces] = useState(() =>
|
|
73
|
+
Array.from(processorRef.current.model.surfacesMap.values()),
|
|
74
|
+
)
|
|
30
75
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}, [
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
mountedRef.current = true
|
|
78
|
+
return () => {
|
|
79
|
+
mountedRef.current = false
|
|
80
|
+
}
|
|
81
|
+
}, [])
|
|
37
82
|
|
|
38
|
-
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
processorRef.current = processor
|
|
85
|
+
}, [processor])
|
|
39
86
|
|
|
40
87
|
useEffect(() => {
|
|
41
|
-
const sync = () =>
|
|
88
|
+
const sync = () => {
|
|
89
|
+
if (!mountedRef.current) return
|
|
90
|
+
setSurfaces(Array.from(processor.model.surfacesMap.values()))
|
|
91
|
+
}
|
|
42
92
|
const createdSub = processor.onSurfaceCreated(sync)
|
|
43
93
|
const deletedSub = processor.onSurfaceDeleted(sync)
|
|
44
94
|
return () => {
|
|
@@ -48,23 +98,91 @@ export default function SkoponA2uiStreamRenderer({
|
|
|
48
98
|
}, [processor])
|
|
49
99
|
|
|
50
100
|
useEffect(() => {
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
101
|
+
let cancelled = false
|
|
102
|
+
const surfaceIdChanged = surfaceIdRef.current !== surfaceId
|
|
103
|
+
surfaceIdRef.current = surfaceId
|
|
104
|
+
|
|
105
|
+
if (surfaceIdChanged) {
|
|
106
|
+
const next = createProcessor()
|
|
107
|
+
if (Array.isArray(messages) && messages.length > 0) {
|
|
108
|
+
next.processMessages(messages)
|
|
109
|
+
if (!cancelled) commitProcessor(next, messages)
|
|
110
|
+
} else {
|
|
111
|
+
processorRef.current = next
|
|
112
|
+
processedCountRef.current = 0
|
|
113
|
+
lastProcessedMessagesRef.current = ''
|
|
114
|
+
if (!cancelled) {
|
|
115
|
+
setProcessor(next)
|
|
116
|
+
setSurfaces([])
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return () => {
|
|
120
|
+
cancelled = true
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
125
|
+
if (processedCountRef.current > 0) {
|
|
126
|
+
const next = createProcessor()
|
|
127
|
+
processorRef.current = next
|
|
128
|
+
processedCountRef.current = 0
|
|
129
|
+
lastProcessedMessagesRef.current = ''
|
|
130
|
+
if (!cancelled) {
|
|
131
|
+
setProcessor(next)
|
|
132
|
+
setSurfaces([])
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return () => {
|
|
136
|
+
cancelled = true
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const activeProcessor = processorRef.current
|
|
141
|
+
const processedCount = processedCountRef.current
|
|
142
|
+
|
|
143
|
+
if (
|
|
144
|
+
messages.length < processedCount ||
|
|
145
|
+
processedPrefixChanged(messages, processedCount)
|
|
146
|
+
) {
|
|
147
|
+
const next = createProcessor()
|
|
148
|
+
next.processMessages(messages)
|
|
149
|
+
if (!cancelled) commitProcessor(next, messages)
|
|
150
|
+
return () => {
|
|
151
|
+
cancelled = true
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const pending = messages.slice(processedCount)
|
|
156
|
+
if (pending.length === 0) {
|
|
157
|
+
return () => {
|
|
158
|
+
cancelled = true
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
activeProcessor.processMessages(pending)
|
|
55
162
|
processedCountRef.current = messages.length
|
|
56
|
-
|
|
57
|
-
|
|
163
|
+
lastProcessedMessagesRef.current = JSON.stringify(messages)
|
|
164
|
+
if (!cancelled) {
|
|
165
|
+
setSurfaces(Array.from(activeProcessor.model.surfacesMap.values()))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return () => {
|
|
169
|
+
cancelled = true
|
|
170
|
+
}
|
|
171
|
+
}, [messages, surfaceId])
|
|
58
172
|
|
|
59
173
|
if (surfaces.length === 0) return <>{emptyHint}</>
|
|
60
174
|
|
|
61
175
|
return (
|
|
62
|
-
<
|
|
63
|
-
<
|
|
64
|
-
{
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
176
|
+
<ResumeSearchProvider resumeSearch={resumeSearch}>
|
|
177
|
+
<CaseSearchProvider caseSearch={caseSearch}>
|
|
178
|
+
<A2uiPreviewModeProvider interactive={interactive}>
|
|
179
|
+
<div className="a2ui-surface a2ui-container">
|
|
180
|
+
{surfaces.map((surface) => (
|
|
181
|
+
<A2uiSurface key={surface.id} surface={surface} />
|
|
182
|
+
))}
|
|
183
|
+
</div>
|
|
184
|
+
</A2uiPreviewModeProvider>
|
|
185
|
+
</CaseSearchProvider>
|
|
186
|
+
</ResumeSearchProvider>
|
|
69
187
|
)
|
|
70
188
|
}
|