@skopon-cool/form-sdk 0.2.1 → 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 (50) hide show
  1. package/README.md +94 -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/client/apiEnvelope.d.ts +7 -0
  8. package/dist/client/apiEnvelope.d.ts.map +1 -0
  9. package/dist/client/authStorage.d.ts +19 -0
  10. package/dist/client/authStorage.d.ts.map +1 -0
  11. package/dist/client/caseSearchClient.d.ts +11 -0
  12. package/dist/client/caseSearchClient.d.ts.map +1 -0
  13. package/dist/client/formClient.d.ts +2 -2
  14. package/dist/client/formClient.d.ts.map +1 -1
  15. package/dist/client/resumeSearchClient.d.ts +9 -0
  16. package/dist/client/resumeSearchClient.d.ts.map +1 -0
  17. package/dist/client/skoponSdkClients.d.ts +22 -0
  18. package/dist/client/skoponSdkClients.d.ts.map +1 -0
  19. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  20. package/dist/index.d.ts +4 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1013 -759
  23. package/dist/submit/validateFormSubmit.d.ts +10 -0
  24. package/dist/submit/validateFormSubmit.d.ts.map +1 -0
  25. package/package.json +1 -1
  26. package/src/adapter/a2uiAdapter.ts +1 -1
  27. package/src/adapter/formSchema.test.ts +24 -0
  28. package/src/adapter/formSchema.ts +9 -2
  29. package/src/adapter/resolveSurface.ts +3 -3
  30. package/src/catalog/a2uiCustomCatalog.tsx +35 -3
  31. package/src/catalog/textFieldPreview.test.tsx +31 -0
  32. package/src/client/apiEnvelope.ts +21 -0
  33. package/src/client/authStorage.test.ts +61 -0
  34. package/src/client/authStorage.ts +70 -0
  35. package/src/client/caseSearchClient.ts +107 -0
  36. package/src/client/formClient.test.ts +25 -0
  37. package/src/client/formClient.ts +7 -25
  38. package/src/client/resumeSearchClient.test.ts +87 -0
  39. package/src/client/resumeSearchClient.ts +108 -0
  40. package/src/client/skoponSdkClients.ts +51 -0
  41. package/src/components/AskUserFormCard.tsx +6 -0
  42. package/src/index.ts +21 -0
  43. package/src/submit/submit.test.ts +40 -0
  44. package/src/submit/validateFormSubmit.ts +111 -0
  45. package/dist/catalog/skoponSelectLoading.test.d.ts +0 -2
  46. package/dist/catalog/skoponSelectLoading.test.d.ts.map +0 -1
  47. package/dist/catalog/textFieldPreview.test.d.ts +0 -2
  48. package/dist/catalog/textFieldPreview.test.d.ts.map +0 -1
  49. package/dist/components/SkoponA2uiStreamRenderer.test.d.ts +0 -2
  50. package/dist/components/SkoponA2uiStreamRenderer.test.d.ts.map +0 -1
@@ -0,0 +1,10 @@
1
+ import type { FormBlock } from '../types/index';
2
+ export interface FormSubmitValidationError {
3
+ fieldName: string;
4
+ label: string;
5
+ message: string;
6
+ }
7
+ export declare function matchesValidationRegexp(value: string, pattern: string): boolean;
8
+ /** 提交前校验 renderBlocks 对应字段值;首个错误即返回。 */
9
+ export declare function validateFormSubmitValues(blocks: FormBlock[], values: Record<string, unknown>): FormSubmitValidationError | null;
10
+ //# sourceMappingURL=validateFormSubmit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validateFormSubmit.d.ts","sourceRoot":"","sources":["../../src/submit/validateFormSubmit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAM/C,MAAM,WAAW,yBAAyB;IACxC,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAO/E;AA6BD,wCAAwC;AACxC,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,SAAS,EAAE,EACnB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,yBAAyB,GAAG,IAAI,CA0DlC"}
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.2.1",
6
+ "version": "0.2.2",
7
7
  "description": "Skopon form rendering SDK (A2UI + form_definition) with submit helpers",
8
8
  "type": "module",
9
9
  "sideEffects": [
@@ -27,7 +27,7 @@ import { getBlockTypeAdapter, getComponentAdapter } from '../blocks/registry'
27
27
  const ROOT_ID = 'root'
28
28
  const TITLE_ID = '__title__'
29
29
  const DESC_ID = '__desc__'
30
- const EMAIL_REGEXP = '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$'
30
+ export const EMAIL_REGEXP = '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$'
31
31
 
32
32
  function pathFor(name: string): string {
33
33
  return `/${name}`
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { describe, it, expect } from 'vitest'
5
5
  import { coerceToggleValue, normalizeFormDefinition } from './formSchema'
6
+ import { mapFormDefinitionFromDto } from './resolveSurface'
6
7
 
7
8
  describe('coerceToggleValue', () => {
8
9
  it('accepts boolean, string, and numeric truthy/falsy values', () => {
@@ -36,4 +37,27 @@ describe('normalizeFormDefinition toggle default', () => {
36
37
  })
37
38
  expect(def.blocks[0]?.defaultValue).toBe(true)
38
39
  })
40
+
41
+ it('preserves jsonSchema from raw definition', () => {
42
+ const schema = { type: 'object', properties: { title: { type: 'string' } } }
43
+ const def = normalizeFormDefinition({
44
+ title: '',
45
+ description: '',
46
+ blocks: [],
47
+ jsonSchema: schema,
48
+ })
49
+ expect(def.jsonSchema).toEqual(schema)
50
+ })
51
+ })
52
+
53
+ describe('mapFormDefinitionFromDto jsonSchema', () => {
54
+ it('reads json_schema snake_case from DTO', () => {
55
+ const schema = { type: 'object' }
56
+ const def = mapFormDefinitionFromDto({
57
+ title: 'T',
58
+ blocks: [{ id: 'b1', type: 'text', name: 'x', label: 'X' }],
59
+ json_schema: schema,
60
+ })
61
+ expect(def?.jsonSchema).toEqual(schema)
62
+ })
39
63
  })
@@ -1,4 +1,4 @@
1
- import type { FormBlock, FormBlockType, FormCaseFilter, FormResumeFilter, FormSchema } from '../types/index'
1
+ import type { FormBlock, FormBlockType, FormCaseFilter, FormResumeFilter, FormJsonSchema, FormSchema } from '../types/index'
2
2
  import { isInputBlockType, isMediaBlockType } from '../types/index'
3
3
  import { generateId } from './id'
4
4
  import {
@@ -13,6 +13,13 @@ import {
13
13
 
14
14
  const DEFAULT_MEDIA_URL_MAX = 10
15
15
 
16
+ export function normalizeJsonSchema(raw: unknown): FormJsonSchema {
17
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
18
+ return raw as FormJsonSchema
19
+ }
20
+ return {}
21
+ }
22
+
16
23
  export function coerceToggleValue(raw: unknown): boolean {
17
24
  if (raw === true || raw === 'true' || raw === 1 || raw === '1') return true
18
25
  if (raw === false || raw === 'false' || raw === 0 || raw === '0') return false
@@ -180,7 +187,7 @@ export function normalizeFormDefinition(raw: FormSchema | undefined): FormSchema
180
187
  title: typeof raw.title === 'string' ? raw.title : '',
181
188
  description: typeof raw.description === 'string' ? raw.description : '',
182
189
  blocks,
183
- jsonSchema: {},
190
+ jsonSchema: normalizeJsonSchema(raw.jsonSchema),
184
191
  })
185
192
  }
186
193
 
@@ -1,6 +1,6 @@
1
1
  import type { A2uiSurfaceDoc, FormBlock, FormDefinitionPayload, FormSchema } from '../types/index'
2
2
  import { a2uiToBlocks, blocksToA2ui, isA2uiSurfaceEmpty, type BlocksToA2uiOptions } from './a2uiAdapter'
3
- import { normalizeFormDefinition } from './formSchema'
3
+ import { normalizeFormDefinition, normalizeJsonSchema } from './formSchema'
4
4
 
5
5
  function isA2uiSurfaceDoc(raw: unknown): raw is A2uiSurfaceDoc {
6
6
  return (
@@ -27,7 +27,7 @@ export function resolveSurfaceFromFormDefinition(
27
27
  title: typeof payload.title === 'string' ? payload.title : '',
28
28
  description: typeof payload.description === 'string' ? payload.description : '',
29
29
  blocks,
30
- jsonSchema: {},
30
+ jsonSchema: normalizeJsonSchema(payload.jsonSchema ?? payload.json_schema),
31
31
  })
32
32
  const doc = blocksToA2ui(normalized, options)
33
33
  return isA2uiSurfaceEmpty(doc) ? null : doc
@@ -61,6 +61,6 @@ export function mapFormDefinitionFromDto(raw: unknown): FormSchema | undefined {
61
61
  title: typeof obj.title === 'string' ? obj.title : '',
62
62
  description: typeof obj.description === 'string' ? obj.description : '',
63
63
  blocks: Array.isArray(obj.blocks) ? (obj.blocks as FormSchema['blocks']) : [],
64
- jsonSchema: {},
64
+ jsonSchema: normalizeJsonSchema(obj.jsonSchema ?? obj.json_schema),
65
65
  })
66
66
  }
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod'
2
- import { useEffect, type ReactNode } from 'react'
2
+ import { useEffect, useState, type ReactNode } from 'react'
3
3
  import { Catalog, type ComponentContext } from '@a2ui/web_core/v0_9'
4
4
  import {
5
5
  basicCatalog,
@@ -21,6 +21,7 @@ import {
21
21
  import dayjs, { type Dayjs } from 'dayjs'
22
22
  import type { FormFilePlaceholderIcon, FormMediaSize } from '../types/index'
23
23
  import { coerceToggleValue } from '../adapter/formSchema'
24
+ import { matchesValidationRegexp } from '../submit/validateFormSubmit'
24
25
  import {
25
26
  buildFileAcceptAttribute,
26
27
  formatFileAcceptSummary,
@@ -589,8 +590,22 @@ function TextFieldPreview({ context }: { context: ComponentContext }) {
589
590
  const label = readString(props.label)
590
591
  const placeholder = readString(props.placeholder)
591
592
  const variant = readString(props.variant) || 'shortText'
593
+ const validationRegexp = readString(props.validationRegexp)
592
594
  const stringValue = asOptionalString(value) ?? ''
593
595
  const disabled = !interactive
596
+ const [formatError, setFormatError] = useState('')
597
+
598
+ const validateFormat = (text: string) => {
599
+ if (!validationRegexp || !text.trim()) {
600
+ setFormatError('')
601
+ return
602
+ }
603
+ setFormatError(matchesValidationRegexp(text, validationRegexp) ? '' : '格式不正确')
604
+ }
605
+
606
+ useEffect(() => {
607
+ validateFormat(stringValue)
608
+ }, [validationRegexp, stringValue])
594
609
 
595
610
  const controlClassName = 'form-block-preview-control'
596
611
 
@@ -603,7 +618,13 @@ function TextFieldPreview({ context }: { context: ComponentContext }) {
603
618
  placeholder={placeholder || '长文本回答...'}
604
619
  autoSize={{ minRows: 2, maxRows: 6 }}
605
620
  value={stringValue}
606
- onChange={(e) => setValue(e.target.value)}
621
+ status={formatError ? 'error' : undefined}
622
+ onChange={(e) => {
623
+ const next = e.target.value
624
+ setValue(next)
625
+ validateFormat(next)
626
+ }}
627
+ onBlur={(e) => validateFormat(e.target.value)}
607
628
  />
608
629
  )
609
630
  } else if (variant === 'number') {
@@ -636,7 +657,13 @@ function TextFieldPreview({ context }: { context: ComponentContext }) {
636
657
  className={controlClassName}
637
658
  placeholder={placeholder}
638
659
  value={stringValue}
639
- onChange={(e) => setValue(e.target.value)}
660
+ status={formatError ? 'error' : undefined}
661
+ onChange={(e) => {
662
+ const next = e.target.value
663
+ setValue(next)
664
+ validateFormat(next)
665
+ }}
666
+ onBlur={(e) => validateFormat(e.target.value)}
640
667
  />
641
668
  )
642
669
  }
@@ -645,6 +672,11 @@ function TextFieldPreview({ context }: { context: ComponentContext }) {
645
672
  <div className="form-block-preview">
646
673
  {label ? <div className="form-block-preview-label">{label}</div> : null}
647
674
  {control}
675
+ {formatError ? (
676
+ <Typography.Text type="danger" className="form-block-preview-help">
677
+ {formatError}
678
+ </Typography.Text>
679
+ ) : null}
648
680
  </div>
649
681
  )
650
682
  }
@@ -85,4 +85,35 @@ describe('SkoponFormRenderer TextField longText', () => {
85
85
  expect(warnSpy.mock.calls.some((call) => String(call[0]).includes('[useMarkdown]'))).toBe(false)
86
86
  warnSpy.mockRestore()
87
87
  })
88
+
89
+ it('shows format error for invalid email TextField', () => {
90
+ const doc = blocksToA2ui(
91
+ {
92
+ title: '',
93
+ description: '',
94
+ blocks: [
95
+ {
96
+ id: 'b-email',
97
+ type: 'email',
98
+ name: 'contact',
99
+ label: '邮箱',
100
+ },
101
+ ],
102
+ },
103
+ { includeHeader: false },
104
+ )
105
+ const { container, getByText } = render(
106
+ createElement(SkoponFormRenderer, {
107
+ doc,
108
+ surfaceId: 'test-email',
109
+ fieldNames: ['contact'],
110
+ }),
111
+ )
112
+ const input = container.querySelector('input.ant-input')
113
+ expect(input).toBeTruthy()
114
+ if (input) {
115
+ fireEvent.change(input, { target: { value: 'not-an-email' } })
116
+ }
117
+ expect(getByText('格式不正确')).toBeTruthy()
118
+ })
88
119
  })
@@ -0,0 +1,21 @@
1
+ export interface ApiEnvelope<T> {
2
+ success?: boolean
3
+ data?: T
4
+ message?: string
5
+ }
6
+
7
+ export async function parseEnvelope<T>(response: Response): Promise<T> {
8
+ let json: ApiEnvelope<T>
9
+ try {
10
+ json = (await response.json()) as ApiEnvelope<T>
11
+ } catch {
12
+ throw new Error(`响应非 JSON (${response.status})`)
13
+ }
14
+ if (!response.ok || json.success === false) {
15
+ throw new Error(json.message || `请求失败 (${response.status})`)
16
+ }
17
+ if (json.data === undefined || json.data === null) {
18
+ throw new Error(json.message || `响应缺少 data (${response.status})`)
19
+ }
20
+ return json.data
21
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import {
6
+ DEFAULT_AUTH_STORAGE_KEY,
7
+ resolveSkoponAuthHeaders,
8
+ } from './authStorage'
9
+
10
+ describe('resolveSkoponAuthHeaders', () => {
11
+ afterEach(() => {
12
+ sessionStorage.clear()
13
+ localStorage.clear()
14
+ })
15
+
16
+ it('reads Bearer token from sessionStorage auth_user by default', () => {
17
+ sessionStorage.setItem(
18
+ DEFAULT_AUTH_STORAGE_KEY,
19
+ JSON.stringify({ username: 'u', displayName: 'U', token: 'abc123' }),
20
+ )
21
+ expect(resolveSkoponAuthHeaders()).toEqual({
22
+ Authorization: 'Bearer abc123',
23
+ })
24
+ })
25
+
26
+ it('strips existing Bearer prefix from stored token', () => {
27
+ sessionStorage.setItem(
28
+ DEFAULT_AUTH_STORAGE_KEY,
29
+ JSON.stringify({ token: 'Bearer xyz' }),
30
+ )
31
+ expect(resolveSkoponAuthHeaders()).toEqual({
32
+ Authorization: 'Bearer xyz',
33
+ })
34
+ })
35
+
36
+ it('returns empty object when session is missing', () => {
37
+ expect(resolveSkoponAuthHeaders()).toEqual({})
38
+ })
39
+
40
+ it('returns empty object when token field is empty', () => {
41
+ sessionStorage.setItem(DEFAULT_AUTH_STORAGE_KEY, JSON.stringify({ token: '' }))
42
+ expect(resolveSkoponAuthHeaders()).toEqual({})
43
+ })
44
+
45
+ it('supports custom storageKey and tokenField', () => {
46
+ sessionStorage.setItem('my_session', JSON.stringify({ accessToken: 'tok-1' }))
47
+ expect(
48
+ resolveSkoponAuthHeaders({ storageKey: 'my_session', tokenField: 'accessToken' }),
49
+ ).toEqual({ Authorization: 'Bearer tok-1' })
50
+ })
51
+
52
+ it('supports localStorage', () => {
53
+ localStorage.setItem(
54
+ DEFAULT_AUTH_STORAGE_KEY,
55
+ JSON.stringify({ token: 'local-tok' }),
56
+ )
57
+ expect(resolveSkoponAuthHeaders({ storage: 'localStorage' })).toEqual({
58
+ Authorization: 'Bearer local-tok',
59
+ })
60
+ })
61
+ })
@@ -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
+ })