@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,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.0",
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": [
@@ -8,9 +8,8 @@ import type {
8
8
  FormSchema,
9
9
  } from '../types/index'
10
10
  import { generateId } from './id'
11
- import { coerceToggleValue } from './formSchema'
11
+ import { coerceToggleValue, syncFormDefinition } from './formSchema'
12
12
  import { getMediaUrls, normalizeMediaSize } from './formMedia'
13
- import { syncFormDefinition } from './formSchema'
14
13
  import { getBlockTypeAdapter, getComponentAdapter } from '../blocks/registry'
15
14
 
16
15
  /**
@@ -28,7 +27,7 @@ import { getBlockTypeAdapter, getComponentAdapter } from '../blocks/registry'
28
27
  const ROOT_ID = 'root'
29
28
  const TITLE_ID = '__title__'
30
29
  const DESC_ID = '__desc__'
31
- const EMAIL_REGEXP = '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$'
30
+ export const EMAIL_REGEXP = '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$'
32
31
 
33
32
  function pathFor(name: string): string {
34
33
  return `/${name}`
@@ -491,7 +490,7 @@ function applyDataModelDefaults(
491
490
  const raw = dataModel[name]
492
491
  if (raw === undefined) return block
493
492
  if (block.type === 'toggle') {
494
- return { ...block, defaultValue: raw === true || raw === 'true' }
493
+ return { ...block, defaultValue: coerceToggleValue(raw) }
495
494
  }
496
495
  return { ...block, defaultValue: raw as FormBlock['defaultValue'] }
497
496
  })
@@ -3,13 +3,18 @@
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
- it('accepts boolean and string true', () => {
9
+ it('accepts boolean, string, and numeric truthy/falsy values', () => {
9
10
  expect(coerceToggleValue(true)).toBe(true)
10
11
  expect(coerceToggleValue('true')).toBe(true)
12
+ expect(coerceToggleValue(1)).toBe(true)
13
+ expect(coerceToggleValue('1')).toBe(true)
11
14
  expect(coerceToggleValue(false)).toBe(false)
12
15
  expect(coerceToggleValue('false')).toBe(false)
16
+ expect(coerceToggleValue(0)).toBe(false)
17
+ expect(coerceToggleValue('0')).toBe(false)
13
18
  expect(coerceToggleValue(undefined)).toBe(false)
14
19
  })
15
20
  })
@@ -32,4 +37,27 @@ describe('normalizeFormDefinition toggle default', () => {
32
37
  })
33
38
  expect(def.blocks[0]?.defaultValue).toBe(true)
34
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
+ })
35
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,8 +13,17 @@ 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
- return raw === true || raw === 'true'
24
+ if (raw === true || raw === 'true' || raw === 1 || raw === '1') return true
25
+ if (raw === false || raw === 'false' || raw === 0 || raw === '0') return false
26
+ return false
18
27
  }
19
28
 
20
29
  function normalizeFileUploadCount(raw: unknown, fallback = 1): number {
@@ -178,7 +187,7 @@ export function normalizeFormDefinition(raw: FormSchema | undefined): FormSchema
178
187
  title: typeof raw.title === 'string' ? raw.title : '',
179
188
  description: typeof raw.description === 'string' ? raw.description : '',
180
189
  blocks,
181
- jsonSchema: {},
190
+ jsonSchema: normalizeJsonSchema(raw.jsonSchema),
182
191
  })
183
192
  }
184
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 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,
@@ -35,6 +36,7 @@ import { useA2uiPreviewMode } from './a2uiPreviewContext'
35
36
  import {
36
37
  asOptionalString,
37
38
  asStringArray,
39
+ normalizeSelectStoredValue,
38
40
  readBoundFieldValue,
39
41
  useSkoponBoundField,
40
42
  } from './useSkoponBoundField'
@@ -247,6 +249,11 @@ function SkoponSelectPreview({ context }: { context: ComponentContext }) {
247
249
  const options = readOptions(props.options)
248
250
  const isMultiple = readString(props.mode) === 'multiple'
249
251
 
252
+ useEffect(() => {
253
+ const normalized = normalizeSelectStoredValue(value, isMultiple)
254
+ if (normalized !== null) setValue(normalized)
255
+ }, [isMultiple, value, setValue])
256
+
250
257
  return (
251
258
  <div className="form-block-preview">
252
259
  {label ? <div className="form-block-preview-label">{label}</div> : null}
@@ -289,6 +296,13 @@ function ChoicePickerPreview({ context }: { context: ComponentContext }) {
289
296
  : isMultiple
290
297
  ? 'checkbox'
291
298
  : 'radio'
299
+ const isMultiplePicker =
300
+ effectiveType === 'multiselect' || effectiveType === 'checkbox'
301
+
302
+ useEffect(() => {
303
+ const normalized = normalizeSelectStoredValue(value, isMultiplePicker)
304
+ if (normalized !== null) setValue(normalized)
305
+ }, [isMultiplePicker, value, setValue])
292
306
 
293
307
  return (
294
308
  <div className="form-block-preview">
@@ -557,6 +571,7 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
557
571
  允许:{acceptSummary ?? '全部类型'}
558
572
  {minCount > 0 ? `;至少 ${minCount} 个文件` : ''}
559
573
  {hasMaxLimit ? `;最多 ${maxCount} 个文件` : ''}
574
+ ;当前仅收集文件名(不含文件内容,需宿主另行上传)
560
575
  {belowMinCount && interactive ? `(当前 ${selectedNames.length} 个,未达下限)` : ''}
561
576
  </Typography.Text>
562
577
  </div>
@@ -575,8 +590,22 @@ function TextFieldPreview({ context }: { context: ComponentContext }) {
575
590
  const label = readString(props.label)
576
591
  const placeholder = readString(props.placeholder)
577
592
  const variant = readString(props.variant) || 'shortText'
593
+ const validationRegexp = readString(props.validationRegexp)
578
594
  const stringValue = asOptionalString(value) ?? ''
579
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])
580
609
 
581
610
  const controlClassName = 'form-block-preview-control'
582
611
 
@@ -589,7 +618,13 @@ function TextFieldPreview({ context }: { context: ComponentContext }) {
589
618
  placeholder={placeholder || '长文本回答...'}
590
619
  autoSize={{ minRows: 2, maxRows: 6 }}
591
620
  value={stringValue}
592
- 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)}
593
628
  />
594
629
  )
595
630
  } else if (variant === 'number') {
@@ -622,7 +657,13 @@ function TextFieldPreview({ context }: { context: ComponentContext }) {
622
657
  className={controlClassName}
623
658
  placeholder={placeholder}
624
659
  value={stringValue}
625
- 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)}
626
667
  />
627
668
  )
628
669
  }
@@ -631,6 +672,11 @@ function TextFieldPreview({ context }: { context: ComponentContext }) {
631
672
  <div className="form-block-preview">
632
673
  {label ? <div className="form-block-preview-label">{label}</div> : null}
633
674
  {control}
675
+ {formatError ? (
676
+ <Typography.Text type="danger" className="form-block-preview-help">
677
+ {formatError}
678
+ </Typography.Text>
679
+ ) : null}
634
680
  </div>
635
681
  )
636
682
  }
@@ -90,7 +90,7 @@ function CaseCard({
90
90
  onClick={onToggle}
91
91
  >
92
92
  <div className="skopon-resume-select-card-avatar">
93
- <FileTextOutlined style={{ fontSize: 24, color: 'var(--color-primary, #1677ff)' }} />
93
+ <FileTextOutlined style={{ fontSize: 24, color: 'var(--color-primary, #208368)' }} />
94
94
  </div>
95
95
  <div className="skopon-resume-select-card-body">
96
96
  <div className="skopon-resume-select-card-name">{item.name}</div>
@@ -152,6 +152,7 @@ function SkoponCaseSelectPreview({ context }: { context: ComponentContext }) {
152
152
  setItems([])
153
153
  setTotal(0)
154
154
  setFetchError('')
155
+ setLoading(false)
155
156
  return
156
157
  }
157
158
  setLoading(true)
@@ -198,6 +198,7 @@ function SkoponResumeSelectPreview({ context }: { context: ComponentContext }) {
198
198
  setItems([])
199
199
  setTotal(0)
200
200
  setError('未配置简历搜索服务')
201
+ setLoading(false)
201
202
  return
202
203
  }
203
204
  setLoading(true)
@@ -0,0 +1,182 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'
3
+
4
+ vi.mock('@a2ui/web_core/v0_9/basic_catalog', async (importOriginal) => {
5
+ const actual = await importOriginal<typeof import('@a2ui/web_core/v0_9/basic_catalog')>()
6
+ return { ...actual, injectBasicCatalogStyles: vi.fn() }
7
+ })
8
+
9
+ vi.mock('@a2ui/react/styles', () => ({
10
+ injectStyles: vi.fn(),
11
+ removeStyles: vi.fn(),
12
+ }))
13
+
14
+ import { createElement } from 'react'
15
+ import { render, cleanup, waitFor } from '@testing-library/react'
16
+ import { blocksToA2ui } from '../adapter/a2uiAdapter'
17
+ import SkoponFormRenderer from '../components/SkoponFormRenderer'
18
+ import type { CaseSearchFn } from './caseSearchContext'
19
+ import type { ResumeSearchFn } from './resumeSearchContext'
20
+
21
+ beforeAll(() => {
22
+ class ResizeObserverMock {
23
+ observe() {}
24
+ unobserve() {}
25
+ disconnect() {}
26
+ }
27
+ vi.stubGlobal('ResizeObserver', ResizeObserverMock)
28
+ })
29
+
30
+ function buildCaseSurface(flowUniqueIds: string[]) {
31
+ return blocksToA2ui(
32
+ {
33
+ title: '',
34
+ description: '',
35
+ blocks: [
36
+ {
37
+ id: 'b-case',
38
+ type: 'case_multiselect',
39
+ name: 'cases',
40
+ label: '案例',
41
+ caseFilter: { flowUniqueIds, pageSize: 20 },
42
+ },
43
+ ],
44
+ },
45
+ { includeHeader: false },
46
+ )
47
+ }
48
+
49
+ function buildResumeSurface() {
50
+ return blocksToA2ui(
51
+ {
52
+ title: '',
53
+ description: '',
54
+ blocks: [
55
+ {
56
+ id: 'b-resume',
57
+ type: 'resume_multiselect',
58
+ name: 'resumes',
59
+ label: '简历',
60
+ resumeFilter: { names: [], agentUniqueIds: [], resumeUniqueIds: [], pageSize: 20 },
61
+ },
62
+ ],
63
+ },
64
+ { includeHeader: false },
65
+ )
66
+ }
67
+
68
+ describe('case/resume select loading state', () => {
69
+ afterEach(() => {
70
+ cleanup()
71
+ })
72
+
73
+ it('clears case loading when caseSearch is removed while fetch is in flight', async () => {
74
+ let resolveSearch!: (value: Awaited<ReturnType<CaseSearchFn>>) => void
75
+ const caseSearch = vi.fn(
76
+ () =>
77
+ new Promise<Awaited<ReturnType<CaseSearchFn>>>((resolve) => {
78
+ resolveSearch = resolve
79
+ }),
80
+ )
81
+
82
+ const doc = buildCaseSurface(['flow-1'])
83
+ const { rerender, queryByText } = render(
84
+ createElement(SkoponFormRenderer, {
85
+ doc,
86
+ surfaceId: 'case-loading-test',
87
+ caseSearch,
88
+ }),
89
+ )
90
+
91
+ expect(queryByText('加载案例…')).toBeTruthy()
92
+
93
+ rerender(
94
+ createElement(SkoponFormRenderer, {
95
+ doc,
96
+ surfaceId: 'case-loading-test',
97
+ caseSearch: null,
98
+ }),
99
+ )
100
+
101
+ await waitFor(() => {
102
+ expect(queryByText('加载案例…')).toBeNull()
103
+ })
104
+ expect(queryByText('未配置案例搜索服务')).toBeTruthy()
105
+
106
+ resolveSearch({ list: [], total: 0, page: 1, pageSize: 20 })
107
+ })
108
+
109
+ it('clears case loading when filter becomes invalid while fetch is in flight', async () => {
110
+ let resolveSearch!: (value: Awaited<ReturnType<CaseSearchFn>>) => void
111
+ const caseSearch = vi.fn(
112
+ () =>
113
+ new Promise<Awaited<ReturnType<CaseSearchFn>>>((resolve) => {
114
+ resolveSearch = resolve
115
+ }),
116
+ )
117
+
118
+ const validDoc = buildCaseSurface(['flow-1'])
119
+ const invalidDoc = buildCaseSurface([])
120
+
121
+ const { rerender, queryByText } = render(
122
+ createElement(SkoponFormRenderer, {
123
+ doc: validDoc,
124
+ surfaceId: 'case-filter-test',
125
+ caseSearch,
126
+ }),
127
+ )
128
+
129
+ expect(queryByText('加载案例…')).toBeTruthy()
130
+
131
+ rerender(
132
+ createElement(SkoponFormRenderer, {
133
+ doc: invalidDoc,
134
+ surfaceId: 'case-filter-test',
135
+ caseSearch,
136
+ }),
137
+ )
138
+
139
+ await waitFor(() => {
140
+ expect(queryByText('加载案例…')).toBeNull()
141
+ })
142
+ expect(queryByText('未配置案例列表筛选条件')).toBeTruthy()
143
+
144
+ resolveSearch({ list: [], total: 0, page: 1, pageSize: 20 })
145
+ })
146
+
147
+ it('clears resume loading when resumeSearch is removed while fetch is in flight', async () => {
148
+ let resolveSearch!: (value: Awaited<ReturnType<ResumeSearchFn>>) => void
149
+ const resumeSearch = vi.fn(
150
+ () =>
151
+ new Promise<Awaited<ReturnType<ResumeSearchFn>>>((resolve) => {
152
+ resolveSearch = resolve
153
+ }),
154
+ )
155
+
156
+ const doc = buildResumeSurface()
157
+ const { rerender, queryByText } = render(
158
+ createElement(SkoponFormRenderer, {
159
+ doc,
160
+ surfaceId: 'resume-loading-test',
161
+ resumeSearch,
162
+ }),
163
+ )
164
+
165
+ expect(queryByText('加载简历…')).toBeTruthy()
166
+
167
+ rerender(
168
+ createElement(SkoponFormRenderer, {
169
+ doc,
170
+ surfaceId: 'resume-loading-test',
171
+ resumeSearch: null,
172
+ }),
173
+ )
174
+
175
+ await waitFor(() => {
176
+ expect(queryByText('加载简历…')).toBeNull()
177
+ })
178
+ expect(queryByText('未配置简历搜索服务')).toBeTruthy()
179
+
180
+ resolveSearch({ list: [], total: 0, page: 1, pageSize: 20 })
181
+ })
182
+ })
@@ -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
  })
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { describe, it, expect } from 'vitest'
5
5
  import type { ComponentContext } from '@a2ui/web_core/v0_9'
6
- import { asStringArray, clampStringArraySelection, readBoundFieldValue, toggleStringArraySelection } from './useSkoponBoundField'
6
+ import { asStringArray, clampStringArraySelection, normalizeSelectStoredValue, readBoundFieldValue, toggleStringArraySelection } from './useSkoponBoundField'
7
7
 
8
8
  function createMockContext(initial: unknown = []): {
9
9
  context: ComponentContext
@@ -29,6 +29,19 @@ function createMockContext(initial: unknown = []): {
29
29
  }
30
30
  }
31
31
 
32
+ describe('normalizeSelectStoredValue', () => {
33
+ it('coerces string to array in multiple mode', () => {
34
+ expect(normalizeSelectStoredValue('a', true)).toEqual(['a'])
35
+ expect(normalizeSelectStoredValue(['a', 'b'], true)).toBeNull()
36
+ })
37
+
38
+ it('coerces array to string in single mode', () => {
39
+ expect(normalizeSelectStoredValue(['a', 'b'], false)).toBe('a')
40
+ expect(normalizeSelectStoredValue([], false)).toBe('')
41
+ expect(normalizeSelectStoredValue('a', false)).toBeNull()
42
+ })
43
+ })
44
+
32
45
  describe('readBoundFieldValue', () => {
33
46
  it('reads current bound value from dataContext', () => {
34
47
  const { context } = createMockContext(['a.pdf'])
@@ -65,6 +65,20 @@ export function asStringArray(value: unknown): string[] {
65
65
  return []
66
66
  }
67
67
 
68
+ /** 单选/多选模式切换时规范化 stored value;无需变更时返回 null。 */
69
+ export function normalizeSelectStoredValue(value: unknown, isMultiple: boolean): unknown | null {
70
+ if (isMultiple) {
71
+ if (Array.isArray(value)) return null
72
+ return asStringArray(value)
73
+ }
74
+ if (Array.isArray(value)) {
75
+ return value.length > 0 ? String(value[0]) : ''
76
+ }
77
+ if (value === undefined || value === null) return null
78
+ if (typeof value === 'string' || typeof value === 'number') return null
79
+ return ''
80
+ }
81
+
68
82
  export function readOptionalSelectCount(value: unknown): number | undefined {
69
83
  if (value === undefined || value === null) return undefined
70
84
  const num = typeof value === 'number' ? value : Number(value)
@@ -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
+ })