@skopon-cool/form-sdk 0.1.4 → 0.1.6

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 (48) hide show
  1. package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
  2. package/dist/adapter/formSchema.d.ts.map +1 -1
  3. package/dist/blocks/{case_singleselect → case_multiselect}/adapter.d.ts +2 -2
  4. package/dist/blocks/case_multiselect/adapter.d.ts.map +1 -0
  5. package/dist/blocks/case_multiselect/index.d.ts +3 -0
  6. package/dist/blocks/case_multiselect/index.d.ts.map +1 -0
  7. package/dist/catalog/resumeSearchContext.d.ts +9 -0
  8. package/dist/catalog/resumeSearchContext.d.ts.map +1 -1
  9. package/dist/catalog/skoponCaseSelect.d.ts.map +1 -1
  10. package/dist/catalog/skoponResumeSelect.d.ts.map +1 -1
  11. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  12. package/dist/components/CurlSubmitBlock.d.ts +4 -1
  13. package/dist/components/CurlSubmitBlock.d.ts.map +1 -1
  14. package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -1
  15. package/dist/form-sdk.css +1 -1
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +901 -852
  19. package/dist/submit/buildCurlStatement.d.ts +3 -1
  20. package/dist/submit/buildCurlStatement.d.ts.map +1 -1
  21. package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -1
  22. package/dist/types/index.d.ts +11 -4
  23. package/dist/types/index.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/src/adapter/a2uiAdapter.test.ts +37 -14
  26. package/src/adapter/a2uiAdapter.ts +1 -4
  27. package/src/adapter/formSchema.ts +49 -29
  28. package/src/blocks/case_multiselect/adapter.ts +90 -0
  29. package/src/blocks/case_multiselect/index.ts +14 -0
  30. package/src/blocks/registry.ts +2 -2
  31. package/src/catalog/resumeSearchContext.tsx +10 -0
  32. package/src/catalog/skoponCaseSelect.tsx +38 -13
  33. package/src/catalog/skoponResumeSelect.tsx +76 -10
  34. package/src/components/AskUserFormCard.tsx +3 -1
  35. package/src/components/CurlSubmitBlock.tsx +28 -7
  36. package/src/components/SkoponA2uiStreamRenderer.tsx +11 -4
  37. package/src/index.ts +6 -1
  38. package/src/styles/a2ui-preview.css +4 -4
  39. package/src/styles/index.css +148 -17
  40. package/src/submit/buildCurlStatement.ts +18 -23
  41. package/src/submit/intersectPayloadBlocksWithForm.ts +14 -2
  42. package/src/submit/submit.test.ts +66 -3
  43. package/src/types/index.ts +10 -3
  44. package/dist/blocks/case_singleselect/adapter.d.ts.map +0 -1
  45. package/dist/blocks/case_singleselect/index.d.ts +0 -3
  46. package/dist/blocks/case_singleselect/index.d.ts.map +0 -1
  47. package/src/blocks/case_singleselect/adapter.ts +0 -74
  48. package/src/blocks/case_singleselect/index.ts +0 -14
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
- import { Avatar, Button, Spin, Typography } from 'antd'
3
- import { ReloadOutlined, UserOutlined } from '@ant-design/icons'
2
+ import { Avatar, Button, Spin, Tag, Typography } from 'antd'
3
+ import { FileImageOutlined, ReloadOutlined, UserOutlined } from '@ant-design/icons'
4
4
  import type { ComponentContext } from '@a2ui/web_core/v0_9'
5
5
  import { createBinderlessComponentImplementation } from '@a2ui/react/v0_9'
6
6
  import { z } from 'zod'
@@ -9,6 +9,7 @@ import { useA2uiPreviewMode } from './a2uiPreviewContext'
9
9
  import { asStringArray, useSkoponBoundField } from './useSkoponBoundField'
10
10
  import {
11
11
  type ResumeSearchItem,
12
+ type ResumeSearchWorkItem,
12
13
  useResumeSearch,
13
14
  } from './resumeSearchContext'
14
15
 
@@ -54,6 +55,60 @@ const SkoponResumeSelectApi = {
54
55
  .passthrough(),
55
56
  }
56
57
 
58
+ const MAX_TAG_COUNT = 3
59
+ const MAX_WORK_PREVIEW = 2
60
+
61
+ function renderTagRow(label: string, tags: string[] | undefined) {
62
+ const items = (tags ?? []).filter(Boolean)
63
+ const visible = items.slice(0, MAX_TAG_COUNT)
64
+ const overflow = items.length - visible.length
65
+ return (
66
+ <div className="skopon-resume-select-card-tags">
67
+ <span className="skopon-resume-select-card-tags-label">{label}</span>
68
+ {items.length > 0 ? (
69
+ <div className="skopon-resume-select-card-tags-list">
70
+ {visible.map((tag) => (
71
+ <Tag key={tag} className="skopon-resume-select-card-tag">
72
+ {tag}
73
+ </Tag>
74
+ ))}
75
+ {overflow > 0 ? (
76
+ <Tag className="skopon-resume-select-card-tag">+{overflow}</Tag>
77
+ ) : null}
78
+ </div>
79
+ ) : (
80
+ <span className="skopon-resume-select-card-tags-empty">暂无</span>
81
+ )}
82
+ </div>
83
+ )
84
+ }
85
+
86
+ function renderSatisfactionRow(satisfaction: number | undefined) {
87
+ return (
88
+ <div className="skopon-resume-select-card-field">
89
+ <span className="skopon-resume-select-card-tags-label">满意度</span>
90
+ <span className="skopon-resume-select-card-field-value">
91
+ {typeof satisfaction === 'number' ? `${satisfaction}%` : '暂无'}
92
+ </span>
93
+ </div>
94
+ )
95
+ }
96
+
97
+ function ResumeWorkPreview({ work }: { work: ResumeSearchWorkItem }) {
98
+ return (
99
+ <div className="skopon-resume-select-card-work">
100
+ <div className="skopon-resume-select-card-work-cover">
101
+ {work.coverUrl ? (
102
+ <img src={work.coverUrl} alt="" className="skopon-resume-select-card-work-img" />
103
+ ) : (
104
+ <FileImageOutlined className="skopon-resume-select-card-work-placeholder" />
105
+ )}
106
+ </div>
107
+ <div className="skopon-resume-select-card-work-title">{work.title || '未命名作品'}</div>
108
+ </div>
109
+ )
110
+ }
111
+
57
112
  function ResumeCard({
58
113
  item,
59
114
  selected,
@@ -65,10 +120,12 @@ function ResumeCard({
65
120
  disabled: boolean
66
121
  onToggle: () => void
67
122
  }) {
123
+ const works = (item.works ?? []).slice(0, MAX_WORK_PREVIEW)
124
+
68
125
  return (
69
126
  <button
70
127
  type="button"
71
- className={`skopon-resume-select-card${selected ? ' skopon-resume-select-card--selected' : ''}`}
128
+ className={`skopon-resume-select-card skopon-resume-select-card--profile${selected ? ' skopon-resume-select-card--selected' : ''}`}
72
129
  disabled={disabled}
73
130
  onClick={onToggle}
74
131
  >
@@ -78,14 +135,23 @@ function ResumeCard({
78
135
  icon={!item.avatarUrl ? <UserOutlined /> : undefined}
79
136
  className="skopon-resume-select-card-avatar"
80
137
  />
138
+ <div className="skopon-resume-select-card-name">{item.name || '未命名简历'}</div>
81
139
  <div className="skopon-resume-select-card-body">
82
- <div className="skopon-resume-select-card-name">{item.name}</div>
83
- {item.description ? (
84
- <div className="skopon-resume-select-card-desc">{item.description}</div>
85
- ) : null}
86
- <div className="skopon-resume-select-card-meta">
87
- {item.resumeUniqueId}
88
- {typeof item.satisfaction === 'number' ? ` · 满意度 ${item.satisfaction}%` : ''}
140
+ {renderSatisfactionRow(item.satisfaction)}
141
+ {renderTagRow('性格', item.personality)}
142
+ {renderTagRow('专长', item.specialties)}
143
+ {renderTagRow('擅长领域', item.traits)}
144
+ <div className="skopon-resume-select-card-works">
145
+ <span className="skopon-resume-select-card-works-label">过往作品</span>
146
+ {works.length > 0 ? (
147
+ <div className="skopon-resume-select-card-works-list">
148
+ {works.map((work, index) => (
149
+ <ResumeWorkPreview key={`${work.title}-${index}`} work={work} />
150
+ ))}
151
+ </div>
152
+ ) : (
153
+ <div className="skopon-resume-select-card-works-empty">暂无作品</div>
154
+ )}
89
155
  </div>
90
156
  </div>
91
157
  </button>
@@ -120,10 +120,12 @@ export default function AskUserFormCard({
120
120
  Boolean(payloadDef && payloadHasInputBlocks(payloadDef) && matchedBlocks.length === 0)
121
121
 
122
122
  const renderBlocks = useMemo(() => {
123
+ if (usePayloadFallback && payloadDef) {
124
+ return getPayloadRenderableBlocks(payloadDef)
125
+ }
123
126
  if (intersection && intersection.renderBlocks.length > 0) {
124
127
  return intersection.renderBlocks
125
128
  }
126
- if (usePayloadFallback && payloadDef) return getPayloadRenderableBlocks(payloadDef)
127
129
  return []
128
130
  }, [intersection, usePayloadFallback, payloadDef])
129
131
 
@@ -1,14 +1,21 @@
1
1
  import { useMemo } from 'react'
2
2
  import { Button, Tag, Tooltip, Typography } from 'antd'
3
- import { buildCurlStatement } from '../submit/buildCurlStatement'
3
+ import {
4
+ buildAskUserSubmitCurlFromPayload,
5
+ buildCurlStatement,
6
+ } from '../submit/buildCurlStatement'
4
7
  import { copyTextToClipboard } from '../submit/submitFormJson'
5
8
 
9
+ export type CurlSubmitBlockCopyMode = 'display' | 'submitFields'
10
+
6
11
  export interface CurlSubmitBlockProps {
7
12
  payload: unknown
8
13
  callbackUrl?: string | null
9
14
  title?: string
10
15
  unpublishedFormId?: string | null
11
16
  incompleteFormId?: string | null
17
+ /** display:复制与显示一致;submitFields:复制向 callback 提交返回字段的 curl */
18
+ copyMode?: CurlSubmitBlockCopyMode
12
19
  onNotify?: (type: 'success' | 'error', message: string) => void
13
20
  }
14
21
 
@@ -18,22 +25,34 @@ export default function CurlSubmitBlock({
18
25
  title,
19
26
  unpublishedFormId,
20
27
  incompleteFormId,
28
+ copyMode = 'display',
21
29
  onNotify,
22
30
  }: CurlSubmitBlockProps) {
23
- const curl = useMemo(
31
+ const displayCurl = useMemo(
24
32
  () => buildCurlStatement(payload, callbackUrl),
25
33
  [payload, callbackUrl],
26
34
  )
35
+ const copyCurl = useMemo(() => {
36
+ if (copyMode === 'submitFields') {
37
+ return buildAskUserSubmitCurlFromPayload(payload, callbackUrl)
38
+ }
39
+ return displayCurl
40
+ }, [copyMode, payload, callbackUrl, displayCurl])
27
41
 
28
42
  async function handleCopy() {
29
43
  try {
30
- await copyTextToClipboard(curl)
44
+ await copyTextToClipboard(copyCurl)
31
45
  onNotify?.('success', '已复制到剪贴板')
32
46
  } catch {
33
47
  onNotify?.('error', '复制失败')
34
48
  }
35
49
  }
36
50
 
51
+ const copyTooltip =
52
+ copyMode === 'submitFields'
53
+ ? '复制提交字段 curl(向 callback 回传)'
54
+ : '复制 curl'
55
+
37
56
  return (
38
57
  <div className="ask-user-curl-card">
39
58
  <div className="ask-user-curl-card-header">
@@ -50,11 +69,13 @@ export default function CurlSubmitBlock({
50
69
  </Tooltip>
51
70
  ) : null}
52
71
  </div>
53
- <Button size="small" type="text" onClick={() => void handleCopy()}>
54
- 复制
55
- </Button>
72
+ <Tooltip title={copyTooltip}>
73
+ <Button size="small" type="text" onClick={() => void handleCopy()}>
74
+ 复制
75
+ </Button>
76
+ </Tooltip>
56
77
  </div>
57
- <pre className="skopon-form-curl-json">{curl}</pre>
78
+ <pre className="skopon-form-curl-json">{displayCurl}</pre>
58
79
  </div>
59
80
  )
60
81
  }
@@ -37,7 +37,10 @@ export default function SkoponA2uiStreamRenderer({
37
37
  injectBasicCatalogStyles()
38
38
  }, [])
39
39
 
40
- const processorRef = useRef<MessageProcessor<ReactComponentImplementation>>(createProcessor())
40
+ const processorRef = useRef<MessageProcessor<ReactComponentImplementation> | null>(null)
41
+ if (processorRef.current === null) {
42
+ processorRef.current = createProcessor()
43
+ }
41
44
  const processedCountRef = useRef(0)
42
45
  const lastProcessedMessagesRef = useRef('')
43
46
  const mountedRef = useRef(true)
@@ -68,9 +71,13 @@ export default function SkoponA2uiStreamRenderer({
68
71
  }
69
72
  }
70
73
 
71
- const [processor, setProcessor] = useState(() => processorRef.current)
74
+ const [processor, setProcessor] = useState(
75
+ () => processorRef.current as MessageProcessor<ReactComponentImplementation>,
76
+ )
72
77
  const [surfaces, setSurfaces] = useState(() =>
73
- Array.from(processorRef.current.model.surfacesMap.values()),
78
+ Array.from(
79
+ (processorRef.current as MessageProcessor<ReactComponentImplementation>).model.surfacesMap.values(),
80
+ ),
74
81
  )
75
82
 
76
83
  useEffect(() => {
@@ -137,7 +144,7 @@ export default function SkoponA2uiStreamRenderer({
137
144
  }
138
145
  }
139
146
 
140
- const activeProcessor = processorRef.current
147
+ const activeProcessor = processorRef.current as MessageProcessor<ReactComponentImplementation>
141
148
  const processedCount = processedCountRef.current
142
149
 
143
150
  if (
package/src/index.ts CHANGED
@@ -41,7 +41,12 @@ export { normalizeFormDefinition, syncFormDefinition } from './adapter/formSchem
41
41
 
42
42
  export { extractSurfaceValues } from './adapter/extractSurfaceValues'
43
43
 
44
- export { buildCurlStatement, buildAskUserCurlStatement, buildAskUserCurlBodyJson } from './submit/buildCurlStatement'
44
+ export {
45
+ buildCurlStatement,
46
+ buildAskUserCurlStatement,
47
+ buildAskUserCurlBodyJson,
48
+ buildAskUserSubmitCurlFromPayload,
49
+ } from './submit/buildCurlStatement'
45
50
  export {
46
51
  extractExtraBlockValues,
47
52
  getPayloadInputFieldNames,
@@ -254,8 +254,8 @@
254
254
  box-shadow: var(--shadow-focus);
255
255
  }
256
256
 
257
- /* 原生 A2UI 按钮/chip;勿作用于 Ant Design(Switch、Select、Picker 等) */
258
- .a2ui-surface.a2ui-container button:not([class*='ant-']) {
257
+ /* 原生 A2UI 按钮/chip;勿作用于 Ant Design(Switch、Select、Picker 等)及 Skopon 简历/案例卡片 */
258
+ .a2ui-surface.a2ui-container button:not([class*='ant-']):not(.skopon-resume-select-card) {
259
259
  min-height: var(--button-height-md);
260
260
  padding: 0 var(--button-padding-x-md);
261
261
  font-family: var(--font-sans);
@@ -273,12 +273,12 @@
273
273
  color var(--duration-fast) var(--ease-default);
274
274
  }
275
275
 
276
- .a2ui-surface.a2ui-container button:not([class*='ant-']):hover:not(:disabled) {
276
+ .a2ui-surface.a2ui-container button:not([class*='ant-']):not(.skopon-resume-select-card):hover:not(:disabled) {
277
277
  border-color: var(--color-border-strong);
278
278
  background: var(--color-bg-subtle);
279
279
  }
280
280
 
281
- .a2ui-surface.a2ui-container button:not([class*='ant-']):disabled {
281
+ .a2ui-surface.a2ui-container button:not([class*='ant-']):not(.skopon-resume-select-card):disabled {
282
282
  cursor: not-allowed;
283
283
  opacity: 0.45;
284
284
  }
@@ -196,21 +196,58 @@
196
196
 
197
197
  .skopon-resume-select-list {
198
198
  display: flex;
199
- flex-direction: column;
200
- gap: var(--space-2, 8px);
199
+ flex-direction: row;
200
+ flex-wrap: nowrap;
201
+ gap: var(--space-3, 12px);
201
202
  margin-top: var(--space-2, 8px);
203
+ overflow-x: auto;
204
+ padding-bottom: var(--space-1, 4px);
205
+ -webkit-overflow-scrolling: touch;
202
206
  }
203
207
  .skopon-resume-select-card {
204
208
  display: flex;
205
- align-items: flex-start;
206
- gap: 12px;
207
- width: 100%;
209
+ flex: 0 0 auto;
210
+ box-sizing: border-box;
208
211
  padding: 12px;
209
212
  border: 1px solid var(--color-border, #d9d9d9);
210
- border-radius: var(--radius-md, 8px);
213
+ border-radius: 4px;
211
214
  background: var(--color-surface, #fff);
212
215
  text-align: left;
213
216
  cursor: pointer;
217
+ min-height: unset;
218
+ font-weight: inherit;
219
+ line-height: inherit;
220
+ appearance: none;
221
+ -webkit-appearance: none;
222
+ }
223
+ .a2ui-surface.a2ui-container button.skopon-resume-select-card {
224
+ border-radius: 4px;
225
+ min-height: unset;
226
+ padding: 12px;
227
+ font-weight: inherit;
228
+ }
229
+ .skopon-resume-select-card--profile {
230
+ flex-direction: column;
231
+ align-items: center;
232
+ width: 200px;
233
+ min-width: 200px;
234
+ max-width: 200px;
235
+ height: 320px;
236
+ min-height: 320px;
237
+ max-height: 320px;
238
+ padding: 10px;
239
+ overflow: hidden;
240
+ }
241
+ .a2ui-surface.a2ui-container button.skopon-resume-select-card--profile {
242
+ padding: 10px;
243
+ min-height: 320px;
244
+ }
245
+ .skopon-resume-select-card:not(.skopon-resume-select-card--profile) {
246
+ flex-direction: row;
247
+ align-items: flex-start;
248
+ gap: 12px;
249
+ width: 220px;
250
+ min-width: 220px;
214
251
  }
215
252
  .skopon-resume-select-card:hover:not(:disabled) {
216
253
  border-color: var(--color-primary, #1677ff);
@@ -223,27 +260,121 @@
223
260
  cursor: not-allowed;
224
261
  opacity: 0.72;
225
262
  }
226
- .skopon-resume-select-card-body {
227
- flex: 1;
228
- min-width: 0;
263
+ .skopon-resume-select-card-avatar {
264
+ flex-shrink: 0;
265
+ }
266
+ .skopon-resume-select-card--profile .skopon-resume-select-card-avatar {
267
+ margin-bottom: 6px;
229
268
  }
230
269
  .skopon-resume-select-card-name {
231
270
  font-weight: 600;
271
+ font-size: 14px;
272
+ line-height: 1.3;
273
+ margin-bottom: 6px;
274
+ overflow: hidden;
275
+ text-overflow: ellipsis;
276
+ white-space: nowrap;
277
+ color: var(--color-text, rgba(0, 0, 0, 0.88));
278
+ }
279
+ .skopon-resume-select-card--profile > .skopon-resume-select-card-name {
280
+ flex-shrink: 0;
281
+ width: 100%;
282
+ text-align: center;
283
+ }
284
+ .skopon-resume-select-card-body {
285
+ width: 100%;
286
+ min-width: 0;
287
+ text-align: left;
288
+ }
289
+ .skopon-resume-select-card--profile .skopon-resume-select-card-body {
290
+ flex: 1;
291
+ min-height: 0;
292
+ display: flex;
293
+ flex-direction: column;
294
+ overflow-x: hidden;
295
+ overflow-y: auto;
296
+ }
297
+ .skopon-resume-select-card-field {
232
298
  margin-bottom: 4px;
299
+ flex-shrink: 0;
300
+ }
301
+ .skopon-resume-select-card-field-value {
302
+ font-size: 12px;
303
+ color: var(--color-text, rgba(0, 0, 0, 0.88));
233
304
  }
234
- .skopon-resume-select-card-desc {
235
- color: var(--color-text-secondary, rgba(0, 0, 0, 0.65));
236
- font-size: 13px;
305
+ .skopon-resume-select-card-tags-empty {
306
+ font-size: 12px;
307
+ color: var(--color-text-secondary, rgba(0, 0, 0, 0.45));
308
+ }
309
+ .skopon-resume-select-card-tags {
237
310
  margin-bottom: 4px;
311
+ flex-shrink: 0;
312
+ }
313
+ .skopon-resume-select-card-tags-label,
314
+ .skopon-resume-select-card-works-label {
315
+ display: block;
316
+ margin-bottom: 2px;
317
+ font-size: 11px;
318
+ color: var(--color-text-secondary, rgba(0, 0, 0, 0.45));
319
+ }
320
+ .skopon-resume-select-card-tags-list {
321
+ display: flex;
322
+ flex-wrap: wrap;
323
+ gap: 4px;
324
+ }
325
+ .skopon-resume-select-card-tag {
326
+ margin: 0;
327
+ font-size: 11px;
328
+ line-height: 18px;
329
+ }
330
+ .skopon-resume-select-card-works {
331
+ margin-top: 4px;
332
+ flex-shrink: 0;
333
+ }
334
+ .skopon-resume-select-card-works-list {
335
+ display: flex;
336
+ flex-direction: column;
337
+ gap: 4px;
338
+ }
339
+ .skopon-resume-select-card-works-empty {
340
+ font-size: 12px;
341
+ color: var(--color-text-secondary, rgba(0, 0, 0, 0.45));
342
+ }
343
+ .skopon-resume-select-card-work {
344
+ display: flex;
345
+ align-items: center;
346
+ gap: 6px;
347
+ min-width: 0;
348
+ }
349
+ .skopon-resume-select-card-work-cover {
350
+ display: flex;
351
+ align-items: center;
352
+ justify-content: center;
353
+ flex-shrink: 0;
354
+ width: 32px;
355
+ height: 32px;
356
+ border-radius: 2px;
357
+ border: 1px solid var(--color-border, #d9d9d9);
358
+ background: var(--color-bg-subtle, rgba(0, 0, 0, 0.02));
238
359
  overflow: hidden;
239
- text-overflow: ellipsis;
240
- display: -webkit-box;
241
- -webkit-line-clamp: 2;
242
- -webkit-box-orient: vertical;
243
360
  }
244
- .skopon-resume-select-card-meta {
361
+ .skopon-resume-select-card-work-img {
362
+ width: 100%;
363
+ height: 100%;
364
+ object-fit: cover;
365
+ }
366
+ .skopon-resume-select-card-work-placeholder {
367
+ font-size: 16px;
245
368
  color: var(--color-text-secondary, rgba(0, 0, 0, 0.45));
369
+ }
370
+ .skopon-resume-select-card-work-title {
371
+ flex: 1;
372
+ min-width: 0;
246
373
  font-size: 12px;
374
+ color: var(--color-text, rgba(0, 0, 0, 0.88));
375
+ overflow: hidden;
376
+ text-overflow: ellipsis;
377
+ white-space: nowrap;
247
378
  }
248
379
  .skopon-resume-select-actions {
249
380
  margin-top: var(--space-2, 8px);
@@ -1,3 +1,8 @@
1
+ import {
2
+ extractExtraBlockValues,
3
+ parsePayloadBlocksJson,
4
+ } from './intersectPayloadBlocksWithForm'
5
+
1
6
  function escapeSingleQuotes(text: string): string {
2
7
  return text.replace(/'/g, `'\\''`)
3
8
  }
@@ -12,33 +17,12 @@ export function buildCurlStatement(payload: unknown, callbackUrl?: string | null
12
17
  ].join('\n')
13
18
  }
14
19
 
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
+ /** 构建合法 JSON body(card + extra 合并,便于 curl 直接执行) */
20
21
  export function buildAskUserCurlBodyJson(
21
22
  cardValues: Record<string, unknown>,
22
23
  extraValues: Record<string, unknown>,
23
24
  ): 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')
25
+ return JSON.stringify({ ...cardValues, ...extraValues }, null, 2)
42
26
  }
43
27
 
44
28
  export interface BuildAskUserCurlStatementOptions {
@@ -60,3 +44,14 @@ export function buildAskUserCurlStatement({
60
44
  ` -d '${escapeSingleQuotes(body)}'`,
61
45
  ].join('\n')
62
46
  }
47
+
48
+ /** 从 ask_user payload(blocksJson)生成向 callback 提交返回字段的 curl */
49
+ export function buildAskUserSubmitCurlFromPayload(
50
+ payload: unknown,
51
+ callbackUrl?: string | null,
52
+ ): string {
53
+ const def = parsePayloadBlocksJson(payload)
54
+ if (!def) return buildCurlStatement(payload, callbackUrl)
55
+ const cardValues = extractExtraBlockValues(def.blocks)
56
+ return buildAskUserCurlStatement({ cardValues, callbackUrl })
57
+ }
@@ -127,7 +127,12 @@ function blockValueFromDefault(block: FormBlock): unknown {
127
127
  const { type, defaultValue } = block
128
128
  if (defaultValue !== undefined) {
129
129
  if (type === 'toggle') return defaultValue === true || defaultValue === 'true'
130
- if (type === 'multiselect' || type === 'checkbox') {
130
+ if (
131
+ type === 'multiselect' ||
132
+ type === 'checkbox' ||
133
+ type === 'resume_multiselect' ||
134
+ type === 'case_multiselect'
135
+ ) {
131
136
  if (Array.isArray(defaultValue)) return defaultValue.map(String)
132
137
  if (typeof defaultValue === 'string' && defaultValue) return [defaultValue]
133
138
  return []
@@ -135,7 +140,14 @@ function blockValueFromDefault(block: FormBlock): unknown {
135
140
  return defaultValue
136
141
  }
137
142
  if (type === 'toggle') return false
138
- if (type === 'multiselect' || type === 'checkbox') return []
143
+ if (
144
+ type === 'multiselect' ||
145
+ type === 'checkbox' ||
146
+ type === 'resume_multiselect' ||
147
+ type === 'case_multiselect'
148
+ ) {
149
+ return []
150
+ }
139
151
  if (type === 'number') return null
140
152
  return ''
141
153
  }
@@ -5,6 +5,7 @@ import { describe, it, expect, vi } from 'vitest'
5
5
  import {
6
6
  buildAskUserCurlBodyJson,
7
7
  buildAskUserCurlStatement,
8
+ buildAskUserSubmitCurlFromPayload,
8
9
  buildCurlStatement,
9
10
  } from '../submit/buildCurlStatement'
10
11
  import {
@@ -31,21 +32,57 @@ describe('buildCurlStatement', () => {
31
32
  })
32
33
 
33
34
  describe('buildAskUserCurlStatement', () => {
34
- it('includes extra fields with comment after card values', () => {
35
+ it('merges card and extra values into valid JSON', () => {
35
36
  const body = buildAskUserCurlBodyJson(
36
37
  { video_title: 'hello' },
37
38
  { agent_hint: '请尽快确认' },
38
39
  )
39
40
  expect(body).toContain('"video_title": "hello"')
40
- expect(body).toContain('// 额外字段(未在卡片展示)')
41
41
  expect(body).toContain('"agent_hint": "请尽快确认"')
42
+ expect(body).not.toContain('// 额外字段')
43
+ expect(JSON.parse(body)).toEqual({
44
+ video_title: 'hello',
45
+ agent_hint: '请尽快确认',
46
+ })
42
47
 
43
48
  const curl = buildAskUserCurlStatement({
44
49
  cardValues: { video_title: 'hello' },
45
50
  extraValues: { agent_hint: '请尽快确认' },
46
51
  callbackUrl: 'https://example.com/hook',
47
52
  })
48
- expect(curl).toContain('// 额外字段(未在卡片展示)')
53
+ expect(curl).toContain('"agent_hint": "请尽快确认"')
54
+ expect(curl).not.toContain('// 额外字段')
55
+ })
56
+ })
57
+
58
+ describe('buildAskUserSubmitCurlFromPayload', () => {
59
+ it('builds submit-fields curl from blocksJson payload', () => {
60
+ const payload = {
61
+ title: 'Ask',
62
+ blocks: [
63
+ { id: 'p1', type: 'text', name: 'video_title', label: '标题', defaultValue: 'hello' },
64
+ { id: 'p2', type: 'text', name: 'agent_hint', label: '提示', defaultValue: 'hint' },
65
+ ],
66
+ }
67
+ const curl = buildAskUserSubmitCurlFromPayload(payload, 'https://example.com/hook')
68
+ expect(curl).toContain("curl -X POST 'https://example.com/hook'")
69
+ expect(curl).toContain('"video_title": "hello"')
70
+ expect(curl).toContain('"agent_hint": "hint"')
71
+ const bodyMatch = curl.match(/-d '([\s\S]*)'$/)
72
+ expect(bodyMatch).toBeTruthy()
73
+ const bodyText = bodyMatch![1]!.replace(/'\\''/g, "'")
74
+ expect(JSON.parse(bodyText)).toEqual({
75
+ video_title: 'hello',
76
+ agent_hint: 'hint',
77
+ })
78
+ })
79
+
80
+ it('falls back to display curl when payload is not blocksJson', () => {
81
+ const payload = { video_title: { type: 'text' } }
82
+ const callbackUrl = 'https://example.com/hook'
83
+ expect(buildAskUserSubmitCurlFromPayload(payload, callbackUrl)).toBe(
84
+ buildCurlStatement(payload, callbackUrl),
85
+ )
49
86
  })
50
87
  })
51
88
 
@@ -96,6 +133,18 @@ describe('intersectPayloadBlocksWithForm', () => {
96
133
  expect(extractExtraBlockValues(result.extraBlocks)).toEqual({ agent_hint: 'hint' })
97
134
  })
98
135
 
136
+ it('defaults resume_multiselect extra blocks to empty array', () => {
137
+ const extraBlocks = [
138
+ {
139
+ id: '1',
140
+ type: 'resume_multiselect' as const,
141
+ name: 'resumes',
142
+ label: 'Resumes',
143
+ },
144
+ ]
145
+ expect(extractExtraBlockValues(extraBlocks)).toEqual({ resumes: [] })
146
+ })
147
+
99
148
  it('keeps form block type and schema when payload type differs', () => {
100
149
  const selectForm: FormSchema = {
101
150
  title: 'Form',
@@ -195,6 +244,20 @@ describe('payload fallback helpers', () => {
195
244
  expect(payloadHasInputBlocks(surveyPayload)).toBe(true)
196
245
  expect(getPayloadRenderableBlocks(surveyPayload).length).toBeGreaterThan(0)
197
246
  })
247
+
248
+ it('prefers payload fallback blocks when intersection only has layout blocks', () => {
249
+ const result = intersectPayloadBlocksWithForm(surveyPayload, undefined)
250
+ const usePayloadFallback =
251
+ payloadHasInputBlocks(surveyPayload) && result.matchedBlocks.length === 0
252
+ expect(usePayloadFallback).toBe(true)
253
+ expect(result.renderBlocks.map((b) => b.type)).toEqual(['heading'])
254
+
255
+ const renderBlocks = usePayloadFallback
256
+ ? getPayloadRenderableBlocks(surveyPayload)
257
+ : result.renderBlocks
258
+ expect(renderBlocks.map((b) => b.id)).toEqual(['title', 'q1'])
259
+ expect(getPayloadInputFieldNames(surveyPayload)).toEqual(['occupation'])
260
+ })
198
261
  })
199
262
 
200
263
  describe('submitFormJson', () => {