@skopon-cool/form-sdk 0.6.1 → 0.6.3

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.
Files changed (54) hide show
  1. package/README.md +16 -0
  2. package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
  3. package/dist/adapter/formSchema.d.ts +3 -0
  4. package/dist/adapter/formSchema.d.ts.map +1 -1
  5. package/dist/blocks/resume_multiselect/adapter.d.ts.map +1 -1
  6. package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
  7. package/dist/catalog/contentSearchContext.d.ts +35 -0
  8. package/dist/catalog/contentSearchContext.d.ts.map +1 -0
  9. package/dist/catalog/pairResumeContent.d.ts +20 -0
  10. package/dist/catalog/pairResumeContent.d.ts.map +1 -0
  11. package/dist/catalog/skoponResumeSelect.d.ts.map +1 -1
  12. package/dist/client/contentSearchClient.d.ts +36 -0
  13. package/dist/client/contentSearchClient.d.ts.map +1 -0
  14. package/dist/client/resumeSearchClient.d.ts.map +1 -1
  15. package/dist/client/skoponSdkClients.d.ts +4 -0
  16. package/dist/client/skoponSdkClients.d.ts.map +1 -1
  17. package/dist/components/AskUserFormCard.d.ts +3 -0
  18. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  19. package/dist/components/SkoponA2uiStreamRenderer.d.ts +4 -1
  20. package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -1
  21. package/dist/components/SkoponFormRenderer.d.ts +3 -0
  22. package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
  23. package/dist/form-sdk.css +1 -1
  24. package/dist/index.d.ts +4 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +1855 -1538
  27. package/dist/types/index.d.ts +29 -1
  28. package/dist/types/index.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/adapter/a2uiAdapter.test.ts +12 -0
  31. package/src/adapter/a2uiAdapter.ts +8 -0
  32. package/src/adapter/formSchema.test.ts +8 -0
  33. package/src/adapter/formSchema.ts +55 -0
  34. package/src/blocks/resume_multiselect/adapter.ts +31 -0
  35. package/src/catalog/a2uiCustomCatalog.tsx +19 -0
  36. package/src/catalog/contentSearchContext.tsx +53 -0
  37. package/src/catalog/fileUploadPreview.test.tsx +17 -0
  38. package/src/catalog/pairResumeContent.test.ts +34 -0
  39. package/src/catalog/pairResumeContent.ts +55 -0
  40. package/src/catalog/skoponResumeSelect.tsx +348 -153
  41. package/src/catalog/skoponSelectLoading.test.tsx +84 -5
  42. package/src/client/contentSearchClient.test.ts +123 -0
  43. package/src/client/contentSearchClient.ts +156 -0
  44. package/src/client/resumeSearchClient.test.ts +5 -0
  45. package/src/client/resumeSearchClient.ts +5 -0
  46. package/src/client/skoponSdkClients.ts +9 -0
  47. package/src/components/AskUserFormCard.tsx +5 -0
  48. package/src/components/SkoponA2uiStreamRenderer.tsx +15 -9
  49. package/src/components/SkoponFormRenderer.tsx +20 -12
  50. package/src/index.ts +16 -0
  51. package/src/styles/a2ui-preview.css +4 -4
  52. package/src/styles/index.css +158 -13
  53. package/src/submit/submit.test.ts +1 -0
  54. package/src/types/index.ts +30 -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('加载简历…')).toBeTruthy()
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('加载简历…')).toBeNull()
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
- <CaseSearchProvider caseSearch={caseSearch}>
189
- <A2uiPreviewModeProvider interactive={interactive}>
190
- <div className="a2ui-surface a2ui-container">
191
- {surfaces.map((surface) => (
192
- <A2uiSurface key={surface.id} surface={surface} />
193
- ))}
194
- </div>
195
- </A2uiPreviewModeProvider>
196
- </CaseSearchProvider>
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
- <CaseSearchProvider caseSearch={caseSearch}>
132
- <A2uiPreviewModeProvider interactive={interactive}>{emptyHint}</A2uiPreviewModeProvider>
133
- </CaseSearchProvider>
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
- <CaseSearchProvider caseSearch={caseSearch}>
144
- <A2uiPreviewModeProvider interactive={interactive}>
145
- <div className="a2ui-surface a2ui-container">
146
- {surfaces.map((surface) => (
147
- <A2uiSurface key={surface.id} surface={surface} />
148
- ))}
149
- </div>
150
- </A2uiPreviewModeProvider>
151
- </CaseSearchProvider>
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,