@skopon-cool/form-sdk 0.1.1 → 0.1.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 (51) hide show
  1. package/README.md +47 -11
  2. package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
  3. package/dist/adapter/formFileAccept.d.ts.map +1 -1
  4. package/dist/adapter/formSchema.d.ts +1 -0
  5. package/dist/adapter/formSchema.d.ts.map +1 -1
  6. package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
  7. package/dist/catalog/useSkoponBoundField.d.ts +2 -0
  8. package/dist/catalog/useSkoponBoundField.d.ts.map +1 -1
  9. package/dist/client/formClient.d.ts.map +1 -1
  10. package/dist/components/AskUserFormCard.d.ts +3 -1
  11. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  12. package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -1
  13. package/dist/components/SkoponA2uiStreamRenderer.test.d.ts +2 -0
  14. package/dist/components/SkoponA2uiStreamRenderer.test.d.ts.map +1 -0
  15. package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
  16. package/dist/form-sdk.css +1 -1
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +941 -706
  20. package/dist/submit/buildCurlStatement.d.ts +8 -0
  21. package/dist/submit/buildCurlStatement.d.ts.map +1 -1
  22. package/dist/submit/intersectPayloadBlocksWithForm.d.ts +26 -0
  23. package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -0
  24. package/dist/submit/submitFormJson.d.ts.map +1 -1
  25. package/package.json +11 -6
  26. package/src/adapter/a2uiAdapter.test.ts +68 -0
  27. package/src/adapter/a2uiAdapter.ts +7 -3
  28. package/src/adapter/formFileAccept.test.ts +53 -0
  29. package/src/adapter/formFileAccept.ts +11 -2
  30. package/src/adapter/formSchema.test.ts +35 -0
  31. package/src/adapter/formSchema.ts +5 -1
  32. package/src/catalog/a2uiCustomCatalog.tsx +28 -5
  33. package/src/catalog/textFieldPreview.test.tsx +1 -1
  34. package/src/catalog/useSkoponBoundField.test.ts +62 -0
  35. package/src/catalog/useSkoponBoundField.ts +10 -1
  36. package/src/client/formClient.test.ts +83 -0
  37. package/src/client/formClient.ts +10 -2
  38. package/src/components/AskUserFormCard.tsx +136 -58
  39. package/src/components/SkoponA2uiStreamRenderer.test.ts +62 -0
  40. package/src/components/SkoponA2uiStreamRenderer.test.tsx +79 -0
  41. package/src/components/SkoponA2uiStreamRenderer.tsx +96 -15
  42. package/src/components/SkoponFormRenderer.tsx +10 -7
  43. package/src/index.ts +11 -2
  44. package/src/styles/index.css +5 -0
  45. package/src/submit/buildCurlStatement.ts +49 -0
  46. package/src/submit/intersectPayloadBlocksWithForm.ts +175 -0
  47. package/src/submit/submit.test.ts +170 -10
  48. package/src/submit/submitFormJson.ts +20 -1
  49. package/dist/submit/intersectPayloadWithForm.d.ts +0 -17
  50. package/dist/submit/intersectPayloadWithForm.d.ts.map +0 -1
  51. package/src/submit/intersectPayloadWithForm.ts +0 -54
@@ -1,2 +1,10 @@
1
1
  export declare function buildCurlStatement(payload: unknown, callbackUrl?: string | null): string;
2
+ /** 构建含行注释的 JSON body:extra 段标注「额外字段(未在卡片展示)」 */
3
+ export declare function buildAskUserCurlBodyJson(cardValues: Record<string, unknown>, extraValues: Record<string, unknown>): string;
4
+ export interface BuildAskUserCurlStatementOptions {
5
+ cardValues: Record<string, unknown>;
6
+ extraValues?: Record<string, unknown>;
7
+ callbackUrl?: string | null;
8
+ }
9
+ export declare function buildAskUserCurlStatement({ cardValues, extraValues, callbackUrl, }: BuildAskUserCurlStatementOptions): string;
2
10
  //# sourceMappingURL=buildCurlStatement.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"buildCurlStatement.d.ts","sourceRoot":"","sources":["../../src/submit/buildCurlStatement.ts"],"names":[],"mappings":"AAIA,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAQxF"}
1
+ {"version":3,"file":"buildCurlStatement.d.ts","sourceRoot":"","sources":["../../src/submit/buildCurlStatement.ts"],"names":[],"mappings":"AAIA,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAQxF;AAMD,gDAAgD;AAChD,wBAAgB,wBAAwB,CACtC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACnC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,MAAM,CAmBR;AAED,MAAM,WAAW,gCAAgC;IAC/C,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACnC,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACrC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC5B;AAED,wBAAgB,yBAAyB,CAAC,EACxC,UAAU,EACV,WAAgB,EAChB,WAAW,GACZ,EAAE,gCAAgC,GAAG,MAAM,CAQ3C"}
@@ -0,0 +1,26 @@
1
+ import type { FormBlock, FormSchema } from '../types/index';
2
+ export type PayloadBlocksDefinition = Pick<FormSchema, 'title' | 'description' | 'blocks'>;
3
+ export interface PayloadBlocksIntersection {
4
+ matchedBlocks: FormBlock[];
5
+ extraBlocks: FormBlock[];
6
+ /** 按 payload 顺序:layout + matched input(不含 extra input) */
7
+ renderBlocks: FormBlock[];
8
+ title: string;
9
+ description: string;
10
+ }
11
+ /** 解析 ask_user payload 为 blocksJson 形(title / description / blocks) */
12
+ export declare function parsePayloadBlocksJson(payload: unknown): PayloadBlocksDefinition | null;
13
+ /**
14
+ * payload.blocks 与 vt_forms 按 input 块 name 取交集用于渲染;
15
+ * payload 中 form 不存在的块归入 extraBlocks(不渲染,提交时写入 curl)。
16
+ */
17
+ export declare function intersectPayloadBlocksWithForm(payloadDef: PayloadBlocksDefinition, formDefinition: FormSchema | undefined): PayloadBlocksIntersection;
18
+ /** 从 extraBlocks 提取提交值(优先 defaultValue) */
19
+ export declare function extractExtraBlockValues(extraBlocks: FormBlock[]): Record<string, unknown>;
20
+ /** payload 是否含至少一个具 name 的 input 块(可用于卡片 fallback) */
21
+ export declare function payloadHasInputBlocks(payloadDef: PayloadBlocksDefinition): boolean;
22
+ /** 交集为空时 fallback:payload 中可上屏的 blocks(layout + input) */
23
+ export declare function getPayloadRenderableBlocks(payloadDef: PayloadBlocksDefinition): FormBlock[];
24
+ /** payload 中所有 input 块 field name(fallback 提交用) */
25
+ export declare function getPayloadInputFieldNames(payloadDef: PayloadBlocksDefinition): string[];
26
+ //# sourceMappingURL=intersectPayloadBlocksWithForm.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"intersectPayloadBlocksWithForm.d.ts","sourceRoot":"","sources":["../../src/submit/intersectPayloadBlocksWithForm.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAG3D,MAAM,MAAM,uBAAuB,GAAG,IAAI,CAAC,UAAU,EAAE,OAAO,GAAG,aAAa,GAAG,QAAQ,CAAC,CAAA;AAE1F,MAAM,WAAW,yBAAyB;IACxC,aAAa,EAAE,SAAS,EAAE,CAAA;IAC1B,WAAW,EAAE,SAAS,EAAE,CAAA;IACxB,0DAA0D;IAC1D,YAAY,EAAE,SAAS,EAAE,CAAA;IACzB,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;CACpB;AAqDD,uEAAuE;AACvE,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,GAAG,uBAAuB,GAAG,IAAI,CAUvF;AAED;;;GAGG;AACH,wBAAgB,8BAA8B,CAC5C,UAAU,EAAE,uBAAuB,EACnC,cAAc,EAAE,UAAU,GAAG,SAAS,GACrC,yBAAyB,CAqC3B;AAmBD,2CAA2C;AAC3C,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,SAAS,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASzF;AAED,sDAAsD;AACtD,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,uBAAuB,GAAG,OAAO,CAIlF;AAED,0DAA0D;AAC1D,wBAAgB,0BAA0B,CAAC,UAAU,EAAE,uBAAuB,GAAG,SAAS,EAAE,CAK3F;AAED,mDAAmD;AACnD,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,uBAAuB,GAAG,MAAM,EAAE,CAIvF"}
@@ -1 +1 @@
1
- {"version":3,"file":"submitFormJson.d.ts","sourceRoot":"","sources":["../../src/submit/submitFormJson.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,qBAAqB;IACpC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAA;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACjC;AAED,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,OAAO,CAAA;IACX,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,OAAO,CAAA;CACf;AAED,wBAAsB,cAAc,CAClC,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,OAAO,EAChB,OAAO,CAAC,EAAE,qBAAqB,GAC9B,OAAO,CAAC,oBAAoB,CAAC,CA8B/B;AAED,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAErE"}
1
+ {"version":3,"file":"submitFormJson.d.ts","sourceRoot":"","sources":["../../src/submit/submitFormJson.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,qBAAqB;IACpC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAA;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACjC;AAED,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,OAAO,CAAA;IACX,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,OAAO,CAAA;CACf;AAED,wBAAsB,cAAc,CAClC,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,OAAO,EAChB,OAAO,CAAC,EAAE,qBAAqB,GAC9B,OAAO,CAAC,oBAAoB,CAAC,CA8B/B;AAED,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAqBrE"}
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.1.1",
6
+ "version": "0.1.3",
7
7
  "description": "Skopon form rendering SDK (A2UI + form_definition) with submit helpers",
8
8
  "type": "module",
9
9
  "sideEffects": [
@@ -29,31 +29,36 @@
29
29
  "test": "vitest run",
30
30
  "test:watch": "vitest"
31
31
  },
32
+ "dependencies": {
33
+ "dayjs": "^1.11.13",
34
+ "zod": "^3.24.0"
35
+ },
32
36
  "peerDependencies": {
33
- "@ant-design/icons": "^6.0.0",
34
37
  "@a2ui/react": "^0.10.1",
35
38
  "@a2ui/web_core": "^0.10.2",
39
+ "@ant-design/icons": "^6.0.0",
36
40
  "antd": "^6.4.3",
37
- "dayjs": "^1.11.0",
38
41
  "react": "^18.0.0 || ^19.0.0",
39
42
  "react-dom": "^18.0.0 || ^19.0.0"
40
43
  },
41
44
  "devDependencies": {
42
- "@ant-design/icons": "^6.2.3",
43
45
  "@a2ui/react": "^0.10.1",
44
46
  "@a2ui/web_core": "^0.10.2",
47
+ "@ant-design/icons": "^6.2.3",
48
+ "@testing-library/react": "^16.3.2",
45
49
  "@types/react": "^19.1.2",
46
50
  "@types/react-dom": "^19.1.2",
47
51
  "@vitejs/plugin-react": "^4.5.2",
48
52
  "antd": "^6.4.3",
49
53
  "dayjs": "^1.11.13",
54
+ "happy-dom": "^17.6.3",
55
+ "jsdom": "^29.1.1",
50
56
  "react": "^19.1.0",
51
57
  "react-dom": "^19.1.0",
52
58
  "typescript": "~5.8.3",
53
59
  "vite": "^6.3.5",
54
60
  "vite-plugin-dts": "^4.5.4",
55
61
  "vitest": "^3.2.4",
56
- "jsdom": "^29.1.1",
57
- "@testing-library/react": "^16.3.2"
62
+ "zod": "^3.24.0"
58
63
  }
59
64
  }
@@ -1,3 +1,6 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
1
4
  import { describe, it, expect } from 'vitest'
2
5
  import type { FormSchema } from '../types/index'
3
6
  import { a2uiToBlocks, blocksToA2ui, isA2uiSurfaceEmpty, surfaceDocToMessages } from './a2uiAdapter'
@@ -120,6 +123,71 @@ describe('a2uiAdapter blocks <-> surface', () => {
120
123
  expect(back.blocks.find((b) => b.name === 'tags')?.type).toBe('multiselect')
121
124
  })
122
125
 
126
+ it('blocksToA2ui stores single-select and radio defaults as strings in dataModel', () => {
127
+ const doc = blocksToA2ui(
128
+ {
129
+ title: '',
130
+ description: '',
131
+ blocks: [
132
+ {
133
+ id: 'b-city',
134
+ type: 'select',
135
+ name: 'city',
136
+ label: '城市',
137
+ defaultValue: 'bj',
138
+ options: [{ label: '北京', value: 'bj' }],
139
+ },
140
+ {
141
+ id: 'b-gender',
142
+ type: 'radio',
143
+ name: 'gender',
144
+ label: '性别',
145
+ defaultValue: 'm',
146
+ options: [
147
+ { label: '男', value: 'm' },
148
+ { label: '女', value: 'f' },
149
+ ],
150
+ },
151
+ {
152
+ id: 'b-tags',
153
+ type: 'multiselect',
154
+ name: 'tags',
155
+ label: '标签',
156
+ defaultValue: ['a', 'b'],
157
+ options: [
158
+ { label: 'A', value: 'a' },
159
+ { label: 'B', value: 'b' },
160
+ ],
161
+ },
162
+ ],
163
+ },
164
+ { includeHeader: false },
165
+ )
166
+ expect(doc.dataModel?.city).toBe('bj')
167
+ expect(doc.dataModel?.gender).toBe('m')
168
+ expect(doc.dataModel?.tags).toEqual(['a', 'b'])
169
+ })
170
+
171
+ it('blocksToA2ui stores toggle string default "true" as true in dataModel', () => {
172
+ const doc = blocksToA2ui(
173
+ {
174
+ title: '',
175
+ description: '',
176
+ blocks: [
177
+ {
178
+ id: 'b-sub',
179
+ type: 'toggle',
180
+ name: 'subscribe',
181
+ label: '订阅',
182
+ defaultValue: 'true',
183
+ },
184
+ ],
185
+ },
186
+ { includeHeader: false },
187
+ )
188
+ expect(doc.dataModel?.subscribe).toBe(true)
189
+ })
190
+
123
191
  it('round-trips textarea placeholder through blocksToA2ui', () => {
124
192
  const doc = blocksToA2ui(
125
193
  {
@@ -8,6 +8,7 @@ import type {
8
8
  FormSchema,
9
9
  } from '../types/index'
10
10
  import { generateId } from './id'
11
+ import { coerceToggleValue } from './formSchema'
11
12
  import { getMediaUrls, normalizeMediaSize } from './formMedia'
12
13
  import { syncFormDefinition } from './formSchema'
13
14
 
@@ -95,14 +96,17 @@ function readMediaBlock(
95
96
  function defaultValueForBlock(block: FormBlock): unknown {
96
97
  switch (block.type) {
97
98
  case 'toggle':
98
- return typeof block.defaultValue === 'boolean' ? block.defaultValue : false
99
+ return coerceToggleValue(block.defaultValue)
99
100
  case 'multiselect':
100
101
  case 'checkbox':
101
- case 'select':
102
- case 'radio':
103
102
  if (Array.isArray(block.defaultValue)) return block.defaultValue
104
103
  if (typeof block.defaultValue === 'string' && block.defaultValue) return [block.defaultValue]
105
104
  return []
105
+ case 'select':
106
+ case 'radio':
107
+ if (Array.isArray(block.defaultValue)) return block.defaultValue[0] ?? ''
108
+ if (typeof block.defaultValue === 'string') return block.defaultValue
109
+ return ''
106
110
  default:
107
111
  if (typeof block.defaultValue === 'string') return block.defaultValue
108
112
  return ''
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+ import { describe, it, expect } from 'vitest'
5
+ import {
6
+ buildFileAcceptAttribute,
7
+ filterFileAcceptExtensionsForTypes,
8
+ formatFileAcceptSummary,
9
+ } from './formFileAccept'
10
+ import { normalizeFormDefinition } from './formSchema'
11
+
12
+ describe('filterFileAcceptExtensionsForTypes without types', () => {
13
+ it('keeps whitelisted extensions when fileAcceptTypes is empty', () => {
14
+ expect(filterFileAcceptExtensionsForTypes(undefined, ['.pdf', '.docx'])).toEqual([
15
+ '.pdf',
16
+ '.docx',
17
+ ])
18
+ })
19
+
20
+ it('drops unknown extensions when fileAcceptTypes is empty', () => {
21
+ expect(filterFileAcceptExtensionsForTypes([], ['.pdf', '.exe'])).toEqual(['.pdf'])
22
+ })
23
+ })
24
+
25
+ describe('buildFileAcceptAttribute without types', () => {
26
+ it('preserves extension-only accept attribute', () => {
27
+ expect(buildFileAcceptAttribute(undefined, ['.pdf', '.docx'])).toBe('.pdf,.docx')
28
+ })
29
+
30
+ it('formatFileAcceptSummary shows extension labels without types', () => {
31
+ expect(formatFileAcceptSummary(undefined, ['.pdf'])).toBe('.pdf')
32
+ })
33
+ })
34
+
35
+ describe('normalizeFormDefinition file extensions without types', () => {
36
+ it('retains whitelisted extensions on file blocks', () => {
37
+ const def = normalizeFormDefinition({
38
+ title: '',
39
+ description: '',
40
+ blocks: [
41
+ {
42
+ id: 'b1',
43
+ type: 'file',
44
+ name: 'attach',
45
+ label: '附件',
46
+ fileAcceptExtensions: ['.pdf', '.docx'],
47
+ },
48
+ ],
49
+ jsonSchema: {},
50
+ })
51
+ expect(def.blocks[0]?.fileAcceptExtensions).toEqual(['.pdf', '.docx'])
52
+ })
53
+ })
@@ -103,6 +103,10 @@ const FILE_EXTENSION_LABELS = Object.fromEntries(
103
103
 
104
104
  const FILE_TYPE_VALUE_SET = new Set(FORM_FILE_TYPE_OPTIONS.map((item) => item.value))
105
105
 
106
+ const KNOWN_FILE_EXTENSION_SET = new Set(
107
+ FORM_FILE_EXTENSION_OPTIONS.map((item) => item.value),
108
+ )
109
+
106
110
  function resolveFileAcceptTypeValue(value: string): string | null {
107
111
  const trimmed = value.trim()
108
112
  if (!trimmed) return null
@@ -164,10 +168,15 @@ export function filterFileAcceptExtensionsForTypes(
164
168
  types?: string[],
165
169
  extensions?: string[],
166
170
  ): string[] {
171
+ const normalizedExtensions = normalizeFileAcceptExtensions(extensions)
172
+ const normalizedTypes = normalizeFileAcceptTypes(types)
173
+ if (normalizedTypes.length === 0) {
174
+ return normalizedExtensions.filter((item) => KNOWN_FILE_EXTENSION_SET.has(item))
175
+ }
167
176
  const allowed = new Set(
168
- getFileExtensionOptionsForTypes(types).map((item) => item.value),
177
+ getFileExtensionOptionsForTypes(normalizedTypes).map((item) => item.value),
169
178
  )
170
- return normalizeFileAcceptExtensions(extensions).filter((item) => allowed.has(item))
179
+ return normalizedExtensions.filter((item) => allowed.has(item))
171
180
  }
172
181
 
173
182
  export function buildFileAcceptAttribute(
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+ import { describe, it, expect } from 'vitest'
5
+ import { coerceToggleValue, normalizeFormDefinition } from './formSchema'
6
+
7
+ describe('coerceToggleValue', () => {
8
+ it('accepts boolean and string true', () => {
9
+ expect(coerceToggleValue(true)).toBe(true)
10
+ expect(coerceToggleValue('true')).toBe(true)
11
+ expect(coerceToggleValue(false)).toBe(false)
12
+ expect(coerceToggleValue('false')).toBe(false)
13
+ expect(coerceToggleValue(undefined)).toBe(false)
14
+ })
15
+ })
16
+
17
+ describe('normalizeFormDefinition toggle default', () => {
18
+ it('normalizes string "true" default to boolean true', () => {
19
+ const def = normalizeFormDefinition({
20
+ title: '',
21
+ description: '',
22
+ blocks: [
23
+ {
24
+ id: 'b1',
25
+ type: 'toggle',
26
+ name: 'subscribe',
27
+ label: '订阅',
28
+ defaultValue: 'true',
29
+ },
30
+ ],
31
+ jsonSchema: {},
32
+ })
33
+ expect(def.blocks[0]?.defaultValue).toBe(true)
34
+ })
35
+ })
@@ -13,6 +13,10 @@ import {
13
13
 
14
14
  const DEFAULT_MEDIA_URL_MAX = 10
15
15
 
16
+ export function coerceToggleValue(raw: unknown): boolean {
17
+ return raw === true || raw === 'true'
18
+ }
19
+
16
20
  function normalizeFileUploadCount(raw: unknown, fallback = 1): number {
17
21
  const num = typeof raw === 'number' ? raw : Number(raw)
18
22
  if (!Number.isFinite(num)) return fallback
@@ -24,7 +28,7 @@ function normalizeDefaultValue(
24
28
  raw: FormBlock['defaultValue'],
25
29
  ): FormBlock['defaultValue'] {
26
30
  if (raw === undefined) return undefined
27
- if (type === 'toggle') return raw === true
31
+ if (type === 'toggle') return coerceToggleValue(raw)
28
32
  if (type === 'multiselect' || type === 'checkbox') {
29
33
  if (Array.isArray(raw)) return raw.map(String)
30
34
  if (typeof raw === 'string' && raw) return [raw]
@@ -20,6 +20,7 @@ import {
20
20
  } from 'antd'
21
21
  import dayjs, { type Dayjs } from 'dayjs'
22
22
  import type { FormFilePlaceholderIcon, FormMediaSize } from '../types/index'
23
+ import { coerceToggleValue } from '../adapter/formSchema'
23
24
  import {
24
25
  buildFileAcceptAttribute,
25
26
  formatFileAcceptSummary,
@@ -30,6 +31,7 @@ import { useA2uiPreviewMode } from './a2uiPreviewContext'
30
31
  import {
31
32
  asOptionalString,
32
33
  asStringArray,
34
+ readBoundFieldValue,
33
35
  useSkoponBoundField,
34
36
  } from './useSkoponBoundField'
35
37
 
@@ -359,7 +361,7 @@ function ToggleSwitchPreview({ context }: { context: ComponentContext }) {
359
361
  <Switch
360
362
  disabled={!interactive}
361
363
  className="form-block-preview-control"
362
- checked={value === true}
364
+ checked={coerceToggleValue(value)}
363
365
  onChange={(checked) => setValue(checked)}
364
366
  />
365
367
  </div>
@@ -495,14 +497,18 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
495
497
  )
496
498
  ? (props.filePlaceholderIcon as FormFilePlaceholderIcon)
497
499
  : 'document'
500
+ const minCount = typeof props.minCount === 'number' ? Math.max(0, props.minCount) : 0
498
501
  const maxCount = typeof props.maxCount === 'number' ? props.maxCount : 1
502
+ const hasMaxLimit = maxCount > 0
499
503
  const selectedNames = asStringArray(value)
504
+ const atMaxLimit = hasMaxLimit && selectedNames.length >= maxCount
505
+ const belowMinCount = selectedNames.length < minCount
500
506
 
501
507
  return (
502
508
  <div className="form-block-preview">
503
509
  {label ? <div className="form-block-preview-label">{label}</div> : null}
504
510
  <Upload.Dragger
505
- disabled={!interactive}
511
+ disabled={!interactive || atMaxLimit}
506
512
  accept={buildFileAcceptAttribute(fileAcceptTypes, fileAcceptExtensions)}
507
513
  className="form-file-upload-preview"
508
514
  showUploadList={interactive && selectedNames.length > 0}
@@ -517,12 +523,24 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
517
523
  }
518
524
  beforeUpload={(file) => {
519
525
  if (!interactive) return Upload.LIST_IGNORE
520
- const next = [...selectedNames, file.name].slice(0, Math.max(1, maxCount))
526
+ const currentNames = asStringArray(readBoundFieldValue(context))
527
+ if (hasMaxLimit && currentNames.length >= maxCount) return Upload.LIST_IGNORE
528
+ const next = hasMaxLimit
529
+ ? [...currentNames, file.name].slice(0, maxCount)
530
+ : [...currentNames, file.name]
521
531
  setValue(next)
522
532
  return false
523
533
  }}
524
534
  onRemove={(file) => {
525
- setValue(selectedNames.filter((name) => name !== file.name))
535
+ const currentNames = asStringArray(readBoundFieldValue(context))
536
+ const indexMatch = /^(\d+)-/.exec(file.uid ?? '')
537
+ const index = indexMatch ? Number(indexMatch[1]) : -1
538
+ const next =
539
+ index >= 0 && index < currentNames.length
540
+ ? currentNames.filter((_, i) => i !== index)
541
+ : currentNames.filter((name) => name !== file.name)
542
+ if (next.length < minCount) return false
543
+ setValue(next)
526
544
  }}
527
545
  >
528
546
  <p className="form-file-upload-preview-icon">
@@ -532,6 +550,9 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
532
550
  </Upload.Dragger>
533
551
  <Typography.Text type="secondary" className="form-block-preview-help">
534
552
  允许:{acceptSummary ?? '全部类型'}
553
+ {minCount > 0 ? `;至少 ${minCount} 个文件` : ''}
554
+ {hasMaxLimit ? `;最多 ${maxCount} 个文件` : ''}
555
+ {belowMinCount && interactive ? `(当前 ${selectedNames.length} 个,未达下限)` : ''}
535
556
  </Typography.Text>
536
557
  </div>
537
558
  )
@@ -567,13 +588,15 @@ function TextFieldPreview({ context }: { context: ComponentContext }) {
567
588
  />
568
589
  )
569
590
  } else if (variant === 'number') {
591
+ const numericValue =
592
+ stringValue && Number.isFinite(Number(stringValue)) ? Number(stringValue) : undefined
570
593
  control = (
571
594
  <InputNumber
572
595
  disabled={disabled}
573
596
  className={controlClassName}
574
597
  style={{ width: '100%' }}
575
598
  placeholder={placeholder || '数字'}
576
- value={stringValue ? Number(stringValue) : undefined}
599
+ value={numericValue}
577
600
  onChange={(next) => setValue(next == null ? '' : String(next))}
578
601
  />
579
602
  )
@@ -1,4 +1,4 @@
1
- // @vitest-environment jsdom
1
+ // @vitest-environment happy-dom
2
2
  import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'
3
3
 
4
4
  vi.mock('@a2ui/web_core/v0_9/basic_catalog', async (importOriginal) => {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+ import { describe, it, expect } from 'vitest'
5
+ import type { ComponentContext } from '@a2ui/web_core/v0_9'
6
+ import { asStringArray, readBoundFieldValue } from './useSkoponBoundField'
7
+
8
+ function createMockContext(initial: unknown = []): {
9
+ context: ComponentContext
10
+ getStore: () => unknown
11
+ } {
12
+ const store = new Map<string, unknown>([['/files', initial]])
13
+ const binding = { path: '/files' }
14
+ const context = {
15
+ componentModel: {
16
+ properties: { value: binding },
17
+ },
18
+ dataContext: {
19
+ resolveDynamicValue: (b: typeof binding) => store.get(b.path),
20
+ set: (path: string, value: unknown) => {
21
+ store.set(path, value)
22
+ },
23
+ subscribeDynamicValue: () => ({ unsubscribe: () => {} }),
24
+ },
25
+ } as unknown as ComponentContext
26
+ return {
27
+ context,
28
+ getStore: () => store.get('/files'),
29
+ }
30
+ }
31
+
32
+ describe('readBoundFieldValue', () => {
33
+ it('reads current bound value from dataContext', () => {
34
+ const { context } = createMockContext(['a.pdf'])
35
+ expect(asStringArray(readBoundFieldValue(context))).toEqual(['a.pdf'])
36
+ })
37
+
38
+ it('supports sequential appends without stale reads', () => {
39
+ const { context, getStore } = createMockContext([])
40
+ const append = (fileName: string) => {
41
+ const current = asStringArray(readBoundFieldValue(context))
42
+ context.dataContext.set('/files', [...current, fileName])
43
+ }
44
+ append('first.pdf')
45
+ append('second.pdf')
46
+ expect(getStore()).toEqual(['first.pdf', 'second.pdf'])
47
+ })
48
+ })
49
+
50
+ describe('file upload remove by uid index', () => {
51
+ it('removes only the file at the uid index when names duplicate', () => {
52
+ const currentNames = ['dup.txt', 'dup.txt', 'other.txt']
53
+ const uid = '1-dup.txt'
54
+ const indexMatch = /^(\d+)-/.exec(uid)
55
+ const index = indexMatch ? Number(indexMatch[1]) : -1
56
+ const next =
57
+ index >= 0 && index < currentNames.length
58
+ ? currentNames.filter((_, i) => i !== index)
59
+ : currentNames.filter((name) => name !== 'dup.txt')
60
+ expect(next).toEqual(['dup.txt', 'other.txt'])
61
+ })
62
+ })
@@ -11,6 +11,15 @@ function bindingPath(value: unknown): string | null {
11
11
  return null
12
12
  }
13
13
 
14
+ /** 从 A2UI 组件 context 读取当前绑定字段值(不订阅更新,适合事件回调内读最新值)。 */
15
+ export function readBoundFieldValue(context: ComponentContext, field = 'value'): unknown {
16
+ const props = context.componentModel.properties as Record<string, unknown>
17
+ const binding = props[field] as DynamicBinding | undefined
18
+ const path = bindingPath(binding)
19
+ if (!path || binding === undefined) return undefined
20
+ return context.dataContext.resolveDynamicValue(binding)
21
+ }
22
+
14
23
  /** 将 skopon 预览控件绑定到 A2UI surface dataModel(与 basicCatalog binder 行为一致)。 */
15
24
  export function useSkoponBoundField(context: ComponentContext, field = 'value') {
16
25
  const props = context.componentModel.properties as Record<string, unknown>
@@ -31,7 +40,7 @@ export function useSkoponBoundField(context: ComponentContext, field = 'value')
31
40
  return context.dataContext.resolveDynamicValue(binding)
32
41
  }, [context, binding, path])
33
42
 
34
- const value = path ? useSyncExternalStore(subscribe, getSnapshot, getSnapshot) : undefined
43
+ const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
35
44
 
36
45
  const setValue = useCallback(
37
46
  (next: unknown) => {
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+ import { describe, it, expect, vi } from 'vitest'
5
+ import { createFormClient } from './formClient'
6
+
7
+ function mockResponse(
8
+ init: Partial<Response> & { json?: () => Promise<unknown>; text?: () => Promise<string> },
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: init.text ?? (async () => ''),
16
+ } as Response
17
+ }
18
+
19
+ describe('createFormClient', () => {
20
+ it('fetchDetail returns mapped form definition on success', async () => {
21
+ const fetchMock = vi.fn(async () =>
22
+ mockResponse({
23
+ json: async () => ({
24
+ success: true,
25
+ data: {
26
+ form_id: 1,
27
+ form_unique_id: 'form-abc',
28
+ name: 'Test',
29
+ disabled: 0,
30
+ form_definition: {
31
+ title: 'T',
32
+ blocks: [{ id: 'b1', type: 'text', name: 'x', label: 'X' }],
33
+ },
34
+ },
35
+ }),
36
+ }),
37
+ ) as unknown as typeof fetch
38
+
39
+ const client = createFormClient({ fetch: fetchMock, baseUrl: '/api/v1', detailPath: '/dev/form/detail' })
40
+ const result = await client.fetchDetail({ formUniqueId: 'form-abc' })
41
+ expect(result.formUniqueId).toBe('form-abc')
42
+ expect(result.formId).toBe(1)
43
+ expect(result.disabled).toBe(false)
44
+ expect(result.formDefinition?.blocks).toHaveLength(1)
45
+ })
46
+
47
+ it('throws on non-JSON response', async () => {
48
+ const fetchMock = vi.fn(async () =>
49
+ mockResponse({
50
+ json: async () => {
51
+ throw new SyntaxError('Unexpected token')
52
+ },
53
+ }),
54
+ ) as unknown as typeof fetch
55
+
56
+ const client = createFormClient({ fetch: fetchMock })
57
+ await expect(client.fetchDetail({ formUniqueId: 'x' })).rejects.toThrow('响应非 JSON')
58
+ })
59
+
60
+ it('throws when envelope data is missing', async () => {
61
+ const fetchMock = vi.fn(async () =>
62
+ mockResponse({
63
+ json: async () => ({ success: true, data: null }),
64
+ }),
65
+ ) as unknown as typeof fetch
66
+
67
+ const client = createFormClient({ fetch: fetchMock })
68
+ await expect(client.fetchDetail({ formUniqueId: 'x' })).rejects.toThrow('响应缺少 data')
69
+ })
70
+
71
+ it('throws when API returns success false', async () => {
72
+ const fetchMock = vi.fn(async () =>
73
+ mockResponse({
74
+ ok: false,
75
+ status: 404,
76
+ json: async () => ({ success: false, message: '表单不存在' }),
77
+ }),
78
+ ) as unknown as typeof fetch
79
+
80
+ const client = createFormClient({ fetch: fetchMock })
81
+ await expect(client.fetchDetail({ formUniqueId: 'missing' })).rejects.toThrow('表单不存在')
82
+ })
83
+ })
@@ -27,11 +27,19 @@ interface ApiEnvelope<T> {
27
27
  }
28
28
 
29
29
  async function parseEnvelope<T>(response: Response): Promise<T> {
30
- const json = (await response.json()) as ApiEnvelope<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
+ }
31
36
  if (!response.ok || json.success === false) {
32
37
  throw new Error(json.message || `请求失败 (${response.status})`)
33
38
  }
34
- return json.data as T
39
+ if (json.data === undefined || json.data === null) {
40
+ throw new Error(json.message || `响应缺少 data (${response.status})`)
41
+ }
42
+ return json.data
35
43
  }
36
44
 
37
45
  export function createFormClient(options: FormClientOptions = {}): FormClient {