@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.
Files changed (62) hide show
  1. package/README.md +96 -9
  2. package/dist/adapter/a2uiAdapter.d.ts +1 -0
  3. package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
  4. package/dist/adapter/formSchema.d.ts +2 -1
  5. package/dist/adapter/formSchema.d.ts.map +1 -1
  6. package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
  7. package/dist/catalog/skoponCaseSelect.d.ts.map +1 -1
  8. package/dist/catalog/skoponResumeSelect.d.ts.map +1 -1
  9. package/dist/catalog/useSkoponBoundField.d.ts +2 -0
  10. package/dist/catalog/useSkoponBoundField.d.ts.map +1 -1
  11. package/dist/client/apiEnvelope.d.ts +7 -0
  12. package/dist/client/apiEnvelope.d.ts.map +1 -0
  13. package/dist/client/authStorage.d.ts +19 -0
  14. package/dist/client/authStorage.d.ts.map +1 -0
  15. package/dist/client/caseSearchClient.d.ts +11 -0
  16. package/dist/client/caseSearchClient.d.ts.map +1 -0
  17. package/dist/client/formClient.d.ts +2 -2
  18. package/dist/client/formClient.d.ts.map +1 -1
  19. package/dist/client/resumeSearchClient.d.ts +9 -0
  20. package/dist/client/resumeSearchClient.d.ts.map +1 -0
  21. package/dist/client/skoponSdkClients.d.ts +22 -0
  22. package/dist/client/skoponSdkClients.d.ts.map +1 -0
  23. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  24. package/dist/components/SkoponFormRenderer.d.ts +2 -0
  25. package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
  26. package/dist/form-sdk.css +1 -1
  27. package/dist/index.d.ts +4 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +1097 -826
  30. package/dist/submit/validateFormSubmit.d.ts +10 -0
  31. package/dist/submit/validateFormSubmit.d.ts.map +1 -0
  32. package/package.json +1 -1
  33. package/src/adapter/a2uiAdapter.ts +3 -4
  34. package/src/adapter/formSchema.test.ts +29 -1
  35. package/src/adapter/formSchema.ts +12 -3
  36. package/src/adapter/resolveSurface.ts +3 -3
  37. package/src/catalog/a2uiCustomCatalog.tsx +49 -3
  38. package/src/catalog/skoponCaseSelect.tsx +2 -1
  39. package/src/catalog/skoponResumeSelect.tsx +1 -0
  40. package/src/catalog/skoponSelectLoading.test.tsx +182 -0
  41. package/src/catalog/textFieldPreview.test.tsx +31 -0
  42. package/src/catalog/useSkoponBoundField.test.ts +14 -1
  43. package/src/catalog/useSkoponBoundField.ts +14 -0
  44. package/src/client/apiEnvelope.ts +21 -0
  45. package/src/client/authStorage.test.ts +61 -0
  46. package/src/client/authStorage.ts +70 -0
  47. package/src/client/caseSearchClient.ts +107 -0
  48. package/src/client/formClient.test.ts +25 -0
  49. package/src/client/formClient.ts +7 -25
  50. package/src/client/resumeSearchClient.test.ts +87 -0
  51. package/src/client/resumeSearchClient.ts +108 -0
  52. package/src/client/skoponSdkClients.ts +51 -0
  53. package/src/components/AskUserFormCard.tsx +7 -0
  54. package/src/components/SkoponFormRenderer.tsx +18 -6
  55. package/src/index.ts +21 -0
  56. package/src/styles/index.css +3 -3
  57. package/src/submit/submit.test.ts +40 -0
  58. package/src/submit/validateFormSubmit.ts +111 -0
  59. package/dist/catalog/textFieldPreview.test.d.ts +0 -2
  60. package/dist/catalog/textFieldPreview.test.d.ts.map +0 -1
  61. package/dist/components/SkoponA2uiStreamRenderer.test.d.ts +0 -2
  62. 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
  })
@@ -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 = (await options.getHeaders?.()) ?? {}
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) return <SkoponFormProvider>{emptyHint}</SkoponFormProvider>
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
- </ResumeSearchProvider>
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'
@@ -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, #1677ff);
254
+ border-color: var(--color-primary, #208368);
255
255
  }
256
256
  .skopon-resume-select-card--selected {
257
- border-color: var(--color-primary, #1677ff);
258
- background: color-mix(in srgb, var(--color-primary, #1677ff) 8%, transparent);
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
+ })