@skopon-cool/form-sdk 0.2.0 → 0.2.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 +96 -9
- package/dist/adapter/a2uiAdapter.d.ts +1 -0
- package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
- package/dist/adapter/formSchema.d.ts +2 -1
- package/dist/adapter/formSchema.d.ts.map +1 -1
- package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
- package/dist/catalog/skoponCaseSelect.d.ts.map +1 -1
- package/dist/catalog/skoponResumeSelect.d.ts.map +1 -1
- package/dist/catalog/useSkoponBoundField.d.ts +2 -0
- package/dist/catalog/useSkoponBoundField.d.ts.map +1 -1
- package/dist/client/apiEnvelope.d.ts +7 -0
- package/dist/client/apiEnvelope.d.ts.map +1 -0
- package/dist/client/authStorage.d.ts +19 -0
- package/dist/client/authStorage.d.ts.map +1 -0
- package/dist/client/caseSearchClient.d.ts +11 -0
- package/dist/client/caseSearchClient.d.ts.map +1 -0
- package/dist/client/formClient.d.ts +2 -2
- package/dist/client/formClient.d.ts.map +1 -1
- package/dist/client/resumeSearchClient.d.ts +9 -0
- package/dist/client/resumeSearchClient.d.ts.map +1 -0
- package/dist/client/skoponSdkClients.d.ts +22 -0
- package/dist/client/skoponSdkClients.d.ts.map +1 -0
- package/dist/components/AskUserFormCard.d.ts.map +1 -1
- package/dist/components/SkoponFormRenderer.d.ts +2 -0
- package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
- package/dist/form-sdk.css +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1097 -826
- package/dist/submit/validateFormSubmit.d.ts +10 -0
- package/dist/submit/validateFormSubmit.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/adapter/a2uiAdapter.ts +3 -4
- package/src/adapter/formSchema.test.ts +29 -1
- package/src/adapter/formSchema.ts +12 -3
- package/src/adapter/resolveSurface.ts +3 -3
- package/src/catalog/a2uiCustomCatalog.tsx +49 -3
- package/src/catalog/skoponCaseSelect.tsx +2 -1
- package/src/catalog/skoponResumeSelect.tsx +1 -0
- package/src/catalog/skoponSelectLoading.test.tsx +182 -0
- package/src/catalog/textFieldPreview.test.tsx +31 -0
- package/src/catalog/useSkoponBoundField.test.ts +14 -1
- package/src/catalog/useSkoponBoundField.ts +14 -0
- package/src/client/apiEnvelope.ts +21 -0
- package/src/client/authStorage.test.ts +61 -0
- package/src/client/authStorage.ts +70 -0
- package/src/client/caseSearchClient.ts +107 -0
- package/src/client/formClient.test.ts +25 -0
- package/src/client/formClient.ts +7 -25
- package/src/client/resumeSearchClient.test.ts +87 -0
- package/src/client/resumeSearchClient.ts +108 -0
- package/src/client/skoponSdkClients.ts +51 -0
- package/src/components/AskUserFormCard.tsx +7 -0
- package/src/components/SkoponFormRenderer.tsx +18 -6
- package/src/index.ts +21 -0
- package/src/styles/index.css +3 -3
- package/src/submit/submit.test.ts +40 -0
- package/src/submit/validateFormSubmit.ts +111 -0
- package/dist/catalog/textFieldPreview.test.d.ts +0 -2
- package/dist/catalog/textFieldPreview.test.d.ts.map +0 -1
- package/dist/components/SkoponA2uiStreamRenderer.test.d.ts +0 -2
- package/dist/components/SkoponA2uiStreamRenderer.test.d.ts.map +0 -1
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export interface SkoponAuthStorageOptions {
|
|
2
|
+
/** 默认 sessionStorage(与 skopon-admin / HiAUTH 约定一致) */
|
|
3
|
+
storage?: 'sessionStorage' | 'localStorage'
|
|
4
|
+
/** 默认 auth_user */
|
|
5
|
+
storageKey?: string
|
|
6
|
+
/** JSON 内 token 字段名,默认 token */
|
|
7
|
+
tokenField?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_AUTH_STORAGE_KEY = 'auth_user'
|
|
11
|
+
export const DEFAULT_AUTH_TOKEN_FIELD = 'token'
|
|
12
|
+
|
|
13
|
+
function readStorage(
|
|
14
|
+
kind: 'sessionStorage' | 'localStorage',
|
|
15
|
+
): Storage | null {
|
|
16
|
+
if (typeof window === 'undefined') return null
|
|
17
|
+
try {
|
|
18
|
+
return kind === 'localStorage' ? window.localStorage : window.sessionStorage
|
|
19
|
+
} catch {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readTokenFromSessionJson(
|
|
25
|
+
raw: string,
|
|
26
|
+
tokenField: string,
|
|
27
|
+
): string {
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>
|
|
30
|
+
const token = parsed[tokenField]
|
|
31
|
+
return typeof token === 'string'
|
|
32
|
+
? token.trim().replace(/^bearer\s+/i, '').trim()
|
|
33
|
+
: ''
|
|
34
|
+
} catch {
|
|
35
|
+
return ''
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 从宿主登录缓存读取 Bearer token,返回 Authorization 头(无 token 时返回空对象)。 */
|
|
40
|
+
export function resolveSkoponAuthHeaders(
|
|
41
|
+
options: SkoponAuthStorageOptions = {},
|
|
42
|
+
): Record<string, string> {
|
|
43
|
+
const storageKind = options.storage ?? 'sessionStorage'
|
|
44
|
+
const storageKey = options.storageKey ?? DEFAULT_AUTH_STORAGE_KEY
|
|
45
|
+
const tokenField = options.tokenField ?? DEFAULT_AUTH_TOKEN_FIELD
|
|
46
|
+
|
|
47
|
+
const storage = readStorage(storageKind)
|
|
48
|
+
if (!storage) return {}
|
|
49
|
+
|
|
50
|
+
const raw = storage.getItem(storageKey)
|
|
51
|
+
if (!raw) return {}
|
|
52
|
+
|
|
53
|
+
const token = readTokenFromSessionJson(raw, tokenField)
|
|
54
|
+
return token ? { Authorization: `Bearer ${token}` } : {}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ClientHeadersOptions {
|
|
58
|
+
getHeaders?: () => Record<string, string> | Promise<Record<string, string>>
|
|
59
|
+
authStorage?: SkoponAuthStorageOptions
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 显式 getHeaders 优先;否则从本地登录缓存读取。 */
|
|
63
|
+
export async function resolveClientHeaders(
|
|
64
|
+
options: ClientHeadersOptions = {},
|
|
65
|
+
): Promise<Record<string, string>> {
|
|
66
|
+
if (options.getHeaders) {
|
|
67
|
+
return (await options.getHeaders()) ?? {}
|
|
68
|
+
}
|
|
69
|
+
return resolveSkoponAuthHeaders(options.authStorage)
|
|
70
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { FormCaseFilter } from '../types/index'
|
|
2
|
+
import type { CaseSearchFn, CaseSearchItem } from '../catalog/caseSearchContext'
|
|
3
|
+
import { parseEnvelope } from './apiEnvelope'
|
|
4
|
+
import {
|
|
5
|
+
type ClientHeadersOptions,
|
|
6
|
+
resolveClientHeaders,
|
|
7
|
+
} from './authStorage'
|
|
8
|
+
|
|
9
|
+
export interface CaseSearchClientOptions extends ClientHeadersOptions {
|
|
10
|
+
baseUrl?: string
|
|
11
|
+
listPath?: string
|
|
12
|
+
fetch?: typeof fetch
|
|
13
|
+
/** 默认 1(已发布) */
|
|
14
|
+
published?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface CaseListItemDto {
|
|
18
|
+
case_id: number
|
|
19
|
+
flow_id: number
|
|
20
|
+
name: string
|
|
21
|
+
link: string
|
|
22
|
+
platform?: string | null
|
|
23
|
+
description?: string | null
|
|
24
|
+
case_unique_id: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface PagedListDto<T> {
|
|
28
|
+
list: T[]
|
|
29
|
+
total: number
|
|
30
|
+
page: number
|
|
31
|
+
page_size: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isCaseFilterReady(filter: FormCaseFilter): boolean {
|
|
35
|
+
const hasFlowIds = Boolean(filter.flowUniqueIds?.length)
|
|
36
|
+
const hasCategory = Boolean(
|
|
37
|
+
filter.agentUniqueId?.trim()
|
|
38
|
+
&& filter.flowType?.trim()
|
|
39
|
+
&& filter.category?.trim(),
|
|
40
|
+
)
|
|
41
|
+
return hasFlowIds || hasCategory
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function mapCaseListItem(dto: CaseListItemDto): CaseSearchItem {
|
|
45
|
+
return {
|
|
46
|
+
caseUniqueId: dto.case_unique_id,
|
|
47
|
+
caseId: dto.case_id,
|
|
48
|
+
flowId: dto.flow_id,
|
|
49
|
+
name: dto.name,
|
|
50
|
+
link: dto.link,
|
|
51
|
+
platform: dto.platform ?? null,
|
|
52
|
+
description: dto.description ?? undefined,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildCaseListQuery(
|
|
57
|
+
filter: FormCaseFilter,
|
|
58
|
+
page: number,
|
|
59
|
+
published: number,
|
|
60
|
+
): URLSearchParams {
|
|
61
|
+
const params = new URLSearchParams()
|
|
62
|
+
params.set('page', String(page))
|
|
63
|
+
params.set('page_size', String(filter.pageSize ?? 20))
|
|
64
|
+
params.set('published', String(published))
|
|
65
|
+
if (filter.agentUniqueId?.trim()) {
|
|
66
|
+
params.set('agent_unique_id', filter.agentUniqueId.trim())
|
|
67
|
+
}
|
|
68
|
+
if (filter.flowType?.trim()) {
|
|
69
|
+
params.set('flow_type', filter.flowType.trim())
|
|
70
|
+
}
|
|
71
|
+
if (filter.category?.trim()) {
|
|
72
|
+
params.set('category_unique_id', filter.category.trim())
|
|
73
|
+
}
|
|
74
|
+
for (const flowId of filter.flowUniqueIds ?? []) {
|
|
75
|
+
if (flowId) params.append('flow_unique_ids', flowId)
|
|
76
|
+
}
|
|
77
|
+
return params
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createCaseSearchClient(
|
|
81
|
+
options: CaseSearchClientOptions = {},
|
|
82
|
+
): CaseSearchFn {
|
|
83
|
+
const baseUrl = (options.baseUrl ?? '/api/v1').replace(/\/$/, '')
|
|
84
|
+
const listPath = options.listPath ?? '/univ/case/list'
|
|
85
|
+
const fetchFn = options.fetch ?? fetch
|
|
86
|
+
const published = options.published ?? 1
|
|
87
|
+
|
|
88
|
+
return async ({ filter, page }) => {
|
|
89
|
+
if (!isCaseFilterReady(filter)) {
|
|
90
|
+
return { list: [], total: 0, page: 1, pageSize: filter.pageSize ?? 20 }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const headers = await resolveClientHeaders(options)
|
|
94
|
+
const params = buildCaseListQuery(filter, page, published)
|
|
95
|
+
const response = await fetchFn(`${baseUrl}${listPath}?${params.toString()}`, {
|
|
96
|
+
method: 'GET',
|
|
97
|
+
headers,
|
|
98
|
+
})
|
|
99
|
+
const data = await parseEnvelope<PagedListDto<CaseListItemDto>>(response)
|
|
100
|
+
return {
|
|
101
|
+
list: (data.list ?? []).map(mapCaseListItem),
|
|
102
|
+
total: data.total ?? 0,
|
|
103
|
+
page: data.page ?? page,
|
|
104
|
+
pageSize: data.page_size ?? filter.pageSize ?? 20,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -80,4 +80,29 @@ describe('createFormClient', () => {
|
|
|
80
80
|
const client = createFormClient({ fetch: fetchMock })
|
|
81
81
|
await expect(client.fetchDetail({ formUniqueId: 'missing' })).rejects.toThrow('表单不存在')
|
|
82
82
|
})
|
|
83
|
+
|
|
84
|
+
it('uses getHeaders when provided', async () => {
|
|
85
|
+
const fetchMock = vi.fn(async () =>
|
|
86
|
+
mockResponse({
|
|
87
|
+
json: async () => ({
|
|
88
|
+
success: true,
|
|
89
|
+
data: {
|
|
90
|
+
form_id: 1,
|
|
91
|
+
form_unique_id: 'form-abc',
|
|
92
|
+
name: 'Test',
|
|
93
|
+
disabled: 0,
|
|
94
|
+
form_definition: { title: 'T', blocks: [] },
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
}),
|
|
98
|
+
) as unknown as typeof fetch
|
|
99
|
+
|
|
100
|
+
const client = createFormClient({
|
|
101
|
+
fetch: fetchMock,
|
|
102
|
+
getHeaders: async () => ({ Authorization: 'Bearer custom' }),
|
|
103
|
+
})
|
|
104
|
+
await client.fetchDetail({ formUniqueId: 'form-abc' })
|
|
105
|
+
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
|
106
|
+
expect(init.headers).toEqual({ Authorization: 'Bearer custom' })
|
|
107
|
+
})
|
|
83
108
|
})
|
package/src/client/formClient.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { FormDetailResult, FormSchema } from '../types/index'
|
|
2
2
|
import { mapFormDefinitionFromDto, resolveSurfaceFromFormDefinition } from '../adapter/resolveSurface'
|
|
3
|
+
import { parseEnvelope } from './apiEnvelope'
|
|
4
|
+
import {
|
|
5
|
+
type ClientHeadersOptions,
|
|
6
|
+
resolveClientHeaders,
|
|
7
|
+
} from './authStorage'
|
|
3
8
|
|
|
4
9
|
export interface FormDetailDto {
|
|
5
10
|
form_id: number
|
|
@@ -9,39 +14,16 @@ export interface FormDetailDto {
|
|
|
9
14
|
form_definition: Record<string, unknown>
|
|
10
15
|
}
|
|
11
16
|
|
|
12
|
-
export interface FormClientOptions {
|
|
17
|
+
export interface FormClientOptions extends ClientHeadersOptions {
|
|
13
18
|
baseUrl?: string
|
|
14
19
|
detailPath?: string
|
|
15
20
|
fetch?: typeof fetch
|
|
16
|
-
getHeaders?: () => Record<string, string> | Promise<Record<string, string>>
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
export interface FormClient {
|
|
20
24
|
fetchDetail: (ref: { formUniqueId?: string; formId?: number }) => Promise<FormDetailResult>
|
|
21
25
|
}
|
|
22
26
|
|
|
23
|
-
interface ApiEnvelope<T> {
|
|
24
|
-
success?: boolean
|
|
25
|
-
data?: T
|
|
26
|
-
message?: string
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async function parseEnvelope<T>(response: Response): Promise<T> {
|
|
30
|
-
let json: ApiEnvelope<T>
|
|
31
|
-
try {
|
|
32
|
-
json = (await response.json()) as ApiEnvelope<T>
|
|
33
|
-
} catch {
|
|
34
|
-
throw new Error(`响应非 JSON (${response.status})`)
|
|
35
|
-
}
|
|
36
|
-
if (!response.ok || json.success === false) {
|
|
37
|
-
throw new Error(json.message || `请求失败 (${response.status})`)
|
|
38
|
-
}
|
|
39
|
-
if (json.data === undefined || json.data === null) {
|
|
40
|
-
throw new Error(json.message || `响应缺少 data (${response.status})`)
|
|
41
|
-
}
|
|
42
|
-
return json.data
|
|
43
|
-
}
|
|
44
|
-
|
|
45
27
|
export function createFormClient(options: FormClientOptions = {}): FormClient {
|
|
46
28
|
const baseUrl = (options.baseUrl ?? '/api/v1').replace(/\/$/, '')
|
|
47
29
|
const detailPath = options.detailPath ?? '/dev/form/detail'
|
|
@@ -60,7 +42,7 @@ export function createFormClient(options: FormClientOptions = {}): FormClient {
|
|
|
60
42
|
throw new Error('form_id 或 form_unique_id 至少传一个')
|
|
61
43
|
}
|
|
62
44
|
|
|
63
|
-
const headers =
|
|
45
|
+
const headers = await resolveClientHeaders(options)
|
|
64
46
|
const response = await fetchFn(`${baseUrl}${detailPath}?${params.toString()}`, {
|
|
65
47
|
method: 'GET',
|
|
66
48
|
headers,
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { createResumeSearchClient } from './resumeSearchClient'
|
|
6
|
+
|
|
7
|
+
function mockResponse(
|
|
8
|
+
init: Partial<Response> & { json?: () => Promise<unknown> },
|
|
9
|
+
): Response {
|
|
10
|
+
return {
|
|
11
|
+
ok: init.ok ?? true,
|
|
12
|
+
status: init.status ?? 200,
|
|
13
|
+
headers: { get: () => 'application/json' },
|
|
14
|
+
json: init.json ?? (async () => ({})),
|
|
15
|
+
text: async () => '',
|
|
16
|
+
} as Response
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('createResumeSearchClient', () => {
|
|
20
|
+
it('maps filter to query and response to ResumeSearchItem', async () => {
|
|
21
|
+
const fetchMock = vi.fn(async () =>
|
|
22
|
+
mockResponse({
|
|
23
|
+
json: async () => ({
|
|
24
|
+
success: true,
|
|
25
|
+
data: {
|
|
26
|
+
list: [
|
|
27
|
+
{
|
|
28
|
+
resume_id: 1,
|
|
29
|
+
agent_id: 2,
|
|
30
|
+
name: '导演 A',
|
|
31
|
+
avatar_url: 'https://cdn/a.png',
|
|
32
|
+
satisfaction: 90,
|
|
33
|
+
personality_json: ['外向'],
|
|
34
|
+
specialties_json: ['剪辑'],
|
|
35
|
+
traits_json: ['短视频'],
|
|
36
|
+
works_json: [{ title: '作品1', cover_url: 'https://cdn/c.png', link: 'https://x' }],
|
|
37
|
+
published: 1,
|
|
38
|
+
works_count: 3,
|
|
39
|
+
resume_unique_id: 'resume-abc',
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
total: 1,
|
|
43
|
+
page: 1,
|
|
44
|
+
page_size: 20,
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
}),
|
|
48
|
+
) as unknown as typeof fetch
|
|
49
|
+
|
|
50
|
+
const resumeSearch = createResumeSearchClient({
|
|
51
|
+
fetch: fetchMock,
|
|
52
|
+
baseUrl: '/api/v1',
|
|
53
|
+
getHeaders: async () => ({ Authorization: 'Bearer test' }),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const result = await resumeSearch({
|
|
57
|
+
filter: {
|
|
58
|
+
names: ['导演'],
|
|
59
|
+
agentUniqueIds: ['agent-1'],
|
|
60
|
+
resumeUniqueIds: ['resume-abc'],
|
|
61
|
+
pageSize: 20,
|
|
62
|
+
},
|
|
63
|
+
page: 1,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(fetchMock).toHaveBeenCalledOnce()
|
|
67
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
|
68
|
+
expect(url).toContain('/api/v1/univ/resume/search?')
|
|
69
|
+
expect(url).toContain('name=%E5%AF%BC%E6%BC%94')
|
|
70
|
+
expect(url).toContain('agent_unique_id=agent-1')
|
|
71
|
+
expect(url).toContain('resume_unique_id=resume-abc')
|
|
72
|
+
expect(init.headers).toEqual({ Authorization: 'Bearer test' })
|
|
73
|
+
|
|
74
|
+
expect(result.list).toHaveLength(1)
|
|
75
|
+
expect(result.list[0]).toMatchObject({
|
|
76
|
+
resumeUniqueId: 'resume-abc',
|
|
77
|
+
name: '导演 A',
|
|
78
|
+
satisfaction: 90,
|
|
79
|
+
worksCount: 3,
|
|
80
|
+
})
|
|
81
|
+
expect(result.list[0].works?.[0]).toMatchObject({
|
|
82
|
+
title: '作品1',
|
|
83
|
+
coverUrl: 'https://cdn/c.png',
|
|
84
|
+
})
|
|
85
|
+
expect(result.total).toBe(1)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { FormResumeFilter } from '../types/index'
|
|
2
|
+
import type { ResumeSearchFn, ResumeSearchItem } from '../catalog/resumeSearchContext'
|
|
3
|
+
import { parseEnvelope } from './apiEnvelope'
|
|
4
|
+
import {
|
|
5
|
+
type ClientHeadersOptions,
|
|
6
|
+
resolveClientHeaders,
|
|
7
|
+
} from './authStorage'
|
|
8
|
+
|
|
9
|
+
export interface ResumeSearchClientOptions extends ClientHeadersOptions {
|
|
10
|
+
baseUrl?: string
|
|
11
|
+
searchPath?: string
|
|
12
|
+
fetch?: typeof fetch
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ResumeWorkDto {
|
|
16
|
+
id?: string
|
|
17
|
+
title?: string
|
|
18
|
+
link?: string
|
|
19
|
+
cover_url?: string | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ResumeListItemDto {
|
|
23
|
+
resume_id: number
|
|
24
|
+
agent_id: number
|
|
25
|
+
name: string
|
|
26
|
+
avatar_url?: string | null
|
|
27
|
+
description?: string | null
|
|
28
|
+
satisfaction: number
|
|
29
|
+
personality_json?: string[]
|
|
30
|
+
specialties_json?: string[]
|
|
31
|
+
traits_json?: string[]
|
|
32
|
+
works_json?: ResumeWorkDto[]
|
|
33
|
+
published: number
|
|
34
|
+
works_count: number
|
|
35
|
+
resume_unique_id: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface PagedListDto<T> {
|
|
39
|
+
list: T[]
|
|
40
|
+
total: number
|
|
41
|
+
page: number
|
|
42
|
+
page_size: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function mapResumeListItem(dto: ResumeListItemDto): ResumeSearchItem {
|
|
46
|
+
return {
|
|
47
|
+
resumeUniqueId: dto.resume_unique_id,
|
|
48
|
+
resumeId: dto.resume_id,
|
|
49
|
+
agentId: dto.agent_id,
|
|
50
|
+
name: dto.name,
|
|
51
|
+
avatarUrl: dto.avatar_url ?? null,
|
|
52
|
+
description: dto.description ?? undefined,
|
|
53
|
+
satisfaction: dto.satisfaction,
|
|
54
|
+
published: dto.published,
|
|
55
|
+
worksCount: dto.works_count,
|
|
56
|
+
personality: dto.personality_json ?? [],
|
|
57
|
+
specialties: dto.specialties_json ?? [],
|
|
58
|
+
traits: dto.traits_json ?? [],
|
|
59
|
+
works: (dto.works_json ?? []).map((work) => ({
|
|
60
|
+
title: work.title ?? '',
|
|
61
|
+
coverUrl: work.cover_url ?? null,
|
|
62
|
+
link: work.link,
|
|
63
|
+
})),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildResumeSearchQuery(
|
|
68
|
+
filter: FormResumeFilter,
|
|
69
|
+
page: number,
|
|
70
|
+
): URLSearchParams {
|
|
71
|
+
const params = new URLSearchParams()
|
|
72
|
+
params.set('page', String(page))
|
|
73
|
+
params.set('page_size', String(filter.pageSize ?? 20))
|
|
74
|
+
for (const name of filter.names ?? []) {
|
|
75
|
+
if (name) params.append('name', name)
|
|
76
|
+
}
|
|
77
|
+
for (const id of filter.agentUniqueIds ?? []) {
|
|
78
|
+
if (id) params.append('agent_unique_id', id)
|
|
79
|
+
}
|
|
80
|
+
for (const id of filter.resumeUniqueIds ?? []) {
|
|
81
|
+
if (id) params.append('resume_unique_id', id)
|
|
82
|
+
}
|
|
83
|
+
return params
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createResumeSearchClient(
|
|
87
|
+
options: ResumeSearchClientOptions = {},
|
|
88
|
+
): ResumeSearchFn {
|
|
89
|
+
const baseUrl = (options.baseUrl ?? '/api/v1').replace(/\/$/, '')
|
|
90
|
+
const searchPath = options.searchPath ?? '/univ/resume/search'
|
|
91
|
+
const fetchFn = options.fetch ?? fetch
|
|
92
|
+
|
|
93
|
+
return async ({ filter, page }) => {
|
|
94
|
+
const headers = await resolveClientHeaders(options)
|
|
95
|
+
const params = buildResumeSearchQuery(filter, page)
|
|
96
|
+
const response = await fetchFn(`${baseUrl}${searchPath}?${params.toString()}`, {
|
|
97
|
+
method: 'GET',
|
|
98
|
+
headers,
|
|
99
|
+
})
|
|
100
|
+
const data = await parseEnvelope<PagedListDto<ResumeListItemDto>>(response)
|
|
101
|
+
return {
|
|
102
|
+
list: (data.list ?? []).map(mapResumeListItem),
|
|
103
|
+
total: data.total ?? 0,
|
|
104
|
+
page: data.page ?? page,
|
|
105
|
+
pageSize: data.page_size ?? filter.pageSize ?? 20,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createCaseSearchClient, type CaseSearchClientOptions } from './caseSearchClient'
|
|
2
|
+
import { createFormClient, type FormClient, type FormClientOptions } from './formClient'
|
|
3
|
+
import { createResumeSearchClient, type ResumeSearchClientOptions } from './resumeSearchClient'
|
|
4
|
+
import type { CaseSearchFn } from '../catalog/caseSearchContext'
|
|
5
|
+
import type { ResumeSearchFn } from '../catalog/resumeSearchContext'
|
|
6
|
+
import type { SkoponAuthStorageOptions } from './authStorage'
|
|
7
|
+
|
|
8
|
+
export interface SkoponSdkClientsOptions {
|
|
9
|
+
baseUrl?: string
|
|
10
|
+
fetch?: typeof fetch
|
|
11
|
+
getHeaders?: () => Record<string, string> | Promise<Record<string, string>>
|
|
12
|
+
authStorage?: SkoponAuthStorageOptions
|
|
13
|
+
form?: Pick<FormClientOptions, 'detailPath'>
|
|
14
|
+
resume?: Pick<ResumeSearchClientOptions, 'searchPath'>
|
|
15
|
+
case?: Pick<CaseSearchClientOptions, 'listPath' | 'published'>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SkoponSdkClients {
|
|
19
|
+
fetchDetail: FormClient['fetchDetail']
|
|
20
|
+
resumeSearch: ResumeSearchFn
|
|
21
|
+
caseSearch: CaseSearchFn
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createSkoponSdkClients(
|
|
25
|
+
options: SkoponSdkClientsOptions = {},
|
|
26
|
+
): SkoponSdkClients {
|
|
27
|
+
const shared = {
|
|
28
|
+
baseUrl: options.baseUrl,
|
|
29
|
+
fetch: options.fetch,
|
|
30
|
+
getHeaders: options.getHeaders,
|
|
31
|
+
authStorage: options.authStorage,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const formClient = createFormClient({
|
|
35
|
+
...shared,
|
|
36
|
+
detailPath: options.form?.detailPath,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
fetchDetail: formClient.fetchDetail,
|
|
41
|
+
resumeSearch: createResumeSearchClient({
|
|
42
|
+
...shared,
|
|
43
|
+
searchPath: options.resume?.searchPath,
|
|
44
|
+
}),
|
|
45
|
+
caseSearch: createCaseSearchClient({
|
|
46
|
+
...shared,
|
|
47
|
+
listPath: options.case?.listPath,
|
|
48
|
+
published: options.case?.published,
|
|
49
|
+
}),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
payloadHasInputBlocks,
|
|
13
13
|
} from '../submit/intersectPayloadBlocksWithForm'
|
|
14
14
|
import { copyTextToClipboard, submitFormJson } from '../submit/submitFormJson'
|
|
15
|
+
import { validateFormSubmitValues } from '../submit/validateFormSubmit'
|
|
15
16
|
import type { CaseSearchFn } from '../catalog/caseSearchContext'
|
|
16
17
|
import type { ResumeSearchFn } from '../catalog/resumeSearchContext'
|
|
17
18
|
import SkoponFormRenderer, { type SkoponFormRendererRef } from './SkoponFormRenderer'
|
|
@@ -182,6 +183,11 @@ export default function AskUserFormCard({
|
|
|
182
183
|
setSubmitting(true)
|
|
183
184
|
try {
|
|
184
185
|
const cardValues = rendererRef.current?.getValues(fieldNames) ?? {}
|
|
186
|
+
const validationError = validateFormSubmitValues(renderBlocks, cardValues)
|
|
187
|
+
if (validationError) {
|
|
188
|
+
onNotifyRef.current?.('error', validationError.message)
|
|
189
|
+
return
|
|
190
|
+
}
|
|
185
191
|
const submitBody = { ...cardValues, ...extraValues }
|
|
186
192
|
|
|
187
193
|
if (submitMode === 'post') {
|
|
@@ -230,6 +236,7 @@ export default function AskUserFormCard({
|
|
|
230
236
|
surfaceId={surfaceId}
|
|
231
237
|
fieldNames={fieldNames}
|
|
232
238
|
interactive
|
|
239
|
+
wrapProvider={false}
|
|
233
240
|
resumeSearch={resumeSearch}
|
|
234
241
|
caseSearch={caseSearch}
|
|
235
242
|
/>
|
|
@@ -34,6 +34,8 @@ export interface SkoponFormRendererProps {
|
|
|
34
34
|
resumeSearch?: ResumeSearchFn | null
|
|
35
35
|
/** 案例单选组件:注入搜索函数 */
|
|
36
36
|
caseSearch?: CaseSearchFn | null
|
|
37
|
+
/** 外层已包裹 SkoponFormProvider 时可设为 false,避免重复 ConfigProvider */
|
|
38
|
+
wrapProvider?: boolean
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererProps>(
|
|
@@ -46,6 +48,7 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
|
|
|
46
48
|
fieldNames = [],
|
|
47
49
|
resumeSearch = null,
|
|
48
50
|
caseSearch = null,
|
|
51
|
+
wrapProvider = true,
|
|
49
52
|
},
|
|
50
53
|
ref,
|
|
51
54
|
) {
|
|
@@ -105,11 +108,19 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
|
|
|
105
108
|
[surfaceId],
|
|
106
109
|
)
|
|
107
110
|
|
|
108
|
-
if (surfaces.length === 0)
|
|
109
|
-
|
|
110
|
-
return (
|
|
111
|
-
<SkoponFormProvider>
|
|
111
|
+
if (surfaces.length === 0) {
|
|
112
|
+
const empty = (
|
|
112
113
|
<ResumeSearchProvider resumeSearch={resumeSearch}>
|
|
114
|
+
<CaseSearchProvider caseSearch={caseSearch}>
|
|
115
|
+
<A2uiPreviewModeProvider interactive={interactive}>{emptyHint}</A2uiPreviewModeProvider>
|
|
116
|
+
</CaseSearchProvider>
|
|
117
|
+
</ResumeSearchProvider>
|
|
118
|
+
)
|
|
119
|
+
return wrapProvider ? <SkoponFormProvider>{empty}</SkoponFormProvider> : empty
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const content = (
|
|
123
|
+
<ResumeSearchProvider resumeSearch={resumeSearch}>
|
|
113
124
|
<CaseSearchProvider caseSearch={caseSearch}>
|
|
114
125
|
<A2uiPreviewModeProvider interactive={interactive}>
|
|
115
126
|
<div className="a2ui-surface a2ui-container">
|
|
@@ -119,9 +130,10 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
|
|
|
119
130
|
</div>
|
|
120
131
|
</A2uiPreviewModeProvider>
|
|
121
132
|
</CaseSearchProvider>
|
|
122
|
-
|
|
123
|
-
</SkoponFormProvider>
|
|
133
|
+
</ResumeSearchProvider>
|
|
124
134
|
)
|
|
135
|
+
|
|
136
|
+
return wrapProvider ? <SkoponFormProvider>{content}</SkoponFormProvider> : content
|
|
125
137
|
},
|
|
126
138
|
)
|
|
127
139
|
|
package/src/index.ts
CHANGED
|
@@ -65,6 +65,27 @@ export {
|
|
|
65
65
|
} from './submit/submitFormJson'
|
|
66
66
|
|
|
67
67
|
export { createFormClient, type FormClient, type FormClientOptions } from './client/formClient'
|
|
68
|
+
export {
|
|
69
|
+
resolveSkoponAuthHeaders,
|
|
70
|
+
resolveClientHeaders,
|
|
71
|
+
DEFAULT_AUTH_STORAGE_KEY,
|
|
72
|
+
DEFAULT_AUTH_TOKEN_FIELD,
|
|
73
|
+
type SkoponAuthStorageOptions,
|
|
74
|
+
type ClientHeadersOptions,
|
|
75
|
+
} from './client/authStorage'
|
|
76
|
+
export {
|
|
77
|
+
createResumeSearchClient,
|
|
78
|
+
type ResumeSearchClientOptions,
|
|
79
|
+
} from './client/resumeSearchClient'
|
|
80
|
+
export {
|
|
81
|
+
createCaseSearchClient,
|
|
82
|
+
type CaseSearchClientOptions,
|
|
83
|
+
} from './client/caseSearchClient'
|
|
84
|
+
export {
|
|
85
|
+
createSkoponSdkClients,
|
|
86
|
+
type SkoponSdkClients,
|
|
87
|
+
type SkoponSdkClientsOptions,
|
|
88
|
+
} from './client/skoponSdkClients'
|
|
68
89
|
|
|
69
90
|
export { default as SkoponFormRenderer } from './components/SkoponFormRenderer'
|
|
70
91
|
export type { SkoponFormRendererProps, SkoponFormRendererRef } from './components/SkoponFormRenderer'
|
package/src/styles/index.css
CHANGED
|
@@ -251,11 +251,11 @@
|
|
|
251
251
|
min-width: 220px;
|
|
252
252
|
}
|
|
253
253
|
.skopon-resume-select-card:hover:not(:disabled) {
|
|
254
|
-
border-color: var(--color-primary, #
|
|
254
|
+
border-color: var(--color-primary, #208368);
|
|
255
255
|
}
|
|
256
256
|
.skopon-resume-select-card--selected {
|
|
257
|
-
border-color: var(--color-primary, #
|
|
258
|
-
background: color-mix(in srgb, var(--color-primary, #
|
|
257
|
+
border-color: var(--color-primary, #208368);
|
|
258
|
+
background: color-mix(in srgb, var(--color-primary, #208368) 8%, transparent);
|
|
259
259
|
}
|
|
260
260
|
.skopon-resume-select-card:disabled {
|
|
261
261
|
cursor: not-allowed;
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
payloadHasInputBlocks,
|
|
18
18
|
} from '../submit/intersectPayloadBlocksWithForm'
|
|
19
19
|
import { submitFormJson } from '../submit/submitFormJson'
|
|
20
|
+
import { validateFormSubmitValues } from '../submit/validateFormSubmit'
|
|
20
21
|
import type { FormSchema } from '../types/index'
|
|
21
22
|
|
|
22
23
|
describe('buildCurlStatement', () => {
|
|
@@ -284,3 +285,42 @@ describe('submitFormJson', () => {
|
|
|
284
285
|
)
|
|
285
286
|
})
|
|
286
287
|
})
|
|
288
|
+
|
|
289
|
+
describe('validateFormSubmitValues', () => {
|
|
290
|
+
it('rejects empty required text field', () => {
|
|
291
|
+
const blocks = [
|
|
292
|
+
{ id: 'b1', type: 'text' as const, name: 'title', label: '标题', required: true },
|
|
293
|
+
]
|
|
294
|
+
expect(validateFormSubmitValues(blocks, { title: '' })?.message).toBe('请填写「标题」')
|
|
295
|
+
expect(validateFormSubmitValues(blocks, { title: 'ok' })).toBeNull()
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('rejects invalid email format', () => {
|
|
299
|
+
const blocks = [
|
|
300
|
+
{ id: 'b1', type: 'email' as const, name: 'email', label: '邮箱' },
|
|
301
|
+
]
|
|
302
|
+
expect(validateFormSubmitValues(blocks, { email: 'bad' })?.message).toBe('「邮箱」格式不正确')
|
|
303
|
+
expect(validateFormSubmitValues(blocks, { email: 'a@b.co' })).toBeNull()
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('enforces resume and case minCount when required or partially filled', () => {
|
|
307
|
+
const resumeBlocks = [
|
|
308
|
+
{
|
|
309
|
+
id: 'b1',
|
|
310
|
+
type: 'resume_multiselect' as const,
|
|
311
|
+
name: 'resumes',
|
|
312
|
+
label: '简历',
|
|
313
|
+
resumeMinCount: 2,
|
|
314
|
+
},
|
|
315
|
+
]
|
|
316
|
+
expect(validateFormSubmitValues(resumeBlocks, { resumes: [] })).toBeNull()
|
|
317
|
+
expect(validateFormSubmitValues(resumeBlocks, { resumes: ['r1'] })?.message).toBe(
|
|
318
|
+
'「简历」至少选择 2 项',
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
const requiredResume = [{ ...resumeBlocks[0]!, required: true }]
|
|
322
|
+
expect(validateFormSubmitValues(requiredResume, { resumes: [] })?.message).toBe(
|
|
323
|
+
'请填写「简历」',
|
|
324
|
+
)
|
|
325
|
+
})
|
|
326
|
+
})
|