@skopon-cool/form-sdk 0.6.0 → 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.
Files changed (56) hide show
  1. package/README.md +30 -2
  2. package/dist/adapter/formSchema.d.ts +3 -0
  3. package/dist/adapter/formSchema.d.ts.map +1 -1
  4. package/dist/blocks/resume_multiselect/adapter.d.ts.map +1 -1
  5. package/dist/catalog/contentSearchContext.d.ts +35 -0
  6. package/dist/catalog/contentSearchContext.d.ts.map +1 -0
  7. package/dist/catalog/pairResumeContent.d.ts +20 -0
  8. package/dist/catalog/pairResumeContent.d.ts.map +1 -0
  9. package/dist/catalog/skoponResumeSelect.d.ts.map +1 -1
  10. package/dist/client/authStorage.d.ts +9 -0
  11. package/dist/client/authStorage.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/client/uploadClient.d.ts +1 -1
  18. package/dist/client/uploadClient.d.ts.map +1 -1
  19. package/dist/components/AskUserFormCard.d.ts +3 -0
  20. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  21. package/dist/components/SkoponA2uiStreamRenderer.d.ts +4 -1
  22. package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -1
  23. package/dist/components/SkoponFormRenderer.d.ts +3 -0
  24. package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
  25. package/dist/form-sdk.css +1 -1
  26. package/dist/index.d.ts +5 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1963 -1625
  29. package/dist/types/index.d.ts +25 -1
  30. package/dist/types/index.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/src/adapter/formSchema.test.ts +8 -0
  33. package/src/adapter/formSchema.ts +49 -0
  34. package/src/blocks/resume_multiselect/adapter.ts +31 -0
  35. package/src/catalog/contentSearchContext.tsx +53 -0
  36. package/src/catalog/pairResumeContent.test.ts +34 -0
  37. package/src/catalog/pairResumeContent.ts +55 -0
  38. package/src/catalog/skoponResumeSelect.tsx +348 -153
  39. package/src/catalog/skoponSelectLoading.test.tsx +84 -5
  40. package/src/client/authStorage.test.ts +67 -1
  41. package/src/client/authStorage.ts +67 -0
  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/client/uploadClient.test.ts +41 -2
  48. package/src/client/uploadClient.ts +7 -3
  49. package/src/components/AskUserFormCard.tsx +5 -0
  50. package/src/components/SkoponA2uiStreamRenderer.tsx +15 -9
  51. package/src/components/SkoponFormRenderer.tsx +20 -12
  52. package/src/index.ts +19 -0
  53. package/src/styles/a2ui-preview.css +4 -4
  54. package/src/styles/index.css +158 -13
  55. package/src/submit/submit.test.ts +1 -0
  56. 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('加载简历…')).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
  })
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * @vitest-environment happy-dom
3
3
  */
4
- import { afterEach, describe, expect, it } from 'vitest'
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
5
  import {
6
6
  DEFAULT_AUTH_ANSWER_AGENT_CALLBACK_URL_FIELD,
7
7
  DEFAULT_AUTH_REALTIME_WS_URL_FIELD,
8
+ DEFAULT_AUTH_SKOPON_API_BASE_URL_FIELD,
8
9
  DEFAULT_ANSWER_AGENT_CALLBACK_URL,
9
10
  DEFAULT_AUTH_STORAGE_KEY,
10
11
  resolveAnswerAgentCallbackUrl,
11
12
  resolveRealtimeWsUrl,
13
+ resolveSkoponApiBaseUrl,
12
14
  resolveSkoponAuthHeaders,
13
15
  resolveSubmitAuthHeaders,
14
16
  } from './authStorage'
@@ -17,6 +19,7 @@ describe('resolveSkoponAuthHeaders', () => {
17
19
  afterEach(() => {
18
20
  sessionStorage.clear()
19
21
  localStorage.clear()
22
+ vi.unstubAllGlobals()
20
23
  })
21
24
 
22
25
  it('reads Bearer token from sessionStorage auth_user by default', () => {
@@ -66,6 +69,69 @@ describe('resolveSkoponAuthHeaders', () => {
66
69
  })
67
70
  })
68
71
 
72
+ describe('resolveSkoponApiBaseUrl', () => {
73
+ afterEach(() => {
74
+ if (typeof sessionStorage !== 'undefined') sessionStorage.clear()
75
+ if (typeof localStorage !== 'undefined') localStorage.clear()
76
+ vi.unstubAllGlobals()
77
+ })
78
+
79
+ it('reads and normalizes skopon_api_base_url from auth_user', () => {
80
+ sessionStorage.setItem(
81
+ DEFAULT_AUTH_STORAGE_KEY,
82
+ JSON.stringify({
83
+ [DEFAULT_AUTH_SKOPON_API_BASE_URL_FIELD]:
84
+ ' https://admin-test.hibot.lol/api/skopon/api/v1/ ',
85
+ }),
86
+ )
87
+ expect(resolveSkoponApiBaseUrl()).toBe(
88
+ 'https://admin-test.hibot.lol/api/skopon/api/v1',
89
+ )
90
+ })
91
+
92
+ it('falls back to the current origin when session or field is missing', () => {
93
+ expect(resolveSkoponApiBaseUrl()).toBe(`${window.location.origin}/api/v1`)
94
+ sessionStorage.setItem(DEFAULT_AUTH_STORAGE_KEY, JSON.stringify({ token: 'tok' }))
95
+ expect(resolveSkoponApiBaseUrl()).toBe(`${window.location.origin}/api/v1`)
96
+ })
97
+
98
+ it('falls back to a relative API path outside the browser', () => {
99
+ vi.stubGlobal('window', undefined)
100
+ expect(resolveSkoponApiBaseUrl()).toBe('/api/v1')
101
+ })
102
+
103
+ it('falls back when browser storage is inaccessible', () => {
104
+ const origin = window.location.origin
105
+ vi.stubGlobal('window', {
106
+ location: { origin },
107
+ sessionStorage: {
108
+ getItem() {
109
+ throw new Error('blocked')
110
+ },
111
+ },
112
+ })
113
+ expect(resolveSkoponApiBaseUrl()).toBe(`${origin}/api/v1`)
114
+ })
115
+
116
+ it.each([
117
+ '/api/v1',
118
+ 'ftp://example.com/api/v1',
119
+ 'https://user:pass@example.com/api/v1',
120
+ 'https://example.com/api/v1?tenant=1',
121
+ 'https://example.com/api/v1#upload',
122
+ ])('rejects invalid configured base URL: %s', (configured) => {
123
+ sessionStorage.setItem(
124
+ DEFAULT_AUTH_STORAGE_KEY,
125
+ JSON.stringify({
126
+ [DEFAULT_AUTH_SKOPON_API_BASE_URL_FIELD]: configured,
127
+ }),
128
+ )
129
+ expect(() => resolveSkoponApiBaseUrl()).toThrow(
130
+ 'skopon_api_base_url 格式无效,无法上传文件',
131
+ )
132
+ })
133
+ })
134
+
69
135
  describe('resolveSubmitAuthHeaders', () => {
70
136
  afterEach(() => {
71
137
  sessionStorage.clear()
@@ -11,8 +11,10 @@ export const DEFAULT_AUTH_STORAGE_KEY = 'auth_user'
11
11
  export const DEFAULT_AUTH_TOKEN_FIELD = 'token'
12
12
  export const DEFAULT_AUTH_ANSWER_AGENT_CALLBACK_URL_FIELD = 'answer_agent_callback_url'
13
13
  export const DEFAULT_AUTH_REALTIME_WS_URL_FIELD = 'realtime_ws_url'
14
+ export const DEFAULT_AUTH_SKOPON_API_BASE_URL_FIELD = 'skopon_api_base_url'
14
15
  /** 与 skopon-admin 登录写入的 mock answer-agent 路径一致 */
15
16
  export const DEFAULT_ANSWER_AGENT_CALLBACK_URL = '/api/v1/mock/ask_user_answer_mock'
17
+ export const DEFAULT_SKOPON_API_BASE_PATH = '/api/v1'
16
18
 
17
19
  function readStorage(
18
20
  kind: 'sessionStorage' | 'localStorage',
@@ -35,6 +37,71 @@ function readSessionJson(raw: string): Record<string, unknown> | null {
35
37
  }
36
38
  }
37
39
 
40
+ function defaultSkoponApiBaseUrl(): string {
41
+ if (typeof window === 'undefined') return DEFAULT_SKOPON_API_BASE_PATH
42
+ try {
43
+ const origin = window.location.origin
44
+ const parsed = new URL(origin)
45
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
46
+ return DEFAULT_SKOPON_API_BASE_PATH
47
+ }
48
+ return `${parsed.origin}${DEFAULT_SKOPON_API_BASE_PATH}`
49
+ } catch {
50
+ return DEFAULT_SKOPON_API_BASE_PATH
51
+ }
52
+ }
53
+
54
+ /**
55
+ * 从宿主登录缓存读取 Skopon API 根地址。
56
+ * 缺失时回退当前 Origin 的 /api/v1;非空配置必须是无凭据、Query、Hash 的绝对 HTTP(S) URL。
57
+ */
58
+ export function resolveSkoponApiBaseUrl(
59
+ options: SkoponAuthStorageOptions & {
60
+ skoponApiBaseUrlField?: string
61
+ } = {},
62
+ ): string {
63
+ const storageKind = options.storage ?? 'sessionStorage'
64
+ const storageKey = options.storageKey ?? DEFAULT_AUTH_STORAGE_KEY
65
+ const field =
66
+ options.skoponApiBaseUrlField ?? DEFAULT_AUTH_SKOPON_API_BASE_URL_FIELD
67
+
68
+ const storage = readStorage(storageKind)
69
+ let raw: string | null = null
70
+ try {
71
+ raw = storage?.getItem(storageKey) ?? null
72
+ } catch {
73
+ return defaultSkoponApiBaseUrl()
74
+ }
75
+ const parsedSession = raw ? readSessionJson(raw) : null
76
+ const configured = parsedSession?.[field]
77
+ if (configured === undefined || configured === null) {
78
+ return defaultSkoponApiBaseUrl()
79
+ }
80
+ if (typeof configured !== 'string') {
81
+ throw new Error('skopon_api_base_url 格式无效,无法上传文件')
82
+ }
83
+
84
+ const trimmed = configured.trim()
85
+ if (!trimmed) return defaultSkoponApiBaseUrl()
86
+
87
+ try {
88
+ const parsed = new URL(trimmed)
89
+ if (
90
+ (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
91
+ parsed.username ||
92
+ parsed.password ||
93
+ parsed.search ||
94
+ parsed.hash
95
+ ) {
96
+ throw new Error('invalid Skopon API base URL')
97
+ }
98
+ const pathname = parsed.pathname.replace(/\/+$/, '')
99
+ return `${parsed.origin}${pathname}`
100
+ } catch {
101
+ throw new Error('skopon_api_base_url 格式无效,无法上传文件')
102
+ }
103
+ }
104
+
38
105
  function readTokenFromSessionJson(
39
106
  raw: string,
40
107
  tokenField: string,
@@ -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,