@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
|
@@ -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
|
+
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest'
|
|
5
|
+
import type { ComponentContext } from '@a2ui/web_core/v0_9'
|
|
6
|
+
import { asStringArray, readBoundFieldValue } from './useSkoponBoundField'
|
|
7
|
+
|
|
8
|
+
function createMockContext(initial: unknown = []): {
|
|
9
|
+
context: ComponentContext
|
|
10
|
+
getStore: () => unknown
|
|
11
|
+
} {
|
|
12
|
+
const store = new Map<string, unknown>([['/files', initial]])
|
|
13
|
+
const binding = { path: '/files' }
|
|
14
|
+
const context = {
|
|
15
|
+
componentModel: {
|
|
16
|
+
properties: { value: binding },
|
|
17
|
+
},
|
|
18
|
+
dataContext: {
|
|
19
|
+
resolveDynamicValue: (b: typeof binding) => store.get(b.path),
|
|
20
|
+
set: (path: string, value: unknown) => {
|
|
21
|
+
store.set(path, value)
|
|
22
|
+
},
|
|
23
|
+
subscribeDynamicValue: () => ({ unsubscribe: () => {} }),
|
|
24
|
+
},
|
|
25
|
+
} as unknown as ComponentContext
|
|
26
|
+
return {
|
|
27
|
+
context,
|
|
28
|
+
getStore: () => store.get('/files'),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('readBoundFieldValue', () => {
|
|
33
|
+
it('reads current bound value from dataContext', () => {
|
|
34
|
+
const { context } = createMockContext(['a.pdf'])
|
|
35
|
+
expect(asStringArray(readBoundFieldValue(context))).toEqual(['a.pdf'])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('supports sequential appends without stale reads', () => {
|
|
39
|
+
const { context, getStore } = createMockContext([])
|
|
40
|
+
const append = (fileName: string) => {
|
|
41
|
+
const current = asStringArray(readBoundFieldValue(context))
|
|
42
|
+
context.dataContext.set('/files', [...current, fileName])
|
|
43
|
+
}
|
|
44
|
+
append('first.pdf')
|
|
45
|
+
append('second.pdf')
|
|
46
|
+
expect(getStore()).toEqual(['first.pdf', 'second.pdf'])
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('file upload remove by uid index', () => {
|
|
51
|
+
it('removes only the file at the uid index when names duplicate', () => {
|
|
52
|
+
const currentNames = ['dup.txt', 'dup.txt', 'other.txt']
|
|
53
|
+
const uid = '1-dup.txt'
|
|
54
|
+
const indexMatch = /^(\d+)-/.exec(uid)
|
|
55
|
+
const index = indexMatch ? Number(indexMatch[1]) : -1
|
|
56
|
+
const next =
|
|
57
|
+
index >= 0 && index < currentNames.length
|
|
58
|
+
? currentNames.filter((_, i) => i !== index)
|
|
59
|
+
: currentNames.filter((name) => name !== 'dup.txt')
|
|
60
|
+
expect(next).toEqual(['dup.txt', 'other.txt'])
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -11,6 +11,15 @@ function bindingPath(value: unknown): string | null {
|
|
|
11
11
|
return null
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/** 从 A2UI 组件 context 读取当前绑定字段值(不订阅更新,适合事件回调内读最新值)。 */
|
|
15
|
+
export function readBoundFieldValue(context: ComponentContext, field = 'value'): unknown {
|
|
16
|
+
const props = context.componentModel.properties as Record<string, unknown>
|
|
17
|
+
const binding = props[field] as DynamicBinding | undefined
|
|
18
|
+
const path = bindingPath(binding)
|
|
19
|
+
if (!path || binding === undefined) return undefined
|
|
20
|
+
return context.dataContext.resolveDynamicValue(binding)
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
/** 将 skopon 预览控件绑定到 A2UI surface dataModel(与 basicCatalog binder 行为一致)。 */
|
|
15
24
|
export function useSkoponBoundField(context: ComponentContext, field = 'value') {
|
|
16
25
|
const props = context.componentModel.properties as Record<string, unknown>
|
|
@@ -31,7 +40,7 @@ export function useSkoponBoundField(context: ComponentContext, field = 'value')
|
|
|
31
40
|
return context.dataContext.resolveDynamicValue(binding)
|
|
32
41
|
}, [context, binding, path])
|
|
33
42
|
|
|
34
|
-
const value =
|
|
43
|
+
const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
35
44
|
|
|
36
45
|
const setValue = useCallback(
|
|
37
46
|
(next: unknown) => {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
5
|
+
import { createFormClient } from './formClient'
|
|
6
|
+
|
|
7
|
+
function mockResponse(
|
|
8
|
+
init: Partial<Response> & { json?: () => Promise<unknown>; text?: () => Promise<string> },
|
|
9
|
+
): Response {
|
|
10
|
+
return {
|
|
11
|
+
ok: init.ok ?? true,
|
|
12
|
+
status: init.status ?? 200,
|
|
13
|
+
headers: { get: () => 'application/json' },
|
|
14
|
+
json: init.json ?? (async () => ({})),
|
|
15
|
+
text: init.text ?? (async () => ''),
|
|
16
|
+
} as Response
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('createFormClient', () => {
|
|
20
|
+
it('fetchDetail returns mapped form definition on success', async () => {
|
|
21
|
+
const fetchMock = vi.fn(async () =>
|
|
22
|
+
mockResponse({
|
|
23
|
+
json: async () => ({
|
|
24
|
+
success: true,
|
|
25
|
+
data: {
|
|
26
|
+
form_id: 1,
|
|
27
|
+
form_unique_id: 'form-abc',
|
|
28
|
+
name: 'Test',
|
|
29
|
+
disabled: 0,
|
|
30
|
+
form_definition: {
|
|
31
|
+
title: 'T',
|
|
32
|
+
blocks: [{ id: 'b1', type: 'text', name: 'x', label: 'X' }],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
}),
|
|
37
|
+
) as unknown as typeof fetch
|
|
38
|
+
|
|
39
|
+
const client = createFormClient({ fetch: fetchMock, baseUrl: '/api/v1', detailPath: '/dev/form/detail' })
|
|
40
|
+
const result = await client.fetchDetail({ formUniqueId: 'form-abc' })
|
|
41
|
+
expect(result.formUniqueId).toBe('form-abc')
|
|
42
|
+
expect(result.formId).toBe(1)
|
|
43
|
+
expect(result.disabled).toBe(false)
|
|
44
|
+
expect(result.formDefinition?.blocks).toHaveLength(1)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('throws on non-JSON response', async () => {
|
|
48
|
+
const fetchMock = vi.fn(async () =>
|
|
49
|
+
mockResponse({
|
|
50
|
+
json: async () => {
|
|
51
|
+
throw new SyntaxError('Unexpected token')
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
) as unknown as typeof fetch
|
|
55
|
+
|
|
56
|
+
const client = createFormClient({ fetch: fetchMock })
|
|
57
|
+
await expect(client.fetchDetail({ formUniqueId: 'x' })).rejects.toThrow('响应非 JSON')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('throws when envelope data is missing', async () => {
|
|
61
|
+
const fetchMock = vi.fn(async () =>
|
|
62
|
+
mockResponse({
|
|
63
|
+
json: async () => ({ success: true, data: null }),
|
|
64
|
+
}),
|
|
65
|
+
) as unknown as typeof fetch
|
|
66
|
+
|
|
67
|
+
const client = createFormClient({ fetch: fetchMock })
|
|
68
|
+
await expect(client.fetchDetail({ formUniqueId: 'x' })).rejects.toThrow('响应缺少 data')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('throws when API returns success false', async () => {
|
|
72
|
+
const fetchMock = vi.fn(async () =>
|
|
73
|
+
mockResponse({
|
|
74
|
+
ok: false,
|
|
75
|
+
status: 404,
|
|
76
|
+
json: async () => ({ success: false, message: '表单不存在' }),
|
|
77
|
+
}),
|
|
78
|
+
) as unknown as typeof fetch
|
|
79
|
+
|
|
80
|
+
const client = createFormClient({ fetch: fetchMock })
|
|
81
|
+
await expect(client.fetchDetail({ formUniqueId: 'missing' })).rejects.toThrow('表单不存在')
|
|
82
|
+
})
|
|
83
|
+
})
|
package/src/client/formClient.ts
CHANGED
|
@@ -27,11 +27,19 @@ interface ApiEnvelope<T> {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
async function parseEnvelope<T>(response: Response): Promise<T> {
|
|
30
|
-
|
|
30
|
+
let json: ApiEnvelope<T>
|
|
31
|
+
try {
|
|
32
|
+
json = (await response.json()) as ApiEnvelope<T>
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error(`响应非 JSON (${response.status})`)
|
|
35
|
+
}
|
|
31
36
|
if (!response.ok || json.success === false) {
|
|
32
37
|
throw new Error(json.message || `请求失败 (${response.status})`)
|
|
33
38
|
}
|
|
34
|
-
|
|
39
|
+
if (json.data === undefined || json.data === null) {
|
|
40
|
+
throw new Error(json.message || `响应缺少 data (${response.status})`)
|
|
41
|
+
}
|
|
42
|
+
return json.data
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
export function createFormClient(options: FormClientOptions = {}): FormClient {
|