@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.
- package/README.md +30 -2
- 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/authStorage.d.ts +9 -0
- package/dist/client/authStorage.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/client/uploadClient.d.ts +1 -1
- package/dist/client/uploadClient.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 +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1963 -1625
- 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/authStorage.test.ts +67 -1
- package/src/client/authStorage.ts +67 -0
- 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/client/uploadClient.test.ts +41 -2
- package/src/client/uploadClient.ts +7 -3
- 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 +19 -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
|
})
|
|
@@ -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,
|