@skopon-cool/form-sdk 0.1.1 → 0.1.4

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 (93) hide show
  1. package/README.md +49 -11
  2. package/dist/adapter/a2uiAdapter.d.ts +0 -1
  3. package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
  4. package/dist/adapter/formFileAccept.d.ts.map +1 -1
  5. package/dist/adapter/formSchema.d.ts +1 -0
  6. package/dist/adapter/formSchema.d.ts.map +1 -1
  7. package/dist/blocks/case_singleselect/adapter.d.ts +10 -0
  8. package/dist/blocks/case_singleselect/adapter.d.ts.map +1 -0
  9. package/dist/blocks/case_singleselect/catalog.d.ts +2 -0
  10. package/dist/blocks/case_singleselect/catalog.d.ts.map +1 -0
  11. package/dist/blocks/case_singleselect/index.d.ts +3 -0
  12. package/dist/blocks/case_singleselect/index.d.ts.map +1 -0
  13. package/dist/blocks/registry.d.ts +8 -0
  14. package/dist/blocks/registry.d.ts.map +1 -0
  15. package/dist/blocks/resume_multiselect/adapter.d.ts +10 -0
  16. package/dist/blocks/resume_multiselect/adapter.d.ts.map +1 -0
  17. package/dist/blocks/resume_multiselect/catalog.d.ts +2 -0
  18. package/dist/blocks/resume_multiselect/catalog.d.ts.map +1 -0
  19. package/dist/blocks/resume_multiselect/index.d.ts +3 -0
  20. package/dist/blocks/resume_multiselect/index.d.ts.map +1 -0
  21. package/dist/blocks/types.d.ts +27 -0
  22. package/dist/blocks/types.d.ts.map +1 -0
  23. package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
  24. package/dist/catalog/caseSearchContext.d.ts +28 -0
  25. package/dist/catalog/caseSearchContext.d.ts.map +1 -0
  26. package/dist/catalog/resumeSearchContext.d.ts +30 -0
  27. package/dist/catalog/resumeSearchContext.d.ts.map +1 -0
  28. package/dist/catalog/skoponCaseSelect.d.ts +2 -0
  29. package/dist/catalog/skoponCaseSelect.d.ts.map +1 -0
  30. package/dist/catalog/skoponResumeSelect.d.ts +2 -0
  31. package/dist/catalog/skoponResumeSelect.d.ts.map +1 -0
  32. package/dist/catalog/useSkoponBoundField.d.ts +2 -0
  33. package/dist/catalog/useSkoponBoundField.d.ts.map +1 -1
  34. package/dist/client/formClient.d.ts.map +1 -1
  35. package/dist/components/AskUserFormCard.d.ts +10 -2
  36. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  37. package/dist/components/SkoponA2uiStreamRenderer.d.ts +7 -1
  38. package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -1
  39. package/dist/components/SkoponA2uiStreamRenderer.test.d.ts +2 -0
  40. package/dist/components/SkoponA2uiStreamRenderer.test.d.ts.map +1 -0
  41. package/dist/components/SkoponFormRenderer.d.ts +6 -0
  42. package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
  43. package/dist/form-sdk.css +1 -1
  44. package/dist/index.d.ts +7 -2
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +1390 -710
  47. package/dist/submit/buildCurlStatement.d.ts +8 -0
  48. package/dist/submit/buildCurlStatement.d.ts.map +1 -1
  49. package/dist/submit/intersectPayloadBlocksWithForm.d.ts +26 -0
  50. package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -0
  51. package/dist/submit/submitFormJson.d.ts.map +1 -1
  52. package/dist/types/index.d.ts +26 -2
  53. package/dist/types/index.d.ts.map +1 -1
  54. package/package.json +11 -6
  55. package/src/adapter/a2uiAdapter.test.ts +116 -0
  56. package/src/adapter/a2uiAdapter.ts +48 -4
  57. package/src/adapter/formFileAccept.test.ts +53 -0
  58. package/src/adapter/formFileAccept.ts +11 -2
  59. package/src/adapter/formSchema.test.ts +35 -0
  60. package/src/adapter/formSchema.ts +70 -3
  61. package/src/blocks/case_singleselect/adapter.ts +74 -0
  62. package/src/blocks/case_singleselect/catalog.ts +1 -0
  63. package/src/blocks/case_singleselect/index.ts +14 -0
  64. package/src/blocks/registry.ts +34 -0
  65. package/src/blocks/resume_multiselect/adapter.ts +57 -0
  66. package/src/blocks/resume_multiselect/catalog.ts +1 -0
  67. package/src/blocks/resume_multiselect/index.ts +14 -0
  68. package/src/blocks/types.ts +34 -0
  69. package/src/catalog/a2uiCustomCatalog.tsx +34 -5
  70. package/src/catalog/caseSearchContext.tsx +46 -0
  71. package/src/catalog/resumeSearchContext.tsx +48 -0
  72. package/src/catalog/skoponCaseSelect.tsx +215 -0
  73. package/src/catalog/skoponResumeSelect.tsx +227 -0
  74. package/src/catalog/textFieldPreview.test.tsx +1 -1
  75. package/src/catalog/useSkoponBoundField.test.ts +62 -0
  76. package/src/catalog/useSkoponBoundField.ts +10 -1
  77. package/src/client/formClient.test.ts +83 -0
  78. package/src/client/formClient.ts +10 -2
  79. package/src/components/AskUserFormCard.tsx +146 -58
  80. package/src/components/SkoponA2uiStreamRenderer.test.ts +78 -0
  81. package/src/components/SkoponA2uiStreamRenderer.test.tsx +103 -0
  82. package/src/components/SkoponA2uiStreamRenderer.tsx +141 -23
  83. package/src/components/SkoponFormRenderer.tsx +42 -17
  84. package/src/index.ts +34 -2
  85. package/src/styles/index.css +65 -0
  86. package/src/submit/buildCurlStatement.ts +49 -0
  87. package/src/submit/intersectPayloadBlocksWithForm.ts +175 -0
  88. package/src/submit/submit.test.ts +170 -10
  89. package/src/submit/submitFormJson.ts +20 -1
  90. package/src/types/index.ts +30 -0
  91. package/dist/submit/intersectPayloadWithForm.d.ts +0 -17
  92. package/dist/submit/intersectPayloadWithForm.d.ts.map +0 -1
  93. package/src/submit/intersectPayloadWithForm.ts +0 -54
@@ -15,6 +15,8 @@ import { extractSurfaceValues } from '../adapter/extractSurfaceValues'
15
15
  import { isA2uiSurfaceEmpty, surfaceDocToMessages } from '../adapter/a2uiAdapter'
16
16
  import { SKOPON_CATALOG_ID, buildSkoponCatalog } from '../catalog/a2uiCustomCatalog'
17
17
  import { A2uiPreviewModeProvider } from '../catalog/a2uiPreviewContext'
18
+ import { CaseSearchProvider, type CaseSearchFn } from '../catalog/caseSearchContext'
19
+ import { ResumeSearchProvider, type ResumeSearchFn } from '../catalog/resumeSearchContext'
18
20
 
19
21
  export interface SkoponFormRendererRef {
20
22
  getValues: (fieldNames?: string[]) => Record<string, unknown>
@@ -27,6 +29,10 @@ export interface SkoponFormRendererProps {
27
29
  interactive?: boolean
28
30
  /** 默认用于 getValues() 的字段名列表 */
29
31
  fieldNames?: string[]
32
+ /** 简历多选组件:注入搜索函数 */
33
+ resumeSearch?: ResumeSearchFn | null
34
+ /** 案例单选组件:注入搜索函数 */
35
+ caseSearch?: CaseSearchFn | null
30
36
  }
31
37
 
32
38
  const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererProps>(
@@ -37,6 +43,8 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
37
43
  emptyHint = null,
38
44
  interactive = true,
39
45
  fieldNames = [],
46
+ resumeSearch = null,
47
+ caseSearch = null,
40
48
  },
41
49
  ref,
42
50
  ) {
@@ -49,26 +57,39 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
49
57
  const fieldNamesRef = useRef(fieldNames)
50
58
  fieldNamesRef.current = fieldNames
51
59
 
52
- const surfaces = useMemo(() => {
53
- if (isA2uiSurfaceEmpty(doc)) {
54
- processorRef.current = null
55
- return []
60
+ const docKey = useMemo(() => {
61
+ if (isA2uiSurfaceEmpty(doc)) return ''
62
+ try {
63
+ return JSON.stringify(doc)
64
+ } catch {
65
+ return String(doc)
66
+ }
67
+ }, [doc])
68
+
69
+ const renderResult = useMemo(() => {
70
+ if (!docKey) {
71
+ return { surfaces: [], processor: null as MessageProcessor<ReactComponentImplementation> | null }
56
72
  }
57
73
  try {
74
+ const resolvedDoc = JSON.parse(docKey) as A2uiSurfaceDoc
58
75
  const processor = new MessageProcessor([buildSkoponCatalog()])
59
- const messages = surfaceDocToMessages(doc!, {
76
+ const messages = surfaceDocToMessages(resolvedDoc, {
60
77
  surfaceId,
61
78
  catalogId: SKOPON_CATALOG_ID,
62
79
  }) as unknown as A2uiMessage[]
63
80
  processor.processMessages(messages)
64
- processorRef.current = processor
65
- return Array.from(processor.model.surfacesMap.values())
81
+ return {
82
+ surfaces: Array.from(processor.model.surfacesMap.values()),
83
+ processor,
84
+ }
66
85
  } catch (error) {
67
86
  console.error('[SkoponFormRenderer] 渲染 A2UI surface 失败', error)
68
- processorRef.current = null
69
- return []
87
+ return { surfaces: [], processor: null as MessageProcessor<ReactComponentImplementation> | null }
70
88
  }
71
- }, [doc, surfaceId])
89
+ }, [docKey, surfaceId])
90
+
91
+ const { surfaces, processor } = renderResult
92
+ processorRef.current = processor
72
93
 
73
94
  useImperativeHandle(
74
95
  ref,
@@ -86,13 +107,17 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
86
107
  if (surfaces.length === 0) return <>{emptyHint}</>
87
108
 
88
109
  return (
89
- <A2uiPreviewModeProvider interactive={interactive}>
90
- <div className="a2ui-surface a2ui-container">
91
- {surfaces.map((surface) => (
92
- <A2uiSurface key={surface.id} surface={surface} />
93
- ))}
94
- </div>
95
- </A2uiPreviewModeProvider>
110
+ <ResumeSearchProvider resumeSearch={resumeSearch}>
111
+ <CaseSearchProvider caseSearch={caseSearch}>
112
+ <A2uiPreviewModeProvider interactive={interactive}>
113
+ <div className="a2ui-surface a2ui-container">
114
+ {surfaces.map((surface) => (
115
+ <A2uiSurface key={surface.id} surface={surface} />
116
+ ))}
117
+ </div>
118
+ </A2uiPreviewModeProvider>
119
+ </CaseSearchProvider>
120
+ </ResumeSearchProvider>
96
121
  )
97
122
  },
98
123
  )
package/src/index.ts CHANGED
@@ -41,8 +41,17 @@ export { normalizeFormDefinition, syncFormDefinition } from './adapter/formSchem
41
41
 
42
42
  export { extractSurfaceValues } from './adapter/extractSurfaceValues'
43
43
 
44
- export { buildCurlStatement } from './submit/buildCurlStatement'
45
- export { intersectPayloadWithForm, type PayloadFormIntersection } from './submit/intersectPayloadWithForm'
44
+ export { buildCurlStatement, buildAskUserCurlStatement, buildAskUserCurlBodyJson } from './submit/buildCurlStatement'
45
+ export {
46
+ extractExtraBlockValues,
47
+ getPayloadInputFieldNames,
48
+ getPayloadRenderableBlocks,
49
+ intersectPayloadBlocksWithForm,
50
+ parsePayloadBlocksJson,
51
+ payloadHasInputBlocks,
52
+ type PayloadBlocksDefinition,
53
+ type PayloadBlocksIntersection,
54
+ } from './submit/intersectPayloadBlocksWithForm'
46
55
  export {
47
56
  copyTextToClipboard,
48
57
  submitFormJson,
@@ -54,6 +63,8 @@ export { createFormClient, type FormClient, type FormClientOptions } from './cli
54
63
 
55
64
  export { default as SkoponFormRenderer } from './components/SkoponFormRenderer'
56
65
  export type { SkoponFormRendererProps, SkoponFormRendererRef } from './components/SkoponFormRenderer'
66
+ export type { ResumeSearchFn as SkoponResumeSearchFn } from './catalog/resumeSearchContext'
67
+ export type { CaseSearchFn as SkoponCaseSearchFn } from './catalog/caseSearchContext'
57
68
 
58
69
  export { default as SkoponA2uiStreamRenderer } from './components/SkoponA2uiStreamRenderer'
59
70
  export type { SkoponA2uiStreamRendererProps } from './components/SkoponA2uiStreamRenderer'
@@ -64,4 +75,25 @@ export type { AskUserFormCardProps } from './components/AskUserFormCard'
64
75
  export { default as CurlSubmitBlock } from './components/CurlSubmitBlock'
65
76
  export type { CurlSubmitBlockProps } from './components/CurlSubmitBlock'
66
77
 
78
+
79
+ export {
80
+ ResumeSearchProvider,
81
+ useResumeSearch,
82
+ type ResumeSearchFn,
83
+ type ResumeSearchItem,
84
+ type ResumeSearchParams,
85
+ type ResumeSearchResult,
86
+ } from './catalog/resumeSearchContext'
87
+
88
+ export {
89
+ CaseSearchProvider,
90
+ useCaseSearch,
91
+ type CaseSearchFn,
92
+ type CaseSearchItem,
93
+ type CaseSearchParams,
94
+ type CaseSearchResult,
95
+ } from './catalog/caseSearchContext'
96
+
97
+ export type { FormResumeFilter, FormCaseFilter } from './types/index'
98
+
67
99
  import './styles/index.css'
@@ -132,6 +132,11 @@
132
132
  min-width: 0;
133
133
  }
134
134
 
135
+ .ask-user-form-card-status {
136
+ display: flex;
137
+ justify-content: flex-end;
138
+ }
139
+
135
140
  .ask-user-form-actions {
136
141
  display: flex;
137
142
  justify-content: flex-end;
@@ -188,3 +193,63 @@
188
193
  white-space: pre-wrap;
189
194
  word-break: break-all;
190
195
  }
196
+
197
+ .skopon-resume-select-list {
198
+ display: flex;
199
+ flex-direction: column;
200
+ gap: var(--space-2, 8px);
201
+ margin-top: var(--space-2, 8px);
202
+ }
203
+ .skopon-resume-select-card {
204
+ display: flex;
205
+ align-items: flex-start;
206
+ gap: 12px;
207
+ width: 100%;
208
+ padding: 12px;
209
+ border: 1px solid var(--color-border, #d9d9d9);
210
+ border-radius: var(--radius-md, 8px);
211
+ background: var(--color-surface, #fff);
212
+ text-align: left;
213
+ cursor: pointer;
214
+ }
215
+ .skopon-resume-select-card:hover:not(:disabled) {
216
+ border-color: var(--color-primary, #1677ff);
217
+ }
218
+ .skopon-resume-select-card--selected {
219
+ border-color: var(--color-primary, #1677ff);
220
+ background: color-mix(in srgb, var(--color-primary, #1677ff) 8%, transparent);
221
+ }
222
+ .skopon-resume-select-card:disabled {
223
+ cursor: not-allowed;
224
+ opacity: 0.72;
225
+ }
226
+ .skopon-resume-select-card-body {
227
+ flex: 1;
228
+ min-width: 0;
229
+ }
230
+ .skopon-resume-select-card-name {
231
+ font-weight: 600;
232
+ margin-bottom: 4px;
233
+ }
234
+ .skopon-resume-select-card-desc {
235
+ color: var(--color-text-secondary, rgba(0, 0, 0, 0.65));
236
+ font-size: 13px;
237
+ margin-bottom: 4px;
238
+ overflow: hidden;
239
+ text-overflow: ellipsis;
240
+ display: -webkit-box;
241
+ -webkit-line-clamp: 2;
242
+ -webkit-box-orient: vertical;
243
+ }
244
+ .skopon-resume-select-card-meta {
245
+ color: var(--color-text-secondary, rgba(0, 0, 0, 0.45));
246
+ font-size: 12px;
247
+ }
248
+ .skopon-resume-select-actions {
249
+ margin-top: var(--space-2, 8px);
250
+ }
251
+ .skopon-resume-select-empty,
252
+ .skopon-resume-select-status {
253
+ padding: 12px 0;
254
+ color: var(--color-text-secondary, rgba(0, 0, 0, 0.45));
255
+ }
@@ -11,3 +11,52 @@ export function buildCurlStatement(payload: unknown, callbackUrl?: string | null
11
11
  ` -d '${escapeSingleQuotes(body)}'`,
12
12
  ].join('\n')
13
13
  }
14
+
15
+ function formatJsonBodyLine(key: string, value: unknown, trailingComma: boolean): string {
16
+ return ` ${JSON.stringify(key)}: ${JSON.stringify(value)}${trailingComma ? ',' : ''}`
17
+ }
18
+
19
+ /** 构建含行注释的 JSON body:extra 段标注「额外字段(未在卡片展示)」 */
20
+ export function buildAskUserCurlBodyJson(
21
+ cardValues: Record<string, unknown>,
22
+ extraValues: Record<string, unknown>,
23
+ ): string {
24
+ const lines: string[] = ['{']
25
+ const cardEntries = Object.entries(cardValues)
26
+ const extraEntries = Object.entries(extraValues)
27
+
28
+ cardEntries.forEach(([key, value], index) => {
29
+ const hasMore = index < cardEntries.length - 1 || extraEntries.length > 0
30
+ lines.push(formatJsonBodyLine(key, value, hasMore))
31
+ })
32
+
33
+ if (extraEntries.length > 0) {
34
+ lines.push(' // 额外字段(未在卡片展示)')
35
+ extraEntries.forEach(([key, value], index) => {
36
+ lines.push(formatJsonBodyLine(key, value, index < extraEntries.length - 1))
37
+ })
38
+ }
39
+
40
+ lines.push('}')
41
+ return lines.join('\n')
42
+ }
43
+
44
+ export interface BuildAskUserCurlStatementOptions {
45
+ cardValues: Record<string, unknown>
46
+ extraValues?: Record<string, unknown>
47
+ callbackUrl?: string | null
48
+ }
49
+
50
+ export function buildAskUserCurlStatement({
51
+ cardValues,
52
+ extraValues = {},
53
+ callbackUrl,
54
+ }: BuildAskUserCurlStatementOptions): string {
55
+ const url = (callbackUrl ?? '').trim() || '<callback_url>'
56
+ const body = buildAskUserCurlBodyJson(cardValues, extraValues)
57
+ return [
58
+ `curl -X POST '${url}' \\`,
59
+ ` -H 'Content-Type: application/json' \\`,
60
+ ` -d '${escapeSingleQuotes(body)}'`,
61
+ ].join('\n')
62
+ }
@@ -0,0 +1,175 @@
1
+ import { normalizeFormDefinition } from '../adapter/formSchema'
2
+ import type { FormBlock, FormSchema } from '../types/index'
3
+ import { isInputBlockType, isLayoutBlockType } from '../types/index'
4
+
5
+ export type PayloadBlocksDefinition = Pick<FormSchema, 'title' | 'description' | 'blocks'>
6
+
7
+ export interface PayloadBlocksIntersection {
8
+ matchedBlocks: FormBlock[]
9
+ extraBlocks: FormBlock[]
10
+ /** 按 payload 顺序:layout + matched input(不含 extra input) */
11
+ renderBlocks: FormBlock[]
12
+ title: string
13
+ description: string
14
+ }
15
+
16
+ function normalizePayloadBlock(block: FormBlock): FormBlock {
17
+ return normalizeFormDefinition({
18
+ title: '',
19
+ description: '',
20
+ blocks: [block],
21
+ jsonSchema: {},
22
+ }).blocks[0]!
23
+ }
24
+
25
+ function buildRenderBlocks(
26
+ payloadDef: PayloadBlocksDefinition,
27
+ matchedBlocks: FormBlock[],
28
+ ): FormBlock[] {
29
+ const matchedByName = new Map<string, FormBlock>()
30
+ for (const block of matchedBlocks) {
31
+ const name = block.name?.trim()
32
+ if (name) matchedByName.set(name, block)
33
+ }
34
+
35
+ const renderBlocks: FormBlock[] = []
36
+ for (const payloadBlock of payloadDef.blocks) {
37
+ if (isLayoutBlockType(payloadBlock.type)) {
38
+ renderBlocks.push(normalizePayloadBlock(payloadBlock))
39
+ continue
40
+ }
41
+ if (!isInputBlockType(payloadBlock.type)) continue
42
+ const name = payloadBlock.name?.trim()
43
+ if (!name) continue
44
+ const matched = matchedByName.get(name)
45
+ if (matched) renderBlocks.push(matched)
46
+ }
47
+ return renderBlocks
48
+ }
49
+
50
+ /** 交集块以 form 定义为准,仅从 payload 合并运行时 defaultValue。 */
51
+ function mergeBlock(formBlock: FormBlock, payloadBlock: FormBlock): FormBlock {
52
+ const merged: FormBlock = {
53
+ ...formBlock,
54
+ name: formBlock.name?.trim() || payloadBlock.name,
55
+ }
56
+ if (payloadBlock.defaultValue !== undefined) {
57
+ merged.defaultValue = payloadBlock.defaultValue
58
+ }
59
+ return normalizeFormDefinition({
60
+ title: '',
61
+ description: '',
62
+ blocks: [merged],
63
+ jsonSchema: {},
64
+ }).blocks[0]!
65
+ }
66
+
67
+ /** 解析 ask_user payload 为 blocksJson 形(title / description / blocks) */
68
+ export function parsePayloadBlocksJson(payload: unknown): PayloadBlocksDefinition | null {
69
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null
70
+ const obj = payload as Record<string, unknown>
71
+ if (!Array.isArray(obj.blocks)) return null
72
+ return normalizeFormDefinition({
73
+ title: typeof obj.title === 'string' ? obj.title : '',
74
+ description: typeof obj.description === 'string' ? obj.description : '',
75
+ blocks: obj.blocks as FormBlock[],
76
+ jsonSchema: {},
77
+ })
78
+ }
79
+
80
+ /**
81
+ * payload.blocks 与 vt_forms 按 input 块 name 取交集用于渲染;
82
+ * payload 中 form 不存在的块归入 extraBlocks(不渲染,提交时写入 curl)。
83
+ */
84
+ export function intersectPayloadBlocksWithForm(
85
+ payloadDef: PayloadBlocksDefinition,
86
+ formDefinition: FormSchema | undefined,
87
+ ): PayloadBlocksIntersection {
88
+ const formByName = new Map<string, FormBlock>()
89
+ for (const block of formDefinition?.blocks ?? []) {
90
+ if (isInputBlockType(block.type) && block.name?.trim()) {
91
+ formByName.set(block.name.trim(), block)
92
+ }
93
+ }
94
+
95
+ const matchedBlocks: FormBlock[] = []
96
+ const extraBlocks: FormBlock[] = []
97
+
98
+ for (const payloadBlock of payloadDef.blocks) {
99
+ if (!isInputBlockType(payloadBlock.type)) continue
100
+ const name = payloadBlock.name?.trim()
101
+ if (!name) continue
102
+ const formBlock = formByName.get(name)
103
+ if (formBlock) {
104
+ matchedBlocks.push(mergeBlock(formBlock, payloadBlock))
105
+ } else {
106
+ extraBlocks.push(
107
+ normalizeFormDefinition({
108
+ title: '',
109
+ description: '',
110
+ blocks: [payloadBlock],
111
+ jsonSchema: {},
112
+ }).blocks[0]!,
113
+ )
114
+ }
115
+ }
116
+
117
+ return {
118
+ matchedBlocks,
119
+ extraBlocks,
120
+ renderBlocks: buildRenderBlocks(payloadDef, matchedBlocks),
121
+ title: payloadDef.title?.trim() || formDefinition?.title?.trim() || '',
122
+ description: payloadDef.description?.trim() || formDefinition?.description?.trim() || '',
123
+ }
124
+ }
125
+
126
+ function blockValueFromDefault(block: FormBlock): unknown {
127
+ const { type, defaultValue } = block
128
+ if (defaultValue !== undefined) {
129
+ if (type === 'toggle') return defaultValue === true || defaultValue === 'true'
130
+ if (type === 'multiselect' || type === 'checkbox') {
131
+ if (Array.isArray(defaultValue)) return defaultValue.map(String)
132
+ if (typeof defaultValue === 'string' && defaultValue) return [defaultValue]
133
+ return []
134
+ }
135
+ return defaultValue
136
+ }
137
+ if (type === 'toggle') return false
138
+ if (type === 'multiselect' || type === 'checkbox') return []
139
+ if (type === 'number') return null
140
+ return ''
141
+ }
142
+
143
+ /** 从 extraBlocks 提取提交值(优先 defaultValue) */
144
+ export function extractExtraBlockValues(extraBlocks: FormBlock[]): Record<string, unknown> {
145
+ const values: Record<string, unknown> = {}
146
+ for (const block of extraBlocks) {
147
+ if (!isInputBlockType(block.type)) continue
148
+ const name = block.name?.trim()
149
+ if (!name) continue
150
+ values[name] = blockValueFromDefault(block)
151
+ }
152
+ return values
153
+ }
154
+
155
+ /** payload 是否含至少一个具 name 的 input 块(可用于卡片 fallback) */
156
+ export function payloadHasInputBlocks(payloadDef: PayloadBlocksDefinition): boolean {
157
+ return payloadDef.blocks.some(
158
+ (block) => isInputBlockType(block.type) && Boolean(block.name?.trim()),
159
+ )
160
+ }
161
+
162
+ /** 交集为空时 fallback:payload 中可上屏的 blocks(layout + input) */
163
+ export function getPayloadRenderableBlocks(payloadDef: PayloadBlocksDefinition): FormBlock[] {
164
+ return payloadDef.blocks.filter((block) => {
165
+ if (isLayoutBlockType(block.type)) return true
166
+ return isInputBlockType(block.type) && Boolean(block.name?.trim())
167
+ })
168
+ }
169
+
170
+ /** payload 中所有 input 块 field name(fallback 提交用) */
171
+ export function getPayloadInputFieldNames(payloadDef: PayloadBlocksDefinition): string[] {
172
+ return payloadDef.blocks
173
+ .filter((block) => isInputBlockType(block.type) && block.name?.trim())
174
+ .map((block) => block.name!.trim())
175
+ }
@@ -1,6 +1,20 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
1
4
  import { describe, it, expect, vi } from 'vitest'
2
- import { buildCurlStatement } from '../submit/buildCurlStatement'
3
- import { intersectPayloadWithForm } from '../submit/intersectPayloadWithForm'
5
+ import {
6
+ buildAskUserCurlBodyJson,
7
+ buildAskUserCurlStatement,
8
+ buildCurlStatement,
9
+ } from '../submit/buildCurlStatement'
10
+ import {
11
+ extractExtraBlockValues,
12
+ getPayloadInputFieldNames,
13
+ getPayloadRenderableBlocks,
14
+ intersectPayloadBlocksWithForm,
15
+ parsePayloadBlocksJson,
16
+ payloadHasInputBlocks,
17
+ } from '../submit/intersectPayloadBlocksWithForm'
4
18
  import { submitFormJson } from '../submit/submitFormJson'
5
19
  import type { FormSchema } from '../types/index'
6
20
 
@@ -16,9 +30,44 @@ describe('buildCurlStatement', () => {
16
30
  })
17
31
  })
18
32
 
19
- describe('intersectPayloadWithForm', () => {
33
+ describe('buildAskUserCurlStatement', () => {
34
+ it('includes extra fields with comment after card values', () => {
35
+ const body = buildAskUserCurlBodyJson(
36
+ { video_title: 'hello' },
37
+ { agent_hint: '请尽快确认' },
38
+ )
39
+ expect(body).toContain('"video_title": "hello"')
40
+ expect(body).toContain('// 额外字段(未在卡片展示)')
41
+ expect(body).toContain('"agent_hint": "请尽快确认"')
42
+
43
+ const curl = buildAskUserCurlStatement({
44
+ cardValues: { video_title: 'hello' },
45
+ extraValues: { agent_hint: '请尽快确认' },
46
+ callbackUrl: 'https://example.com/hook',
47
+ })
48
+ expect(curl).toContain('// 额外字段(未在卡片展示)')
49
+ })
50
+ })
51
+
52
+ describe('parsePayloadBlocksJson', () => {
53
+ it('parses blocksJson payload', () => {
54
+ const parsed = parsePayloadBlocksJson({
55
+ title: 't',
56
+ blocks: [{ id: '1', type: 'text', name: 'video_title', label: 'Title' }],
57
+ })
58
+ expect(parsed?.blocks).toHaveLength(1)
59
+ expect(parsed?.blocks[0]?.name).toBe('video_title')
60
+ })
61
+
62
+ it('returns null for non-blocksJson', () => {
63
+ expect(parsePayloadBlocksJson({ video_title: { type: 'text' } })).toBeNull()
64
+ expect(parsePayloadBlocksJson(null)).toBeNull()
65
+ })
66
+ })
67
+
68
+ describe('intersectPayloadBlocksWithForm', () => {
20
69
  const formDef: FormSchema = {
21
- title: 't',
70
+ title: 'Form',
22
71
  description: '',
23
72
  blocks: [
24
73
  { id: '1', type: 'text', name: 'video_title', label: 'Title' },
@@ -27,13 +76,124 @@ describe('intersectPayloadWithForm', () => {
27
76
  jsonSchema: {},
28
77
  }
29
78
 
30
- it('matches payload keys to form blocks', () => {
31
- const result = intersectPayloadWithForm(
32
- { video_title: { type: 'text' }, extra: { type: 'text' } },
33
- formDef,
34
- )
79
+ const payloadDef = parsePayloadBlocksJson({
80
+ title: 'Ask',
81
+ blocks: [
82
+ { id: 'p1', type: 'text', name: 'video_title', label: '视频标题' },
83
+ { id: 'p2', type: 'text', name: 'agent_hint', label: '提示', defaultValue: 'hint' },
84
+ ],
85
+ })!
86
+
87
+ it('matches payload blocks to form by name', () => {
88
+ const result = intersectPayloadBlocksWithForm(payloadDef, formDef)
89
+ expect(result.matchedBlocks.map((b) => b.name)).toEqual(['video_title'])
90
+ expect(result.extraBlocks.map((b) => b.name)).toEqual(['agent_hint'])
91
+ expect(result.title).toBe('Ask')
92
+ })
93
+
94
+ it('extracts extra block default values', () => {
95
+ const result = intersectPayloadBlocksWithForm(payloadDef, formDef)
96
+ expect(extractExtraBlockValues(result.extraBlocks)).toEqual({ agent_hint: 'hint' })
97
+ })
98
+
99
+ it('keeps form block type and schema when payload type differs', () => {
100
+ const selectForm: FormSchema = {
101
+ title: 'Form',
102
+ description: '',
103
+ blocks: [
104
+ {
105
+ id: '1',
106
+ type: 'select',
107
+ name: 'choice',
108
+ label: 'Form Label',
109
+ options: [
110
+ { label: 'A', value: 'a' },
111
+ { label: 'B', value: 'b' },
112
+ ],
113
+ },
114
+ ],
115
+ jsonSchema: {},
116
+ }
117
+ const mismatchedPayload = parsePayloadBlocksJson({
118
+ title: 'Ask',
119
+ blocks: [
120
+ {
121
+ id: 'p1',
122
+ type: 'text',
123
+ name: 'choice',
124
+ label: 'Payload Label',
125
+ defaultValue: 'a',
126
+ },
127
+ ],
128
+ })!
129
+ const result = intersectPayloadBlocksWithForm(mismatchedPayload, selectForm)
130
+ expect(result.matchedBlocks).toHaveLength(1)
131
+ expect(result.matchedBlocks[0]?.type).toBe('select')
132
+ expect(result.matchedBlocks[0]?.label).toBe('Form Label')
133
+ expect(result.matchedBlocks[0]?.options).toEqual([
134
+ { label: 'A', value: 'a' },
135
+ { label: 'B', value: 'b' },
136
+ ])
137
+ expect(result.matchedBlocks[0]?.defaultValue).toBe('a')
138
+ })
139
+
140
+ it('preserves payload layout blocks in renderBlocks on partial intersection', () => {
141
+ const partialPayload = parsePayloadBlocksJson({
142
+ title: 'Ask',
143
+ blocks: [
144
+ { id: 'h1', type: 'heading', label: '请填写以下信息' },
145
+ { id: 'p1', type: 'text', name: 'video_title', label: '视频标题' },
146
+ { id: 'p2', type: 'text', name: 'agent_hint', label: '提示', defaultValue: 'hint' },
147
+ { id: 'para1', type: 'paragraph', label: '提交后不可修改' },
148
+ ],
149
+ })!
150
+ const result = intersectPayloadBlocksWithForm(partialPayload, formDef)
35
151
  expect(result.matchedBlocks.map((b) => b.name)).toEqual(['video_title'])
36
- expect(Object.keys(result.remainderPayload)).toEqual(['extra'])
152
+ expect(result.extraBlocks.map((b) => b.name)).toEqual(['agent_hint'])
153
+ expect(result.renderBlocks.map((b) => b.type)).toEqual([
154
+ 'heading',
155
+ 'text',
156
+ 'paragraph',
157
+ ])
158
+ expect(result.renderBlocks.map((b) => b.label)).toEqual([
159
+ '请填写以下信息',
160
+ 'Title',
161
+ '提交后不可修改',
162
+ ])
163
+ expect(result.renderBlocks.some((b) => b.type === 'heading' && b.label === '请填写以下信息')).toBe(
164
+ true,
165
+ )
166
+ expect(result.extraBlocks.some((b) => b.type === 'heading')).toBe(false)
167
+ })
168
+ })
169
+
170
+ describe('payload fallback helpers', () => {
171
+ const surveyPayload = parsePayloadBlocksJson({
172
+ title: '',
173
+ blocks: [
174
+ { id: 'title', type: 'heading', label: '问卷调查' },
175
+ { id: 'q1', type: 'radio', name: 'occupation', label: '职业', options: [] },
176
+ ],
177
+ })!
178
+
179
+ it('detects input blocks for fallback', () => {
180
+ expect(payloadHasInputBlocks(surveyPayload)).toBe(true)
181
+ })
182
+
183
+ it('returns layout and input blocks for render', () => {
184
+ const blocks = getPayloadRenderableBlocks(surveyPayload)
185
+ expect(blocks.map((b) => b.id)).toEqual(['title', 'q1'])
186
+ })
187
+
188
+ it('returns input field names for submit', () => {
189
+ expect(getPayloadInputFieldNames(surveyPayload)).toEqual(['occupation'])
190
+ })
191
+
192
+ it('intersection empty when formDef missing but payload still renderable', () => {
193
+ const result = intersectPayloadBlocksWithForm(surveyPayload, undefined)
194
+ expect(result.matchedBlocks).toHaveLength(0)
195
+ expect(payloadHasInputBlocks(surveyPayload)).toBe(true)
196
+ expect(getPayloadRenderableBlocks(surveyPayload).length).toBeGreaterThan(0)
37
197
  })
38
198
  })
39
199
 
@@ -46,5 +46,24 @@ export async function submitFormJson(
46
46
  }
47
47
 
48
48
  export async function copyTextToClipboard(text: string): Promise<void> {
49
- await navigator.clipboard.writeText(text)
49
+ if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
50
+ await navigator.clipboard.writeText(text)
51
+ return
52
+ }
53
+ if (typeof document === 'undefined') {
54
+ throw new Error('clipboard unavailable')
55
+ }
56
+ const textarea = document.createElement('textarea')
57
+ textarea.value = text
58
+ textarea.setAttribute('readonly', '')
59
+ textarea.style.position = 'fixed'
60
+ textarea.style.left = '-9999px'
61
+ document.body.appendChild(textarea)
62
+ textarea.select()
63
+ try {
64
+ const ok = document.execCommand('copy')
65
+ if (!ok) throw new Error('execCommand copy failed')
66
+ } finally {
67
+ document.body.removeChild(textarea)
68
+ }
50
69
  }