@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.
- package/README.md +94 -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/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/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1013 -759
- 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 +1 -1
- package/src/adapter/formSchema.test.ts +24 -0
- package/src/adapter/formSchema.ts +9 -2
- package/src/adapter/resolveSurface.ts +3 -3
- package/src/catalog/a2uiCustomCatalog.tsx +35 -3
- package/src/catalog/textFieldPreview.test.tsx +31 -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 +6 -0
- package/src/index.ts +21 -0
- package/src/submit/submit.test.ts +40 -0
- package/src/submit/validateFormSubmit.ts +111 -0
- package/dist/catalog/skoponSelectLoading.test.d.ts +0 -2
- package/dist/catalog/skoponSelectLoading.test.d.ts.map +0 -1
- 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,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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
})
|
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
|
+
})
|