@skopon-cool/form-sdk 0.2.1 → 0.2.3

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 (60) 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 +24 -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 +3 -1
  20. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  21. package/dist/components/CurlSubmitBlock.d.ts +3 -1
  22. package/dist/components/CurlSubmitBlock.d.ts.map +1 -1
  23. package/dist/index.d.ts +6 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1191 -820
  26. package/dist/submit/buildAnswerAgentBody.d.ts +19 -0
  27. package/dist/submit/buildAnswerAgentBody.d.ts.map +1 -0
  28. package/dist/submit/submitAnswerAgent.d.ts +9 -0
  29. package/dist/submit/submitAnswerAgent.d.ts.map +1 -0
  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 +1 -1
  34. package/src/adapter/formSchema.test.ts +24 -0
  35. package/src/adapter/formSchema.ts +9 -2
  36. package/src/adapter/resolveSurface.ts +3 -3
  37. package/src/catalog/a2uiCustomCatalog.tsx +35 -3
  38. package/src/catalog/textFieldPreview.test.tsx +31 -0
  39. package/src/client/apiEnvelope.ts +21 -0
  40. package/src/client/authStorage.test.ts +87 -0
  41. package/src/client/authStorage.ts +102 -0
  42. package/src/client/caseSearchClient.ts +107 -0
  43. package/src/client/formClient.test.ts +25 -0
  44. package/src/client/formClient.ts +7 -25
  45. package/src/client/resumeSearchClient.test.ts +87 -0
  46. package/src/client/resumeSearchClient.ts +108 -0
  47. package/src/client/skoponSdkClients.ts +51 -0
  48. package/src/components/AskUserFormCard.tsx +45 -11
  49. package/src/components/CurlSubmitBlock.tsx +73 -25
  50. package/src/index.ts +32 -0
  51. package/src/submit/buildAnswerAgentBody.ts +61 -0
  52. package/src/submit/submit.test.ts +115 -0
  53. package/src/submit/submitAnswerAgent.ts +39 -0
  54. package/src/submit/validateFormSubmit.ts +111 -0
  55. package/dist/catalog/skoponSelectLoading.test.d.ts +0 -2
  56. package/dist/catalog/skoponSelectLoading.test.d.ts.map +0 -1
  57. package/dist/catalog/textFieldPreview.test.d.ts +0 -2
  58. package/dist/catalog/textFieldPreview.test.d.ts.map +0 -1
  59. package/dist/components/SkoponA2uiStreamRenderer.test.d.ts +0 -2
  60. package/dist/components/SkoponA2uiStreamRenderer.test.d.ts.map +0 -1
@@ -0,0 +1,19 @@
1
+ import type { FormSchema } from '../types/index';
2
+ import { type PayloadBlocksDefinition } from './intersectPayloadBlocksWithForm';
3
+ export interface AskUserAnswerAgentContext {
4
+ missionId?: number | string | null;
5
+ tenantId?: number | null;
6
+ tenantUserId?: string | null;
7
+ requestId?: string | null;
8
+ askUserQueryDescription?: string | null;
9
+ callbackUrl?: string | null;
10
+ }
11
+ export interface BuildAnswerAgentBodyOptions {
12
+ context: AskUserAnswerAgentContext;
13
+ payload: unknown;
14
+ form?: PayloadBlocksDefinition | FormSchema | null;
15
+ answer: Record<string, unknown>;
16
+ }
17
+ /** 组装 answer-agent 外包消息体(与 mock AskUserAnswerMockVo 对齐)。 */
18
+ export declare function buildAnswerAgentBody({ context, payload, form, answer, }: BuildAnswerAgentBodyOptions): Record<string, unknown>;
19
+ //# sourceMappingURL=buildAnswerAgentBody.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"buildAnswerAgentBody.d.ts","sourceRoot":"","sources":["../../src/submit/buildAnswerAgentBody.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAChD,OAAO,EAEL,KAAK,uBAAuB,EAC7B,MAAM,kCAAkC,CAAA;AAEzC,MAAM,WAAW,yBAAyB;IACxC,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;IAClC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,uBAAuB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACvC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC5B;AAmBD,MAAM,WAAW,2BAA2B;IAC1C,OAAO,EAAE,yBAAyB,CAAA;IAClC,OAAO,EAAE,OAAO,CAAA;IAChB,IAAI,CAAC,EAAE,uBAAuB,GAAG,UAAU,GAAG,IAAI,CAAA;IAClD,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAChC;AAED,4DAA4D;AAC5D,wBAAgB,oBAAoB,CAAC,EACnC,OAAO,EACP,OAAO,EACP,IAAI,EACJ,MAAM,GACP,EAAE,2BAA2B,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAevD"}
@@ -0,0 +1,9 @@
1
+ import { type SkoponAuthStorageOptions } from '../client/authStorage';
2
+ import { type SubmitFormJsonOptions, type SubmitFormJsonResult } from './submitFormJson';
3
+ export interface SubmitAnswerAgentOptions extends SubmitFormJsonOptions {
4
+ authStorage?: SkoponAuthStorageOptions;
5
+ /** 显式覆盖 auth_user 中的 answer_agent_callback_url */
6
+ answerAgentCallbackUrl?: string | null;
7
+ }
8
+ export declare function submitAnswerAgent(body: Record<string, unknown>, options?: SubmitAnswerAgentOptions): Promise<SubmitFormJsonResult>;
9
+ //# sourceMappingURL=submitAnswerAgent.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"submitAnswerAgent.d.ts","sourceRoot":"","sources":["../../src/submit/submitAnswerAgent.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,wBAAwB,EAC9B,MAAM,uBAAuB,CAAA;AAC9B,OAAO,EAEL,KAAK,qBAAqB,EAC1B,KAAK,oBAAoB,EAC1B,MAAM,kBAAkB,CAAA;AAEzB,MAAM,WAAW,wBAAyB,SAAQ,qBAAqB;IACrE,WAAW,CAAC,EAAE,wBAAwB,CAAA;IACtC,kDAAkD;IAClD,sBAAsB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACvC;AAED,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,OAAO,CAAC,EAAE,wBAAwB,GACjC,OAAO,CAAC,oBAAoB,CAAC,CAkB/B"}
@@ -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.3",
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,87 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import {
6
+ DEFAULT_AUTH_ANSWER_AGENT_CALLBACK_URL_FIELD,
7
+ DEFAULT_AUTH_STORAGE_KEY,
8
+ resolveAnswerAgentCallbackUrl,
9
+ resolveSkoponAuthHeaders,
10
+ } from './authStorage'
11
+
12
+ describe('resolveSkoponAuthHeaders', () => {
13
+ afterEach(() => {
14
+ sessionStorage.clear()
15
+ localStorage.clear()
16
+ })
17
+
18
+ it('reads Bearer token from sessionStorage auth_user by default', () => {
19
+ sessionStorage.setItem(
20
+ DEFAULT_AUTH_STORAGE_KEY,
21
+ JSON.stringify({ username: 'u', displayName: 'U', token: 'abc123' }),
22
+ )
23
+ expect(resolveSkoponAuthHeaders()).toEqual({
24
+ Authorization: 'Bearer abc123',
25
+ })
26
+ })
27
+
28
+ it('strips existing Bearer prefix from stored token', () => {
29
+ sessionStorage.setItem(
30
+ DEFAULT_AUTH_STORAGE_KEY,
31
+ JSON.stringify({ token: 'Bearer xyz' }),
32
+ )
33
+ expect(resolveSkoponAuthHeaders()).toEqual({
34
+ Authorization: 'Bearer xyz',
35
+ })
36
+ })
37
+
38
+ it('returns empty object when session is missing', () => {
39
+ expect(resolveSkoponAuthHeaders()).toEqual({})
40
+ })
41
+
42
+ it('returns empty object when token field is empty', () => {
43
+ sessionStorage.setItem(DEFAULT_AUTH_STORAGE_KEY, JSON.stringify({ token: '' }))
44
+ expect(resolveSkoponAuthHeaders()).toEqual({})
45
+ })
46
+
47
+ it('supports custom storageKey and tokenField', () => {
48
+ sessionStorage.setItem('my_session', JSON.stringify({ accessToken: 'tok-1' }))
49
+ expect(
50
+ resolveSkoponAuthHeaders({ storageKey: 'my_session', tokenField: 'accessToken' }),
51
+ ).toEqual({ Authorization: 'Bearer tok-1' })
52
+ })
53
+
54
+ it('supports localStorage', () => {
55
+ localStorage.setItem(
56
+ DEFAULT_AUTH_STORAGE_KEY,
57
+ JSON.stringify({ token: 'local-tok' }),
58
+ )
59
+ expect(resolveSkoponAuthHeaders({ storage: 'localStorage' })).toEqual({
60
+ Authorization: 'Bearer local-tok',
61
+ })
62
+ })
63
+ })
64
+
65
+ describe('resolveAnswerAgentCallbackUrl', () => {
66
+ afterEach(() => {
67
+ sessionStorage.clear()
68
+ localStorage.clear()
69
+ })
70
+
71
+ it('reads answer_agent_callback_url from sessionStorage auth_user', () => {
72
+ sessionStorage.setItem(
73
+ DEFAULT_AUTH_STORAGE_KEY,
74
+ JSON.stringify({
75
+ token: 'tok',
76
+ [DEFAULT_AUTH_ANSWER_AGENT_CALLBACK_URL_FIELD]:
77
+ '/api/v1/mock/ask_user_answer_mock',
78
+ }),
79
+ )
80
+ expect(resolveAnswerAgentCallbackUrl()).toBe('/api/v1/mock/ask_user_answer_mock')
81
+ })
82
+
83
+ it('returns empty string when field is missing', () => {
84
+ sessionStorage.setItem(DEFAULT_AUTH_STORAGE_KEY, JSON.stringify({ token: 'tok' }))
85
+ expect(resolveAnswerAgentCallbackUrl()).toBe('')
86
+ })
87
+ })
@@ -0,0 +1,102 @@
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
+ export const DEFAULT_AUTH_ANSWER_AGENT_CALLBACK_URL_FIELD = 'answer_agent_callback_url'
13
+
14
+ function readStorage(
15
+ kind: 'sessionStorage' | 'localStorage',
16
+ ): Storage | null {
17
+ if (typeof window === 'undefined') return null
18
+ try {
19
+ return kind === 'localStorage' ? window.localStorage : window.sessionStorage
20
+ } catch {
21
+ return null
22
+ }
23
+ }
24
+
25
+ function readSessionJson(raw: string): Record<string, unknown> | null {
26
+ try {
27
+ const parsed = JSON.parse(raw) as unknown
28
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
29
+ return parsed as Record<string, unknown>
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+
35
+ function readTokenFromSessionJson(
36
+ raw: string,
37
+ tokenField: string,
38
+ ): string {
39
+ const parsed = readSessionJson(raw)
40
+ if (!parsed) return ''
41
+ const token = parsed[tokenField]
42
+ return typeof token === 'string'
43
+ ? token.trim().replace(/^bearer\s+/i, '').trim()
44
+ : ''
45
+ }
46
+
47
+ /** 从宿主登录缓存读取 answer_agent_callback_url(无则返回空串)。 */
48
+ export function resolveAnswerAgentCallbackUrl(
49
+ options: SkoponAuthStorageOptions & {
50
+ answerAgentCallbackUrlField?: string
51
+ } = {},
52
+ ): string {
53
+ const storageKind = options.storage ?? 'sessionStorage'
54
+ const storageKey = options.storageKey ?? DEFAULT_AUTH_STORAGE_KEY
55
+ const field =
56
+ options.answerAgentCallbackUrlField ?? DEFAULT_AUTH_ANSWER_AGENT_CALLBACK_URL_FIELD
57
+
58
+ const storage = readStorage(storageKind)
59
+ if (!storage) return ''
60
+
61
+ const raw = storage.getItem(storageKey)
62
+ if (!raw) return ''
63
+
64
+ const parsed = readSessionJson(raw)
65
+ if (!parsed) return ''
66
+
67
+ const url = parsed[field]
68
+ return typeof url === 'string' ? url.trim() : ''
69
+ }
70
+
71
+ /** 从宿主登录缓存读取 Bearer token,返回 Authorization 头(无 token 时返回空对象)。 */
72
+ export function resolveSkoponAuthHeaders(
73
+ options: SkoponAuthStorageOptions = {},
74
+ ): Record<string, string> {
75
+ const storageKind = options.storage ?? 'sessionStorage'
76
+ const storageKey = options.storageKey ?? DEFAULT_AUTH_STORAGE_KEY
77
+ const tokenField = options.tokenField ?? DEFAULT_AUTH_TOKEN_FIELD
78
+
79
+ const storage = readStorage(storageKind)
80
+ if (!storage) return {}
81
+
82
+ const raw = storage.getItem(storageKey)
83
+ if (!raw) return {}
84
+
85
+ const token = readTokenFromSessionJson(raw, tokenField)
86
+ return token ? { Authorization: `Bearer ${token}` } : {}
87
+ }
88
+
89
+ export interface ClientHeadersOptions {
90
+ getHeaders?: () => Record<string, string> | Promise<Record<string, string>>
91
+ authStorage?: SkoponAuthStorageOptions
92
+ }
93
+
94
+ /** 显式 getHeaders 优先;否则从本地登录缓存读取。 */
95
+ export async function resolveClientHeaders(
96
+ options: ClientHeadersOptions = {},
97
+ ): Promise<Record<string, string>> {
98
+ if (options.getHeaders) {
99
+ return (await options.getHeaders()) ?? {}
100
+ }
101
+ return resolveSkoponAuthHeaders(options.authStorage)
102
+ }
@@ -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
  })