@skopon-cool/form-sdk 0.1.0 → 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 (53) hide show
  1. package/README.md +52 -12
  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/textFieldPreview.test.d.ts +2 -0
  8. package/dist/catalog/textFieldPreview.test.d.ts.map +1 -0
  9. package/dist/catalog/useSkoponBoundField.d.ts +2 -0
  10. package/dist/catalog/useSkoponBoundField.d.ts.map +1 -1
  11. package/dist/client/formClient.d.ts.map +1 -1
  12. package/dist/components/AskUserFormCard.d.ts +3 -1
  13. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  14. package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -1
  15. package/dist/components/SkoponA2uiStreamRenderer.test.d.ts +2 -0
  16. package/dist/components/SkoponA2uiStreamRenderer.test.d.ts.map +1 -0
  17. package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
  18. package/dist/form-sdk.css +1 -1
  19. package/dist/index.d.ts +2 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +998 -667
  22. package/dist/submit/buildCurlStatement.d.ts +8 -0
  23. package/dist/submit/buildCurlStatement.d.ts.map +1 -1
  24. package/dist/submit/intersectPayloadBlocksWithForm.d.ts +26 -0
  25. package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -0
  26. package/dist/submit/submitFormJson.d.ts.map +1 -1
  27. package/package.json +16 -5
  28. package/src/adapter/a2uiAdapter.test.ts +91 -0
  29. package/src/adapter/a2uiAdapter.ts +36 -7
  30. package/src/adapter/formFileAccept.test.ts +53 -0
  31. package/src/adapter/formFileAccept.ts +11 -2
  32. package/src/adapter/formSchema.test.ts +35 -0
  33. package/src/adapter/formSchema.ts +5 -1
  34. package/src/catalog/a2uiCustomCatalog.tsx +154 -5
  35. package/src/catalog/textFieldPreview.test.tsx +88 -0
  36. package/src/catalog/useSkoponBoundField.test.ts +62 -0
  37. package/src/catalog/useSkoponBoundField.ts +10 -1
  38. package/src/client/formClient.test.ts +83 -0
  39. package/src/client/formClient.ts +10 -2
  40. package/src/components/AskUserFormCard.tsx +136 -58
  41. package/src/components/SkoponA2uiStreamRenderer.test.ts +62 -0
  42. package/src/components/SkoponA2uiStreamRenderer.test.tsx +79 -0
  43. package/src/components/SkoponA2uiStreamRenderer.tsx +96 -15
  44. package/src/components/SkoponFormRenderer.tsx +10 -7
  45. package/src/index.ts +11 -2
  46. package/src/styles/index.css +5 -0
  47. package/src/submit/buildCurlStatement.ts +49 -0
  48. package/src/submit/intersectPayloadBlocksWithForm.ts +175 -0
  49. package/src/submit/submit.test.ts +170 -10
  50. package/src/submit/submitFormJson.ts +20 -1
  51. package/dist/submit/intersectPayloadWithForm.d.ts +0 -17
  52. package/dist/submit/intersectPayloadWithForm.d.ts.map +0 -1
  53. 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,9 +3,13 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.1.0",
6
+ "version": "0.1.3",
7
7
  "description": "Skopon form rendering SDK (A2UI + form_definition) with submit helpers",
8
8
  "type": "module",
9
+ "sideEffects": [
10
+ "*.css",
11
+ "dist/form-sdk.css"
12
+ ],
9
13
  "main": "./dist/index.js",
10
14
  "module": "./dist/index.js",
11
15
  "types": "./dist/index.d.ts",
@@ -25,29 +29,36 @@
25
29
  "test": "vitest run",
26
30
  "test:watch": "vitest"
27
31
  },
32
+ "dependencies": {
33
+ "dayjs": "^1.11.13",
34
+ "zod": "^3.24.0"
35
+ },
28
36
  "peerDependencies": {
29
- "@ant-design/icons": "^6.0.0",
30
37
  "@a2ui/react": "^0.10.1",
31
38
  "@a2ui/web_core": "^0.10.2",
39
+ "@ant-design/icons": "^6.0.0",
32
40
  "antd": "^6.4.3",
33
- "dayjs": "^1.11.0",
34
41
  "react": "^18.0.0 || ^19.0.0",
35
42
  "react-dom": "^18.0.0 || ^19.0.0"
36
43
  },
37
44
  "devDependencies": {
38
- "@ant-design/icons": "^6.2.3",
39
45
  "@a2ui/react": "^0.10.1",
40
46
  "@a2ui/web_core": "^0.10.2",
47
+ "@ant-design/icons": "^6.2.3",
48
+ "@testing-library/react": "^16.3.2",
41
49
  "@types/react": "^19.1.2",
42
50
  "@types/react-dom": "^19.1.2",
43
51
  "@vitejs/plugin-react": "^4.5.2",
44
52
  "antd": "^6.4.3",
45
53
  "dayjs": "^1.11.13",
54
+ "happy-dom": "^17.6.3",
55
+ "jsdom": "^29.1.1",
46
56
  "react": "^19.1.0",
47
57
  "react-dom": "^19.1.0",
48
58
  "typescript": "~5.8.3",
49
59
  "vite": "^6.3.5",
50
60
  "vite-plugin-dts": "^4.5.4",
51
- "vitest": "^3.2.4"
61
+ "vitest": "^3.2.4",
62
+ "zod": "^3.24.0"
52
63
  }
53
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,94 @@ 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
+
191
+ it('round-trips textarea placeholder through blocksToA2ui', () => {
192
+ const doc = blocksToA2ui(
193
+ {
194
+ title: '',
195
+ description: '',
196
+ blocks: [
197
+ {
198
+ id: 'b-bio',
199
+ type: 'textarea',
200
+ name: 'bio',
201
+ label: '简介',
202
+ placeholder: '请填写简介',
203
+ },
204
+ ],
205
+ },
206
+ { includeHeader: false },
207
+ )
208
+ const bioNode = doc.components.find((c) => c.id === 'b-bio')
209
+ expect(bioNode?.placeholder).toBe('请填写简介')
210
+ const back = a2uiToBlocks(doc)
211
+ expect(back.blocks.find((b) => b.name === 'bio')?.placeholder).toBe('请填写简介')
212
+ })
213
+
123
214
  it('surfaceDocToMessages emits createSurface + updateComponents + updateDataModel', () => {
124
215
  const doc = blocksToA2ui(makeDefinition())
125
216
  const messages = surfaceDocToMessages(doc, { surfaceId: 's1', catalogId: 'c1' })
@@ -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 ''
@@ -136,7 +140,14 @@ function blockToComponent(block: FormBlock): {
136
140
  case 'text':
137
141
  case 'tel':
138
142
  case 'url':
139
- return withData({ id, component: 'TextField', label, variant: 'shortText', value: { path } })
143
+ return withData({
144
+ id,
145
+ component: 'TextField',
146
+ label,
147
+ variant: 'shortText',
148
+ ...(block.placeholder ? { placeholder: block.placeholder } : {}),
149
+ value: { path },
150
+ })
140
151
  case 'email':
141
152
  return withData({
142
153
  id,
@@ -144,12 +155,27 @@ function blockToComponent(block: FormBlock): {
144
155
  label,
145
156
  variant: 'shortText',
146
157
  validationRegexp: EMAIL_REGEXP,
158
+ ...(block.placeholder ? { placeholder: block.placeholder } : {}),
147
159
  value: { path },
148
160
  })
149
161
  case 'textarea':
150
- return withData({ id, component: 'TextField', label, variant: 'longText', value: { path } })
162
+ return withData({
163
+ id,
164
+ component: 'TextField',
165
+ label,
166
+ variant: 'longText',
167
+ ...(block.placeholder ? { placeholder: block.placeholder } : {}),
168
+ value: { path },
169
+ })
151
170
  case 'number':
152
- return withData({ id, component: 'TextField', label, variant: 'number', value: { path } })
171
+ return withData({
172
+ id,
173
+ component: 'TextField',
174
+ label,
175
+ variant: 'number',
176
+ ...(block.placeholder ? { placeholder: block.placeholder } : {}),
177
+ value: { path },
178
+ })
153
179
 
154
180
  case 'select':
155
181
  return withData({
@@ -337,7 +363,10 @@ function componentToBlock(node: A2uiComponentNode): FormBlock | null {
337
363
  if (variant === 'longText') type = 'textarea'
338
364
  else if (variant === 'number') type = 'number'
339
365
  else if (asLiteral(node.validationRegexp) === EMAIL_REGEXP) type = 'email'
340
- return { ...base(type), name }
366
+ const block: FormBlock = { ...base(type), name }
367
+ const placeholder = asLiteral(node.placeholder)
368
+ if (placeholder) block.placeholder = placeholder
369
+ return block
341
370
  }
342
371
  case 'CheckBox':
343
372
  return { ...base('toggle'), name }
@@ -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]
@@ -1,13 +1,26 @@
1
1
  import { z } from 'zod'
2
+ import type { ReactNode } from 'react'
2
3
  import { Catalog, type ComponentContext } from '@a2ui/web_core/v0_9'
3
4
  import {
4
5
  basicCatalog,
5
6
  createBinderlessComponentImplementation,
6
7
  type ReactComponentImplementation,
7
8
  } from '@a2ui/react/v0_9'
8
- import { Checkbox, DatePicker, Radio, Select, Switch, TimePicker, Typography, Upload } from 'antd'
9
+ import {
10
+ Checkbox,
11
+ DatePicker,
12
+ Input,
13
+ InputNumber,
14
+ Radio,
15
+ Select,
16
+ Switch,
17
+ TimePicker,
18
+ Typography,
19
+ Upload,
20
+ } from 'antd'
9
21
  import dayjs, { type Dayjs } from 'dayjs'
10
22
  import type { FormFilePlaceholderIcon, FormMediaSize } from '../types/index'
23
+ import { coerceToggleValue } from '../adapter/formSchema'
11
24
  import {
12
25
  buildFileAcceptAttribute,
13
26
  formatFileAcceptSummary,
@@ -18,6 +31,7 @@ import { useA2uiPreviewMode } from './a2uiPreviewContext'
18
31
  import {
19
32
  asOptionalString,
20
33
  asStringArray,
34
+ readBoundFieldValue,
21
35
  useSkoponBoundField,
22
36
  } from './useSkoponBoundField'
23
37
 
@@ -37,8 +51,12 @@ const SKOPON_COMPONENT_NAMES = new Set([
37
51
  'CheckBox',
38
52
  'DateTimeInput',
39
53
  'FileUpload',
54
+ 'TextField',
55
+ 'Text',
40
56
  ])
41
57
 
58
+ const NON_MARKDOWN_TEXT_VARIANTS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'caption'])
59
+
42
60
  function getBasicSchema(name: string): z.ZodTypeAny {
43
61
  const impl = [...basicCatalog.components.values()].find((c) => c.name === name)
44
62
  if (!impl) throw new Error(`Missing basic catalog component: ${name}`)
@@ -343,7 +361,7 @@ function ToggleSwitchPreview({ context }: { context: ComponentContext }) {
343
361
  <Switch
344
362
  disabled={!interactive}
345
363
  className="form-block-preview-control"
346
- checked={value === true}
364
+ checked={coerceToggleValue(value)}
347
365
  onChange={(checked) => setValue(checked)}
348
366
  />
349
367
  </div>
@@ -479,14 +497,18 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
479
497
  )
480
498
  ? (props.filePlaceholderIcon as FormFilePlaceholderIcon)
481
499
  : 'document'
500
+ const minCount = typeof props.minCount === 'number' ? Math.max(0, props.minCount) : 0
482
501
  const maxCount = typeof props.maxCount === 'number' ? props.maxCount : 1
502
+ const hasMaxLimit = maxCount > 0
483
503
  const selectedNames = asStringArray(value)
504
+ const atMaxLimit = hasMaxLimit && selectedNames.length >= maxCount
505
+ const belowMinCount = selectedNames.length < minCount
484
506
 
485
507
  return (
486
508
  <div className="form-block-preview">
487
509
  {label ? <div className="form-block-preview-label">{label}</div> : null}
488
510
  <Upload.Dragger
489
- disabled={!interactive}
511
+ disabled={!interactive || atMaxLimit}
490
512
  accept={buildFileAcceptAttribute(fileAcceptTypes, fileAcceptExtensions)}
491
513
  className="form-file-upload-preview"
492
514
  showUploadList={interactive && selectedNames.length > 0}
@@ -501,12 +523,24 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
501
523
  }
502
524
  beforeUpload={(file) => {
503
525
  if (!interactive) return Upload.LIST_IGNORE
504
- 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]
505
531
  setValue(next)
506
532
  return false
507
533
  }}
508
534
  onRemove={(file) => {
509
- 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)
510
544
  }}
511
545
  >
512
546
  <p className="form-file-upload-preview-icon">
@@ -516,6 +550,9 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
516
550
  </Upload.Dragger>
517
551
  <Typography.Text type="secondary" className="form-block-preview-help">
518
552
  允许:{acceptSummary ?? '全部类型'}
553
+ {minCount > 0 ? `;至少 ${minCount} 个文件` : ''}
554
+ {hasMaxLimit ? `;最多 ${maxCount} 个文件` : ''}
555
+ {belowMinCount && interactive ? `(当前 ${selectedNames.length} 个,未达下限)` : ''}
519
556
  </Typography.Text>
520
557
  </div>
521
558
  )
@@ -526,6 +563,116 @@ const FileUploadImpl = createBinderlessComponentImplementation(
526
563
  FileUploadPreview,
527
564
  )
528
565
 
566
+ function TextFieldPreview({ context }: { context: ComponentContext }) {
567
+ const { interactive } = useA2uiPreviewMode()
568
+ const { value, setValue } = useSkoponBoundField(context)
569
+ const props = context.componentModel.properties as Record<string, unknown>
570
+ const label = readString(props.label)
571
+ const placeholder = readString(props.placeholder)
572
+ const variant = readString(props.variant) || 'shortText'
573
+ const stringValue = asOptionalString(value) ?? ''
574
+ const disabled = !interactive
575
+
576
+ const controlClassName = 'form-block-preview-control'
577
+
578
+ let control: ReactNode
579
+ if (variant === 'longText') {
580
+ control = (
581
+ <Input.TextArea
582
+ disabled={disabled}
583
+ className={controlClassName}
584
+ placeholder={placeholder || '长文本回答...'}
585
+ autoSize={{ minRows: 2, maxRows: 6 }}
586
+ value={stringValue}
587
+ onChange={(e) => setValue(e.target.value)}
588
+ />
589
+ )
590
+ } else if (variant === 'number') {
591
+ const numericValue =
592
+ stringValue && Number.isFinite(Number(stringValue)) ? Number(stringValue) : undefined
593
+ control = (
594
+ <InputNumber
595
+ disabled={disabled}
596
+ className={controlClassName}
597
+ style={{ width: '100%' }}
598
+ placeholder={placeholder || '数字'}
599
+ value={numericValue}
600
+ onChange={(next) => setValue(next == null ? '' : String(next))}
601
+ />
602
+ )
603
+ } else if (variant === 'obscured') {
604
+ control = (
605
+ <Input.Password
606
+ disabled={disabled}
607
+ className={controlClassName}
608
+ placeholder={placeholder}
609
+ value={stringValue}
610
+ onChange={(e) => setValue(e.target.value)}
611
+ />
612
+ )
613
+ } else {
614
+ control = (
615
+ <Input
616
+ disabled={disabled}
617
+ className={controlClassName}
618
+ placeholder={placeholder}
619
+ value={stringValue}
620
+ onChange={(e) => setValue(e.target.value)}
621
+ />
622
+ )
623
+ }
624
+
625
+ return (
626
+ <div className="form-block-preview">
627
+ {label ? <div className="form-block-preview-label">{label}</div> : null}
628
+ {control}
629
+ </div>
630
+ )
631
+ }
632
+
633
+ const TextFieldImpl = createBinderlessComponentImplementation(
634
+ {
635
+ name: 'TextField',
636
+ schema: z
637
+ .object({
638
+ label: z.any().optional(),
639
+ variant: z.any().optional(),
640
+ placeholder: z.any().optional(),
641
+ validationRegexp: z.any().optional(),
642
+ value: z.any().optional(),
643
+ })
644
+ .passthrough(),
645
+ },
646
+ TextFieldPreview,
647
+ )
648
+
649
+ function TextPreview({ context }: { context: ComponentContext }) {
650
+ const props = context.componentModel.properties as Record<string, unknown>
651
+ const text = readString(props.text)
652
+ const variant = readString(props.variant) || 'body'
653
+
654
+ if (NON_MARKDOWN_TEXT_VARIANTS.has(variant)) {
655
+ if (variant === 'caption') {
656
+ return (
657
+ <Typography.Text type="secondary" className="form-block-preview-paragraph">
658
+ <em>{text}</em>
659
+ </Typography.Text>
660
+ )
661
+ }
662
+ const Tag = variant as 'h1' | 'h2' | 'h3' | 'h4' | 'h5'
663
+ return <Tag className="form-block-preview-heading">{text}</Tag>
664
+ }
665
+
666
+ return (
667
+ <Typography.Paragraph className="form-block-preview-paragraph">{text}</Typography.Paragraph>
668
+ )
669
+ }
670
+
671
+ const TextImpl = createBinderlessComponentImplementation(
672
+ { name: 'Text', schema: getBasicSchema('Text') },
673
+ TextPreview,
674
+ )
675
+
529
676
  /** 构建合并后的 skopon catalog(预览每次重建,避免 HMR 缓存旧组件)。 */
530
677
  export function buildSkoponCatalog(): Catalog<ReactComponentImplementation> {
531
678
  const baseComponents = [...basicCatalog.components.values()].filter(
@@ -542,6 +689,8 @@ export function buildSkoponCatalog(): Catalog<ReactComponentImplementation> {
542
689
  CheckBoxImpl,
543
690
  DateTimeInputImpl,
544
691
  FileUploadImpl,
692
+ TextFieldImpl,
693
+ TextImpl,
545
694
  ]
546
695
  const functions = [...basicCatalog.functions.values()]
547
696
  return new Catalog(SKOPON_CATALOG_ID, components, functions, basicCatalog.themeSchema)