@skopon-cool/form-sdk 0.6.1 → 0.6.2
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 +16 -0
- package/dist/adapter/formSchema.d.ts +3 -0
- package/dist/adapter/formSchema.d.ts.map +1 -1
- package/dist/blocks/resume_multiselect/adapter.d.ts.map +1 -1
- package/dist/catalog/contentSearchContext.d.ts +35 -0
- package/dist/catalog/contentSearchContext.d.ts.map +1 -0
- package/dist/catalog/pairResumeContent.d.ts +20 -0
- package/dist/catalog/pairResumeContent.d.ts.map +1 -0
- package/dist/catalog/skoponResumeSelect.d.ts.map +1 -1
- package/dist/client/contentSearchClient.d.ts +36 -0
- package/dist/client/contentSearchClient.d.ts.map +1 -0
- package/dist/client/resumeSearchClient.d.ts.map +1 -1
- package/dist/client/skoponSdkClients.d.ts +4 -0
- package/dist/client/skoponSdkClients.d.ts.map +1 -1
- package/dist/components/AskUserFormCard.d.ts +3 -0
- package/dist/components/AskUserFormCard.d.ts.map +1 -1
- package/dist/components/SkoponA2uiStreamRenderer.d.ts +4 -1
- package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -1
- package/dist/components/SkoponFormRenderer.d.ts +3 -0
- package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
- package/dist/form-sdk.css +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1858 -1557
- package/dist/types/index.d.ts +25 -1
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapter/formSchema.test.ts +8 -0
- package/src/adapter/formSchema.ts +49 -0
- package/src/blocks/resume_multiselect/adapter.ts +31 -0
- package/src/catalog/contentSearchContext.tsx +53 -0
- package/src/catalog/pairResumeContent.test.ts +34 -0
- package/src/catalog/pairResumeContent.ts +55 -0
- package/src/catalog/skoponResumeSelect.tsx +348 -153
- package/src/catalog/skoponSelectLoading.test.tsx +84 -5
- package/src/client/contentSearchClient.test.ts +123 -0
- package/src/client/contentSearchClient.ts +156 -0
- package/src/client/resumeSearchClient.test.ts +5 -0
- package/src/client/resumeSearchClient.ts +5 -0
- package/src/client/skoponSdkClients.ts +9 -0
- package/src/components/AskUserFormCard.tsx +5 -0
- package/src/components/SkoponA2uiStreamRenderer.tsx +15 -9
- package/src/components/SkoponFormRenderer.tsx +20 -12
- package/src/index.ts +16 -0
- package/src/styles/a2ui-preview.css +4 -4
- package/src/styles/index.css +158 -13
- package/src/submit/submit.test.ts +1 -0
- package/src/types/index.ts +26 -1
|
@@ -11,11 +11,12 @@ vi.mock('@a2ui/react/styles', () => ({
|
|
|
11
11
|
removeStyles: vi.fn(),
|
|
12
12
|
}))
|
|
13
13
|
|
|
14
|
-
import { createElement } from 'react'
|
|
15
|
-
import { render, cleanup, waitFor } from '@testing-library/react'
|
|
14
|
+
import { createElement, createRef } from 'react'
|
|
15
|
+
import { render, cleanup, waitFor, fireEvent } from '@testing-library/react'
|
|
16
16
|
import { blocksToA2ui } from '../adapter/a2uiAdapter'
|
|
17
|
-
import SkoponFormRenderer from '../components/SkoponFormRenderer'
|
|
17
|
+
import SkoponFormRenderer, { type SkoponFormRendererRef } from '../components/SkoponFormRenderer'
|
|
18
18
|
import type { CaseSearchFn } from './caseSearchContext'
|
|
19
|
+
import type { ContentSearchFn } from './contentSearchContext'
|
|
19
20
|
import type { ResumeSearchFn } from './resumeSearchContext'
|
|
20
21
|
|
|
21
22
|
beforeAll(() => {
|
|
@@ -152,6 +153,9 @@ describe('case/resume select loading state', () => {
|
|
|
152
153
|
resolveSearch = resolve
|
|
153
154
|
}),
|
|
154
155
|
)
|
|
156
|
+
const contentSearch: ContentSearchFn = vi.fn(
|
|
157
|
+
() => new Promise(() => {}),
|
|
158
|
+
)
|
|
155
159
|
|
|
156
160
|
const doc = buildResumeSurface()
|
|
157
161
|
const { rerender, queryByText } = render(
|
|
@@ -159,24 +163,99 @@ describe('case/resume select loading state', () => {
|
|
|
159
163
|
doc,
|
|
160
164
|
surfaceId: 'resume-loading-test',
|
|
161
165
|
resumeSearch,
|
|
166
|
+
contentSearch,
|
|
162
167
|
}),
|
|
163
168
|
)
|
|
164
169
|
|
|
165
|
-
expect(queryByText('
|
|
170
|
+
expect(queryByText('加载简历和内容…')).toBeTruthy()
|
|
166
171
|
|
|
167
172
|
rerender(
|
|
168
173
|
createElement(SkoponFormRenderer, {
|
|
169
174
|
doc,
|
|
170
175
|
surfaceId: 'resume-loading-test',
|
|
171
176
|
resumeSearch: null,
|
|
177
|
+
contentSearch,
|
|
172
178
|
}),
|
|
173
179
|
)
|
|
174
180
|
|
|
175
181
|
await waitFor(() => {
|
|
176
|
-
expect(queryByText('
|
|
182
|
+
expect(queryByText('加载简历和内容…')).toBeNull()
|
|
177
183
|
})
|
|
178
184
|
expect(queryByText('未配置简历搜索服务')).toBeTruthy()
|
|
179
185
|
|
|
180
186
|
resolveSearch({ list: [], total: 0, page: 1, pageSize: 20 })
|
|
181
187
|
})
|
|
188
|
+
|
|
189
|
+
it('renders content tag labels on each resume-content card', async () => {
|
|
190
|
+
const resumeSearch: ResumeSearchFn = vi.fn(async () => ({
|
|
191
|
+
list: [{ resumeUniqueId: 'resume-1', name: '零食方案负责人' }],
|
|
192
|
+
total: 1,
|
|
193
|
+
page: 1,
|
|
194
|
+
pageSize: 20,
|
|
195
|
+
}))
|
|
196
|
+
const contentSearch: ContentSearchFn = vi.fn(async () => ({
|
|
197
|
+
list: [
|
|
198
|
+
{
|
|
199
|
+
flowUniqueId: 'flow-1',
|
|
200
|
+
name: '零食开箱流程',
|
|
201
|
+
title: '零食开箱测评',
|
|
202
|
+
tagLabels: ['1万–9.9万粉', '直播带货'],
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
total: 1,
|
|
206
|
+
page: 1,
|
|
207
|
+
pageSize: 20,
|
|
208
|
+
}))
|
|
209
|
+
|
|
210
|
+
const { findByText } = render(
|
|
211
|
+
createElement(SkoponFormRenderer, {
|
|
212
|
+
doc: buildResumeSurface(),
|
|
213
|
+
surfaceId: 'resume-content-tags-test',
|
|
214
|
+
resumeSearch,
|
|
215
|
+
contentSearch,
|
|
216
|
+
}),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
expect(await findByText('内容标签')).toBeTruthy()
|
|
220
|
+
expect(await findByText('1万–9.9万粉')).toBeTruthy()
|
|
221
|
+
expect(await findByText('直播带货')).toBeTruthy()
|
|
222
|
+
})
|
|
223
|
+
it('selects content without loading profiles, retains selections across pages and enforces max', async () => {
|
|
224
|
+
const resumeSearch = vi.fn(async () => { throw new Error('must not load profiles') })
|
|
225
|
+
const contentSearch: ContentSearchFn = vi.fn(async ({ page }) => ({
|
|
226
|
+
list: [1, 2].map(i => ({ flowUniqueId: `flow-${(page-1)*2+i}`, name: `内容${(page-1)*2+i}`, title: `内容${(page-1)*2+i}` })),
|
|
227
|
+
total: 4, page, pageSize: 2,
|
|
228
|
+
}))
|
|
229
|
+
const doc = blocksToA2ui({ title: '', description: '', blocks: [{
|
|
230
|
+
id: 'content', type: 'resume_multiselect', name: 'flows',
|
|
231
|
+
resumeDisplayMode: 'content-only', resumeDisplayCount: 2, resumeMaxCount: 2,
|
|
232
|
+
contentFilter: { pageSize: 2 },
|
|
233
|
+
}] }, { includeHeader: false })
|
|
234
|
+
const ref = createRef<SkoponFormRendererRef>()
|
|
235
|
+
const view = render(createElement(SkoponFormRenderer, { ref, doc, resumeSearch, contentSearch, fieldNames: ['flows'] }))
|
|
236
|
+
const one = await view.findByText('内容1')
|
|
237
|
+
expect(view.container.querySelector('.skopon-resume-select-card-person')).toBeNull()
|
|
238
|
+
expect(resumeSearch).not.toHaveBeenCalled()
|
|
239
|
+
fireEvent.click(one.closest('[role="button"]')!)
|
|
240
|
+
fireEvent.click(view.getByRole('button', { name: /换一批/ }))
|
|
241
|
+
const three = await view.findByText('内容3')
|
|
242
|
+
fireEvent.click(three.closest('[role="button"]')!)
|
|
243
|
+
expect(ref.current!.getValues().flows).toEqual(['flow-1', 'flow-3'])
|
|
244
|
+
expect(view.getByText('内容4').closest('[role="button"]')!.getAttribute('aria-disabled')).toBe('true')
|
|
245
|
+
fireEvent.click(view.getByRole('button', { name: /换一批/ }))
|
|
246
|
+
const back = await view.findByText('内容1')
|
|
247
|
+
expect(back.closest('[role="button"]')!.getAttribute('aria-pressed')).toBe('true')
|
|
248
|
+
fireEvent.click(back.closest('[role="button"]')!)
|
|
249
|
+
expect(ref.current!.getValues().flows).toEqual(['flow-3'])
|
|
250
|
+
expect(contentSearch).toHaveBeenCalledTimes(2)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('renders content-only mode when no resume service is provided', async () => {
|
|
254
|
+
const contentSearch: ContentSearchFn = async () => ({list:[{flowUniqueId:'f',name:'内容',title:'独立内容'}],total:1,page:1,pageSize:20})
|
|
255
|
+
const doc=blocksToA2ui({title:'',description:'',blocks:[{id:'c',type:'resume_multiselect',name:'flows',resumeDisplayMode:'content-only'}]}, {includeHeader:false})
|
|
256
|
+
const view=render(createElement(SkoponFormRenderer,{doc,contentSearch}))
|
|
257
|
+
expect(await view.findByText('独立内容')).toBeTruthy()
|
|
258
|
+
expect(view.queryByText('未配置简历搜索服务')).toBeNull()
|
|
259
|
+
})
|
|
260
|
+
|
|
182
261
|
})
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { createContentSearchClient } from './contentSearchClient'
|
|
6
|
+
|
|
7
|
+
function mockResponse(json: unknown): Response {
|
|
8
|
+
return {
|
|
9
|
+
ok: true,
|
|
10
|
+
status: 200,
|
|
11
|
+
headers: { get: () => 'application/json' },
|
|
12
|
+
json: async () => json,
|
|
13
|
+
text: async () => '',
|
|
14
|
+
} as Response
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('createContentSearchClient', () => {
|
|
18
|
+
it('searches published content flows and maps the latest case', async () => {
|
|
19
|
+
const fetchMock = vi.fn(async () =>
|
|
20
|
+
mockResponse({
|
|
21
|
+
success: true,
|
|
22
|
+
data: {
|
|
23
|
+
list: [
|
|
24
|
+
{
|
|
25
|
+
flow_id: 7,
|
|
26
|
+
flow_unique_id: 'flow-1',
|
|
27
|
+
name: '口播流程',
|
|
28
|
+
tag_labels: ['1万–9.9万粉', '直播带货'],
|
|
29
|
+
cases: [
|
|
30
|
+
{
|
|
31
|
+
name: '爆款口播',
|
|
32
|
+
platform: '抖音',
|
|
33
|
+
link: 'https://example/work',
|
|
34
|
+
attachments_json: [
|
|
35
|
+
{ type: 'image', url: 'https://cdn/cover.png' },
|
|
36
|
+
{ type: 'video', url: 'https://cdn/clip.mp4' },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
total: 40,
|
|
43
|
+
page: 1,
|
|
44
|
+
page_size: 20,
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
) as unknown as typeof fetch
|
|
48
|
+
|
|
49
|
+
const contentSearch = createContentSearchClient({
|
|
50
|
+
fetch: fetchMock,
|
|
51
|
+
baseUrl: '/api/v1',
|
|
52
|
+
getHeaders: async () => ({ Authorization: 'Bearer test' }),
|
|
53
|
+
})
|
|
54
|
+
const result = await contentSearch({
|
|
55
|
+
filter: { flowType: 'content-production', tagUniqueIds: ['tag-hot'], pageSize: 20 },
|
|
56
|
+
page: 1,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
|
60
|
+
expect(url).toContain('/api/v1/univ/flow/list?')
|
|
61
|
+
expect(url).toContain('owner_scope=content')
|
|
62
|
+
expect(url).toContain('flow_type=content-production')
|
|
63
|
+
expect(url).toContain('published=1')
|
|
64
|
+
expect(url).toContain('tag_unique_ids=tag-hot')
|
|
65
|
+
expect(url).toContain('tag_match=all')
|
|
66
|
+
expect(init.headers).toEqual({ Authorization: 'Bearer test' })
|
|
67
|
+
expect(result.list[0]).toMatchObject({
|
|
68
|
+
flowUniqueId: 'flow-1',
|
|
69
|
+
title: '爆款口播',
|
|
70
|
+
coverUrl: 'https://cdn/cover.png',
|
|
71
|
+
mediaUrl: 'https://cdn/clip.mp4',
|
|
72
|
+
mediaKind: 'video',
|
|
73
|
+
tagLabels: ['1万–9.9万粉', '直播带货'],
|
|
74
|
+
})
|
|
75
|
+
expect(result.total).toBe(40)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('uses one normally ordered server request for multi-tag any results', async () => {
|
|
79
|
+
const fetchMock = vi.fn(async (input: string | URL | Request) => {
|
|
80
|
+
const url = new URL(String(input), 'https://example.test')
|
|
81
|
+
return mockResponse({
|
|
82
|
+
success: true,
|
|
83
|
+
data: {
|
|
84
|
+
list: [
|
|
85
|
+
{ flow_unique_id: 'flow-zero-1', name: '零粉一', tag_labels: ['0粉冷启动'], cases: [] },
|
|
86
|
+
{ flow_unique_id: 'flow-999-1', name: '千粉内一', tag_labels: ['1–999粉'], cases: [] },
|
|
87
|
+
{ flow_unique_id: 'flow-10k-1', name: '万粉一', tag_labels: ['1万–9.9万粉'], cases: [] },
|
|
88
|
+
],
|
|
89
|
+
total: 5,
|
|
90
|
+
page: Number(url.searchParams.get('page')),
|
|
91
|
+
page_size: Number(url.searchParams.get('page_size')),
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
}) as unknown as typeof fetch
|
|
95
|
+
const contentSearch = createContentSearchClient({ fetch: fetchMock, baseUrl: '/api/v1' })
|
|
96
|
+
|
|
97
|
+
const result = await contentSearch({
|
|
98
|
+
filter: {
|
|
99
|
+
tagUniqueIds: ['tag-fans-zero', 'tag-fans-1-999', 'tag-fans-10k-99k'],
|
|
100
|
+
tagMatch: 'any',
|
|
101
|
+
pageSize: 5,
|
|
102
|
+
},
|
|
103
|
+
page: 1,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
107
|
+
const [url] = fetchMock.mock.calls[0] as [string]
|
|
108
|
+
const params = new URL(url, 'https://example.test').searchParams
|
|
109
|
+
expect(params.getAll('tag_unique_ids')).toEqual([
|
|
110
|
+
'tag-fans-zero',
|
|
111
|
+
'tag-fans-1-999',
|
|
112
|
+
'tag-fans-10k-99k',
|
|
113
|
+
])
|
|
114
|
+
expect(params.get('tag_match')).toBe('any')
|
|
115
|
+
expect(params.get('page_size')).toBe('5')
|
|
116
|
+
expect(result.list.map((item) => item.flowUniqueId)).toEqual([
|
|
117
|
+
'flow-zero-1',
|
|
118
|
+
'flow-999-1',
|
|
119
|
+
'flow-10k-1',
|
|
120
|
+
])
|
|
121
|
+
expect(result.total).toBe(5)
|
|
122
|
+
})
|
|
123
|
+
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { FormContentFilter } from '../types/index'
|
|
2
|
+
import type { ContentSearchFn, ContentSearchItem } from '../catalog/contentSearchContext'
|
|
3
|
+
import { parseEnvelope } from './apiEnvelope'
|
|
4
|
+
import {
|
|
5
|
+
type ClientHeadersOptions,
|
|
6
|
+
resolveClientHeaders,
|
|
7
|
+
} from './authStorage'
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_CONTENT_FLOW_TYPE = 'content-production'
|
|
10
|
+
|
|
11
|
+
export interface ContentSearchClientOptions extends ClientHeadersOptions {
|
|
12
|
+
baseUrl?: string
|
|
13
|
+
listPath?: string
|
|
14
|
+
fetch?: typeof fetch
|
|
15
|
+
/** 默认 1(已发布) */
|
|
16
|
+
published?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ContentAttachmentDto {
|
|
20
|
+
type?: string
|
|
21
|
+
url?: string
|
|
22
|
+
dataUrl?: string
|
|
23
|
+
data_url?: string
|
|
24
|
+
mime_type?: string
|
|
25
|
+
mimeType?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ContentCaseDto {
|
|
29
|
+
name?: string
|
|
30
|
+
link?: string
|
|
31
|
+
platform?: string | null
|
|
32
|
+
attachments_json?: ContentAttachmentDto[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ContentFlowDto {
|
|
36
|
+
flow_id?: number
|
|
37
|
+
flow_unique_id: string
|
|
38
|
+
name?: string
|
|
39
|
+
description?: string | null
|
|
40
|
+
tag_labels?: string[]
|
|
41
|
+
cases?: ContentCaseDto[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface PagedListDto<T> {
|
|
45
|
+
list: T[]
|
|
46
|
+
total: number
|
|
47
|
+
page: number
|
|
48
|
+
page_size: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function attachmentRows(attachments: ContentAttachmentDto[] | undefined) {
|
|
52
|
+
return (attachments ?? [])
|
|
53
|
+
.map((row) => {
|
|
54
|
+
const mime = (row.mime_type ?? row.mimeType ?? '').trim().toLowerCase()
|
|
55
|
+
const declared = (row.type ?? '').trim().toLowerCase()
|
|
56
|
+
const type = declared || (mime.startsWith('video/') ? 'video' : mime.startsWith('image/') ? 'image' : '')
|
|
57
|
+
const url = (row.url ?? row.dataUrl ?? row.data_url ?? '').trim()
|
|
58
|
+
return { type, mime, url }
|
|
59
|
+
})
|
|
60
|
+
.filter((row) => row.url)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isVideo(row: { type: string; mime: string }): boolean {
|
|
64
|
+
return row.type === 'video' || row.type === '视频' || row.mime.startsWith('video/')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isImage(row: { type: string; mime: string }): boolean {
|
|
68
|
+
return row.type === 'image' || row.type === '图片' || row.mime.startsWith('image/') || row.type === ''
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function mediaFromAttachments(attachments: ContentAttachmentDto[] | undefined): {
|
|
72
|
+
coverUrl: string | null
|
|
73
|
+
mediaUrl: string | null
|
|
74
|
+
mediaKind: 'video' | 'image' | null
|
|
75
|
+
} {
|
|
76
|
+
const rows = attachmentRows(attachments)
|
|
77
|
+
const video = rows.find(isVideo)
|
|
78
|
+
const image = rows.find(isImage)
|
|
79
|
+
if (video) {
|
|
80
|
+
return { coverUrl: image?.url ?? null, mediaUrl: video.url, mediaKind: 'video' }
|
|
81
|
+
}
|
|
82
|
+
if (image) {
|
|
83
|
+
return { coverUrl: image.url, mediaUrl: null, mediaKind: 'image' }
|
|
84
|
+
}
|
|
85
|
+
return { coverUrl: null, mediaUrl: null, mediaKind: null }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function mapContentFlow(dto: ContentFlowDto): ContentSearchItem {
|
|
89
|
+
const flowName = dto.name?.trim() || '未命名内容'
|
|
90
|
+
const latest = dto.cases?.[0]
|
|
91
|
+
const caseName = latest?.name?.trim()
|
|
92
|
+
const media = mediaFromAttachments(latest?.attachments_json)
|
|
93
|
+
return {
|
|
94
|
+
flowUniqueId: dto.flow_unique_id,
|
|
95
|
+
flowId: dto.flow_id,
|
|
96
|
+
name: flowName,
|
|
97
|
+
description: dto.description ?? undefined,
|
|
98
|
+
tagLabels: (dto.tag_labels ?? []).map(String).map((item) => item.trim()).filter(Boolean),
|
|
99
|
+
title: caseName || flowName,
|
|
100
|
+
platform: latest?.platform ?? null,
|
|
101
|
+
coverUrl: media.coverUrl,
|
|
102
|
+
mediaUrl: media.mediaUrl,
|
|
103
|
+
mediaKind: media.mediaKind,
|
|
104
|
+
link: latest?.link,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildContentListQuery(
|
|
109
|
+
filter: FormContentFilter,
|
|
110
|
+
page: number,
|
|
111
|
+
published: number,
|
|
112
|
+
): URLSearchParams {
|
|
113
|
+
const params = new URLSearchParams()
|
|
114
|
+
params.set('page', String(page))
|
|
115
|
+
params.set('page_size', String(filter.pageSize ?? 20))
|
|
116
|
+
params.set('owner_scope', 'content')
|
|
117
|
+
params.set('published', String(published))
|
|
118
|
+
params.set('flow_type', (filter.flowType ?? DEFAULT_CONTENT_FLOW_TYPE).trim() || DEFAULT_CONTENT_FLOW_TYPE)
|
|
119
|
+
if (filter.category?.trim()) params.set('category_unique_id', filter.category.trim())
|
|
120
|
+
for (const id of filter.tagUniqueIds ?? []) {
|
|
121
|
+
if (id) params.append('tag_unique_ids', id)
|
|
122
|
+
}
|
|
123
|
+
params.set('tag_match', filter.tagMatch === 'any' ? 'any' : 'all')
|
|
124
|
+
return params
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function createContentSearchClient(
|
|
128
|
+
options: ContentSearchClientOptions = {},
|
|
129
|
+
): ContentSearchFn {
|
|
130
|
+
const baseUrl = (options.baseUrl ?? '/api/v1').replace(/\/$/, '')
|
|
131
|
+
const listPath = options.listPath ?? '/univ/flow/list'
|
|
132
|
+
const fetchFn = options.fetch ?? fetch
|
|
133
|
+
const published = options.published ?? 1
|
|
134
|
+
|
|
135
|
+
return async ({ filter, page }) => {
|
|
136
|
+
const headers = await resolveClientHeaders(options)
|
|
137
|
+
const requestPage = async (requestFilter: FormContentFilter) => {
|
|
138
|
+
const params = buildContentListQuery(requestFilter, page, published)
|
|
139
|
+
const response = await fetchFn(`${baseUrl}${listPath}?${params.toString()}`, {
|
|
140
|
+
method: 'GET',
|
|
141
|
+
headers,
|
|
142
|
+
})
|
|
143
|
+
return parseEnvelope<PagedListDto<ContentFlowDto>>(response)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 多标签 any 查询由服务端一次完成,并沿用 Flow 的正常排序与分页。
|
|
147
|
+
// 不要在客户端按标签拆请求,否则一个 9 标签分组每次换批会放大成 9 次请求。
|
|
148
|
+
const data = await requestPage(filter)
|
|
149
|
+
return {
|
|
150
|
+
list: (data.list ?? []).map(mapContentFlow),
|
|
151
|
+
total: data.total ?? 0,
|
|
152
|
+
page: data.page ?? page,
|
|
153
|
+
pageSize: data.page_size ?? filter.pageSize ?? 20,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -56,7 +56,9 @@ describe('createResumeSearchClient', () => {
|
|
|
56
56
|
const result = await resumeSearch({
|
|
57
57
|
filter: {
|
|
58
58
|
names: ['导演'],
|
|
59
|
+
agentKind: 'director',
|
|
59
60
|
agentUniqueIds: ['agent-1'],
|
|
61
|
+
tagUniqueIds: ['tag-a', 'tag-b'],
|
|
60
62
|
resumeUniqueIds: ['resume-abc'],
|
|
61
63
|
pageSize: 20,
|
|
62
64
|
},
|
|
@@ -68,6 +70,9 @@ describe('createResumeSearchClient', () => {
|
|
|
68
70
|
expect(url).toContain('/api/v1/univ/resume/search?')
|
|
69
71
|
expect(url).toContain('name=%E5%AF%BC%E6%BC%94')
|
|
70
72
|
expect(url).toContain('agent_unique_id=agent-1')
|
|
73
|
+
expect(url).toContain('agent_type=director')
|
|
74
|
+
expect(url).toContain('tag_unique_ids=tag-a')
|
|
75
|
+
expect(url).toContain('tag_unique_ids=tag-b')
|
|
71
76
|
expect(url).toContain('resume_unique_id=resume-abc')
|
|
72
77
|
expect(url).toContain('published=1')
|
|
73
78
|
expect(init.headers).toEqual({ Authorization: 'Bearer test' })
|
|
@@ -80,6 +80,11 @@ function buildResumeSearchQuery(
|
|
|
80
80
|
for (const id of filter.agentUniqueIds ?? []) {
|
|
81
81
|
if (id) params.append('agent_unique_id', id)
|
|
82
82
|
}
|
|
83
|
+
const agentType = filter.agentKind?.trim()
|
|
84
|
+
if (agentType) params.append('agent_type', agentType)
|
|
85
|
+
for (const id of filter.tagUniqueIds ?? []) {
|
|
86
|
+
if (id) params.append('tag_unique_ids', id)
|
|
87
|
+
}
|
|
83
88
|
for (const id of filter.resumeUniqueIds ?? []) {
|
|
84
89
|
if (id) params.append('resume_unique_id', id)
|
|
85
90
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createCaseSearchClient, type CaseSearchClientOptions } from './caseSearchClient'
|
|
2
|
+
import { createContentSearchClient, type ContentSearchClientOptions } from './contentSearchClient'
|
|
2
3
|
import { createFormClient, type FormClient, type FormClientOptions } from './formClient'
|
|
3
4
|
import { createResumeSearchClient, type ResumeSearchClientOptions } from './resumeSearchClient'
|
|
4
5
|
import type { CaseSearchFn } from '../catalog/caseSearchContext'
|
|
6
|
+
import type { ContentSearchFn } from '../catalog/contentSearchContext'
|
|
5
7
|
import type { ResumeSearchFn } from '../catalog/resumeSearchContext'
|
|
6
8
|
import type { SkoponAuthStorageOptions } from './authStorage'
|
|
7
9
|
|
|
@@ -12,12 +14,14 @@ export interface SkoponSdkClientsOptions {
|
|
|
12
14
|
authStorage?: SkoponAuthStorageOptions
|
|
13
15
|
form?: Pick<FormClientOptions, 'detailPath'>
|
|
14
16
|
resume?: Pick<ResumeSearchClientOptions, 'searchPath' | 'published'>
|
|
17
|
+
content?: Pick<ContentSearchClientOptions, 'listPath' | 'published'>
|
|
15
18
|
case?: Pick<CaseSearchClientOptions, 'listPath' | 'published'>
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
export interface SkoponSdkClients {
|
|
19
22
|
fetchDetail: FormClient['fetchDetail']
|
|
20
23
|
resumeSearch: ResumeSearchFn
|
|
24
|
+
contentSearch: ContentSearchFn
|
|
21
25
|
caseSearch: CaseSearchFn
|
|
22
26
|
}
|
|
23
27
|
|
|
@@ -43,6 +47,11 @@ export function createSkoponSdkClients(
|
|
|
43
47
|
searchPath: options.resume?.searchPath,
|
|
44
48
|
published: options.resume?.published,
|
|
45
49
|
}),
|
|
50
|
+
contentSearch: createContentSearchClient({
|
|
51
|
+
...shared,
|
|
52
|
+
listPath: options.content?.listPath,
|
|
53
|
+
published: options.content?.published,
|
|
54
|
+
}),
|
|
46
55
|
caseSearch: createCaseSearchClient({
|
|
47
56
|
...shared,
|
|
48
57
|
listPath: options.case?.listPath,
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from '../submit/submitAnswerAgent'
|
|
28
28
|
import { validateFormSubmitValues } from '../submit/validateFormSubmit'
|
|
29
29
|
import type { CaseSearchFn } from '../catalog/caseSearchContext'
|
|
30
|
+
import type { ContentSearchFn } from '../catalog/contentSearchContext'
|
|
30
31
|
import type { ResumeSearchFn } from '../catalog/resumeSearchContext'
|
|
31
32
|
import { FormRuntimeProvider, useFormRuntime } from '../runtime/formRuntimeContext'
|
|
32
33
|
import { FormRuntimeWsClient } from '../runtime/formRuntimeWs'
|
|
@@ -75,6 +76,8 @@ export interface AskUserFormCardProps {
|
|
|
75
76
|
onNotify?: (type: 'success' | 'error', message: string) => void
|
|
76
77
|
/** 简历多选组件:注入搜索函数 */
|
|
77
78
|
resumeSearch?: ResumeSearchFn | null
|
|
79
|
+
/** 简历多选组件:内容制作搜索 */
|
|
80
|
+
contentSearch?: ContentSearchFn | null
|
|
78
81
|
/** 案例单选组件:注入搜索函数 */
|
|
79
82
|
caseSearch?: CaseSearchFn | null
|
|
80
83
|
}
|
|
@@ -95,6 +98,7 @@ function AskUserFormCardBody({
|
|
|
95
98
|
fetchFormDetail,
|
|
96
99
|
onNotify,
|
|
97
100
|
resumeSearch = null,
|
|
101
|
+
contentSearch = null,
|
|
98
102
|
caseSearch = null,
|
|
99
103
|
onSurfaceReadyChange,
|
|
100
104
|
onAutoControlReady,
|
|
@@ -491,6 +495,7 @@ function AskUserFormCardBody({
|
|
|
491
495
|
interactive
|
|
492
496
|
wrapProvider={false}
|
|
493
497
|
resumeSearch={resumeSearch}
|
|
498
|
+
contentSearch={contentSearch}
|
|
494
499
|
caseSearch={caseSearch}
|
|
495
500
|
onUploadingChange={setUploading}
|
|
496
501
|
/>
|
|
@@ -7,6 +7,7 @@ import { buildSkoponCatalog } from '../catalog/a2uiCustomCatalog'
|
|
|
7
7
|
import { A2uiPreviewModeProvider } from '../catalog/a2uiPreviewContext'
|
|
8
8
|
import { FileUploadStatusProvider } from '../catalog/fileUploadStatusContext'
|
|
9
9
|
import { CaseSearchProvider, type CaseSearchFn } from '../catalog/caseSearchContext'
|
|
10
|
+
import { ContentSearchProvider, type ContentSearchFn } from '../catalog/contentSearchContext'
|
|
10
11
|
import { ResumeSearchProvider, type ResumeSearchFn } from '../catalog/resumeSearchContext'
|
|
11
12
|
import SkoponFormProvider from './SkoponFormProvider'
|
|
12
13
|
|
|
@@ -18,6 +19,8 @@ export interface SkoponA2uiStreamRendererProps {
|
|
|
18
19
|
interactive?: boolean
|
|
19
20
|
/** 简历多选组件:注入搜索函数 */
|
|
20
21
|
resumeSearch?: ResumeSearchFn | null
|
|
22
|
+
/** 简历多选组件:内容制作搜索 */
|
|
23
|
+
contentSearch?: ContentSearchFn | null
|
|
21
24
|
/** 案例单选组件:注入搜索函数 */
|
|
22
25
|
caseSearch?: CaseSearchFn | null
|
|
23
26
|
}
|
|
@@ -32,6 +35,7 @@ export default function SkoponA2uiStreamRenderer({
|
|
|
32
35
|
emptyHint = null,
|
|
33
36
|
interactive = true,
|
|
34
37
|
resumeSearch = null,
|
|
38
|
+
contentSearch = null,
|
|
35
39
|
caseSearch = null,
|
|
36
40
|
}: SkoponA2uiStreamRendererProps) {
|
|
37
41
|
useEffect(() => {
|
|
@@ -185,15 +189,17 @@ export default function SkoponA2uiStreamRenderer({
|
|
|
185
189
|
<SkoponFormProvider>
|
|
186
190
|
<FileUploadStatusProvider>
|
|
187
191
|
<ResumeSearchProvider resumeSearch={resumeSearch}>
|
|
188
|
-
<
|
|
189
|
-
<
|
|
190
|
-
<
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
192
|
+
<ContentSearchProvider contentSearch={contentSearch}>
|
|
193
|
+
<CaseSearchProvider caseSearch={caseSearch}>
|
|
194
|
+
<A2uiPreviewModeProvider interactive={interactive}>
|
|
195
|
+
<div className="a2ui-surface a2ui-container">
|
|
196
|
+
{surfaces.map((surface) => (
|
|
197
|
+
<A2uiSurface key={surface.id} surface={surface} />
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
</A2uiPreviewModeProvider>
|
|
201
|
+
</CaseSearchProvider>
|
|
202
|
+
</ContentSearchProvider>
|
|
197
203
|
</ResumeSearchProvider>
|
|
198
204
|
</FileUploadStatusProvider>
|
|
199
205
|
</SkoponFormProvider>
|
|
@@ -17,6 +17,7 @@ import { SKOPON_CATALOG_ID, buildSkoponCatalog } from '../catalog/a2uiCustomCata
|
|
|
17
17
|
import { A2uiPreviewModeProvider } from '../catalog/a2uiPreviewContext'
|
|
18
18
|
import { FileUploadStatusProvider } from '../catalog/fileUploadStatusContext'
|
|
19
19
|
import { CaseSearchProvider, type CaseSearchFn } from '../catalog/caseSearchContext'
|
|
20
|
+
import { ContentSearchProvider, type ContentSearchFn } from '../catalog/contentSearchContext'
|
|
20
21
|
import { ResumeSearchProvider, type ResumeSearchFn } from '../catalog/resumeSearchContext'
|
|
21
22
|
import SkoponFormProvider from './SkoponFormProvider'
|
|
22
23
|
|
|
@@ -34,6 +35,8 @@ export interface SkoponFormRendererProps {
|
|
|
34
35
|
fieldNames?: string[]
|
|
35
36
|
/** 简历多选组件:注入搜索函数 */
|
|
36
37
|
resumeSearch?: ResumeSearchFn | null
|
|
38
|
+
/** 简历多选组件:内容制作搜索 */
|
|
39
|
+
contentSearch?: ContentSearchFn | null
|
|
37
40
|
/** 案例单选组件:注入搜索函数 */
|
|
38
41
|
caseSearch?: CaseSearchFn | null
|
|
39
42
|
/** 任意文件上传任务的聚合状态 */
|
|
@@ -51,6 +54,7 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
|
|
|
51
54
|
interactive = true,
|
|
52
55
|
fieldNames = [],
|
|
53
56
|
resumeSearch = null,
|
|
57
|
+
contentSearch = null,
|
|
54
58
|
caseSearch = null,
|
|
55
59
|
onUploadingChange,
|
|
56
60
|
wrapProvider = true,
|
|
@@ -128,9 +132,11 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
|
|
|
128
132
|
const empty = (
|
|
129
133
|
<FileUploadStatusProvider onUploadingChange={onUploadingChange}>
|
|
130
134
|
<ResumeSearchProvider resumeSearch={resumeSearch}>
|
|
131
|
-
<
|
|
132
|
-
<
|
|
133
|
-
|
|
135
|
+
<ContentSearchProvider contentSearch={contentSearch}>
|
|
136
|
+
<CaseSearchProvider caseSearch={caseSearch}>
|
|
137
|
+
<A2uiPreviewModeProvider interactive={interactive}>{emptyHint}</A2uiPreviewModeProvider>
|
|
138
|
+
</CaseSearchProvider>
|
|
139
|
+
</ContentSearchProvider>
|
|
134
140
|
</ResumeSearchProvider>
|
|
135
141
|
</FileUploadStatusProvider>
|
|
136
142
|
)
|
|
@@ -140,15 +146,17 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
|
|
|
140
146
|
const content = (
|
|
141
147
|
<FileUploadStatusProvider onUploadingChange={onUploadingChange}>
|
|
142
148
|
<ResumeSearchProvider resumeSearch={resumeSearch}>
|
|
143
|
-
<
|
|
144
|
-
<
|
|
145
|
-
<
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
149
|
+
<ContentSearchProvider contentSearch={contentSearch}>
|
|
150
|
+
<CaseSearchProvider caseSearch={caseSearch}>
|
|
151
|
+
<A2uiPreviewModeProvider interactive={interactive}>
|
|
152
|
+
<div className="a2ui-surface a2ui-container">
|
|
153
|
+
{surfaces.map((surface) => (
|
|
154
|
+
<A2uiSurface key={surface.id} surface={surface} />
|
|
155
|
+
))}
|
|
156
|
+
</div>
|
|
157
|
+
</A2uiPreviewModeProvider>
|
|
158
|
+
</CaseSearchProvider>
|
|
159
|
+
</ContentSearchProvider>
|
|
152
160
|
</ResumeSearchProvider>
|
|
153
161
|
</FileUploadStatusProvider>
|
|
154
162
|
)
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export type {
|
|
|
8
8
|
FormBlockOption,
|
|
9
9
|
FormBlockType,
|
|
10
10
|
FormCaseFilter,
|
|
11
|
+
FormContentFilter,
|
|
11
12
|
FormConditionOperator,
|
|
12
13
|
FormDefinitionPayload,
|
|
13
14
|
FormDetailResult,
|
|
@@ -124,6 +125,11 @@ export {
|
|
|
124
125
|
createResumeSearchClient,
|
|
125
126
|
type ResumeSearchClientOptions,
|
|
126
127
|
} from './client/resumeSearchClient'
|
|
128
|
+
export {
|
|
129
|
+
createContentSearchClient,
|
|
130
|
+
DEFAULT_CONTENT_FLOW_TYPE,
|
|
131
|
+
type ContentSearchClientOptions,
|
|
132
|
+
} from './client/contentSearchClient'
|
|
127
133
|
export {
|
|
128
134
|
createCaseSearchClient,
|
|
129
135
|
type CaseSearchClientOptions,
|
|
@@ -137,6 +143,7 @@ export {
|
|
|
137
143
|
export { default as SkoponFormRenderer } from './components/SkoponFormRenderer'
|
|
138
144
|
export type { SkoponFormRendererProps, SkoponFormRendererRef } from './components/SkoponFormRenderer'
|
|
139
145
|
export type { ResumeSearchFn as SkoponResumeSearchFn } from './catalog/resumeSearchContext'
|
|
146
|
+
export type { ContentSearchFn as SkoponContentSearchFn } from './catalog/contentSearchContext'
|
|
140
147
|
export type { CaseSearchFn as SkoponCaseSearchFn } from './catalog/caseSearchContext'
|
|
141
148
|
|
|
142
149
|
export { default as SkoponA2uiStreamRenderer } from './components/SkoponA2uiStreamRenderer'
|
|
@@ -155,6 +162,15 @@ export { antdTheme as skoponAntdTheme } from './theme/antd-theme'
|
|
|
155
162
|
export { brandColors as skoponBrandColors } from './theme/brand-colors'
|
|
156
163
|
|
|
157
164
|
|
|
165
|
+
export {
|
|
166
|
+
ContentSearchProvider,
|
|
167
|
+
useContentSearch,
|
|
168
|
+
type ContentSearchFn,
|
|
169
|
+
type ContentSearchItem,
|
|
170
|
+
type ContentSearchParams,
|
|
171
|
+
type ContentSearchResult,
|
|
172
|
+
} from './catalog/contentSearchContext'
|
|
173
|
+
|
|
158
174
|
export {
|
|
159
175
|
ResumeSearchProvider,
|
|
160
176
|
useResumeSearch,
|