@skopon-cool/form-sdk 0.1.3 → 0.1.5

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 (73) hide show
  1. package/README.md +2 -0
  2. package/dist/adapter/a2uiAdapter.d.ts +0 -1
  3. package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
  4. package/dist/adapter/formSchema.d.ts.map +1 -1
  5. package/dist/blocks/case_multiselect/adapter.d.ts +10 -0
  6. package/dist/blocks/case_multiselect/adapter.d.ts.map +1 -0
  7. package/dist/blocks/case_multiselect/index.d.ts +3 -0
  8. package/dist/blocks/case_multiselect/index.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/registry.d.ts +8 -0
  12. package/dist/blocks/registry.d.ts.map +1 -0
  13. package/dist/blocks/resume_multiselect/adapter.d.ts +10 -0
  14. package/dist/blocks/resume_multiselect/adapter.d.ts.map +1 -0
  15. package/dist/blocks/resume_multiselect/catalog.d.ts +2 -0
  16. package/dist/blocks/resume_multiselect/catalog.d.ts.map +1 -0
  17. package/dist/blocks/resume_multiselect/index.d.ts +3 -0
  18. package/dist/blocks/resume_multiselect/index.d.ts.map +1 -0
  19. package/dist/blocks/types.d.ts +27 -0
  20. package/dist/blocks/types.d.ts.map +1 -0
  21. package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
  22. package/dist/catalog/caseSearchContext.d.ts +28 -0
  23. package/dist/catalog/caseSearchContext.d.ts.map +1 -0
  24. package/dist/catalog/resumeSearchContext.d.ts +39 -0
  25. package/dist/catalog/resumeSearchContext.d.ts.map +1 -0
  26. package/dist/catalog/skoponCaseSelect.d.ts +2 -0
  27. package/dist/catalog/skoponCaseSelect.d.ts.map +1 -0
  28. package/dist/catalog/skoponResumeSelect.d.ts +2 -0
  29. package/dist/catalog/skoponResumeSelect.d.ts.map +1 -0
  30. package/dist/components/AskUserFormCard.d.ts +7 -1
  31. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  32. package/dist/components/SkoponA2uiStreamRenderer.d.ts +7 -1
  33. package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -1
  34. package/dist/components/SkoponFormRenderer.d.ts +6 -0
  35. package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
  36. package/dist/form-sdk.css +1 -1
  37. package/dist/index.d.ts +5 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1374 -889
  40. package/dist/submit/buildCurlStatement.d.ts +1 -1
  41. package/dist/submit/buildCurlStatement.d.ts.map +1 -1
  42. package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -1
  43. package/dist/types/index.d.ts +33 -2
  44. package/dist/types/index.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/adapter/a2uiAdapter.test.ts +71 -0
  47. package/src/adapter/a2uiAdapter.ts +41 -4
  48. package/src/adapter/formSchema.ts +102 -19
  49. package/src/blocks/case_multiselect/adapter.ts +90 -0
  50. package/src/blocks/case_multiselect/index.ts +14 -0
  51. package/src/blocks/case_singleselect/catalog.ts +1 -0
  52. package/src/blocks/registry.ts +34 -0
  53. package/src/blocks/resume_multiselect/adapter.ts +57 -0
  54. package/src/blocks/resume_multiselect/catalog.ts +1 -0
  55. package/src/blocks/resume_multiselect/index.ts +14 -0
  56. package/src/blocks/types.ts +34 -0
  57. package/src/catalog/a2uiCustomCatalog.tsx +6 -0
  58. package/src/catalog/caseSearchContext.tsx +46 -0
  59. package/src/catalog/resumeSearchContext.tsx +58 -0
  60. package/src/catalog/skoponCaseSelect.tsx +240 -0
  61. package/src/catalog/skoponResumeSelect.tsx +293 -0
  62. package/src/components/AskUserFormCard.tsx +13 -1
  63. package/src/components/SkoponA2uiStreamRenderer.test.ts +18 -2
  64. package/src/components/SkoponA2uiStreamRenderer.test.tsx +26 -2
  65. package/src/components/SkoponA2uiStreamRenderer.tsx +71 -27
  66. package/src/components/SkoponFormRenderer.tsx +32 -10
  67. package/src/index.ts +23 -0
  68. package/src/styles/a2ui-preview.css +4 -4
  69. package/src/styles/index.css +191 -0
  70. package/src/submit/buildCurlStatement.ts +2 -23
  71. package/src/submit/intersectPayloadBlocksWithForm.ts +14 -2
  72. package/src/submit/submit.test.ts +34 -3
  73. package/src/types/index.ts +37 -0
@@ -12,6 +12,8 @@ import {
12
12
  payloadHasInputBlocks,
13
13
  } from '../submit/intersectPayloadBlocksWithForm'
14
14
  import { copyTextToClipboard, submitFormJson } from '../submit/submitFormJson'
15
+ import type { CaseSearchFn } from '../catalog/caseSearchContext'
16
+ import type { ResumeSearchFn } from '../catalog/resumeSearchContext'
15
17
  import SkoponFormRenderer, { type SkoponFormRendererRef } from './SkoponFormRenderer'
16
18
  import CurlSubmitBlock from './CurlSubmitBlock'
17
19
 
@@ -37,6 +39,10 @@ export interface AskUserFormCardProps {
37
39
  /** 需 useCallback 稳定引用,避免重复拉取 */
38
40
  fetchFormDetail: (ref: { formUniqueId: string }) => Promise<FormDetailResult>
39
41
  onNotify?: (type: 'success' | 'error', message: string) => void
42
+ /** 简历多选组件:注入搜索函数 */
43
+ resumeSearch?: ResumeSearchFn | null
44
+ /** 案例单选组件:注入搜索函数 */
45
+ caseSearch?: CaseSearchFn | null
40
46
  }
41
47
 
42
48
  export default function AskUserFormCard({
@@ -46,6 +52,8 @@ export default function AskUserFormCard({
46
52
  submitMode = 'curl',
47
53
  fetchFormDetail,
48
54
  onNotify,
55
+ resumeSearch = null,
56
+ caseSearch = null,
49
57
  }: AskUserFormCardProps) {
50
58
  const [formDef, setFormDef] = useState<FormSchema | null>(null)
51
59
  const [formDisabled, setFormDisabled] = useState(false)
@@ -112,10 +120,12 @@ export default function AskUserFormCard({
112
120
  Boolean(payloadDef && payloadHasInputBlocks(payloadDef) && matchedBlocks.length === 0)
113
121
 
114
122
  const renderBlocks = useMemo(() => {
123
+ if (usePayloadFallback && payloadDef) {
124
+ return getPayloadRenderableBlocks(payloadDef)
125
+ }
115
126
  if (intersection && intersection.renderBlocks.length > 0) {
116
127
  return intersection.renderBlocks
117
128
  }
118
- if (usePayloadFallback && payloadDef) return getPayloadRenderableBlocks(payloadDef)
119
129
  return []
120
130
  }, [intersection, usePayloadFallback, payloadDef])
121
131
 
@@ -213,6 +223,8 @@ export default function AskUserFormCard({
213
223
  surfaceId={surfaceId}
214
224
  fieldNames={fieldNames}
215
225
  interactive
226
+ resumeSearch={resumeSearch}
227
+ caseSearch={caseSearch}
216
228
  />
217
229
 
218
230
  <div className="ask-user-form-actions">
@@ -11,12 +11,12 @@ function createProcessor(): MessageProcessor<ReactComponentImplementation> {
11
11
  return new MessageProcessor([buildSkoponCatalog()])
12
12
  }
13
13
 
14
- function buildStreamMessages(surfaceId: string): A2uiMessage[] {
14
+ function buildStreamMessages(surfaceId: string, label = '姓名'): A2uiMessage[] {
15
15
  const doc = blocksToA2ui(
16
16
  {
17
17
  title: '',
18
18
  description: '',
19
- blocks: [{ id: 'b-name', type: 'text', name: 'name', label: '姓名' }],
19
+ blocks: [{ id: 'b-name', type: 'text', name: 'name', label }],
20
20
  },
21
21
  { includeHeader: false },
22
22
  )
@@ -59,4 +59,20 @@ describe('SkoponA2uiStreamRenderer stream lifecycle', () => {
59
59
  rebuilt.processMessages(messages.slice(0, 1))
60
60
  expect(rebuilt.model.surfacesMap.size).toBeLessThanOrEqual(countAfterFull)
61
61
  })
62
+
63
+ it('rebuilds processor when message count is unchanged but content replaced', () => {
64
+ const surfaceId = 'stream-same-len'
65
+ const messagesA = buildStreamMessages(surfaceId, '姓名')
66
+ const messagesB = buildStreamMessages(surfaceId, '昵称')
67
+ expect(messagesA.length).toBe(messagesB.length)
68
+
69
+ const processor = createProcessor()
70
+ processor.processMessages(messagesA)
71
+ expect(processor.model.surfacesMap.size).toBeGreaterThan(0)
72
+
73
+ const rebuilt = createProcessor()
74
+ rebuilt.processMessages(messagesB)
75
+ expect(rebuilt.model.surfacesMap.size).toBeGreaterThan(0)
76
+ expect(JSON.stringify(messagesA)).not.toBe(JSON.stringify(messagesB))
77
+ })
62
78
  })
@@ -18,12 +18,12 @@ import { blocksToA2ui, surfaceDocToMessages } from '../adapter/a2uiAdapter'
18
18
  import { SKOPON_CATALOG_ID } from '../catalog/a2uiCustomCatalog'
19
19
  import SkoponA2uiStreamRenderer from './SkoponA2uiStreamRenderer'
20
20
 
21
- function buildStreamMessages(surfaceId: string): A2uiMessage[] {
21
+ function buildStreamMessages(surfaceId: string, label = '姓名'): A2uiMessage[] {
22
22
  const doc = blocksToA2ui(
23
23
  {
24
24
  title: '',
25
25
  description: '',
26
- blocks: [{ id: 'b-name', type: 'text', name: 'name', label: '姓名' }],
26
+ blocks: [{ id: 'b-name', type: 'text', name: 'name', label }],
27
27
  },
28
28
  { includeHeader: false },
29
29
  )
@@ -76,4 +76,28 @@ describe('SkoponA2uiStreamRenderer component', () => {
76
76
  expect(getByText('姓名')).toBeTruthy()
77
77
  expect(queryByText('stream-empty')).toBeNull()
78
78
  })
79
+
80
+ it('reprocesses messages when stream content is replaced at same length', () => {
81
+ const surfaceId = 'stream-replace'
82
+ const messagesA = buildStreamMessages(surfaceId, '姓名')
83
+ const messagesB = buildStreamMessages(surfaceId, '昵称')
84
+ expect(messagesA.length).toBe(messagesB.length)
85
+
86
+ const { rerender, getByText, queryByText } = render(
87
+ createElement(SkoponA2uiStreamRenderer, {
88
+ messages: messagesA,
89
+ surfaceId,
90
+ }),
91
+ )
92
+ expect(getByText('姓名')).toBeTruthy()
93
+
94
+ rerender(
95
+ createElement(SkoponA2uiStreamRenderer, {
96
+ messages: messagesB,
97
+ surfaceId,
98
+ }),
99
+ )
100
+ expect(getByText('昵称')).toBeTruthy()
101
+ expect(queryByText('姓名')).toBeNull()
102
+ })
79
103
  })
@@ -5,6 +5,8 @@ import { A2uiSurface, type ReactComponentImplementation } from '@a2ui/react/v0_9
5
5
  import { injectStyles } from '@a2ui/react/styles'
6
6
  import { buildSkoponCatalog } from '../catalog/a2uiCustomCatalog'
7
7
  import { A2uiPreviewModeProvider } from '../catalog/a2uiPreviewContext'
8
+ import { CaseSearchProvider, type CaseSearchFn } from '../catalog/caseSearchContext'
9
+ import { ResumeSearchProvider, type ResumeSearchFn } from '../catalog/resumeSearchContext'
8
10
 
9
11
  export interface SkoponA2uiStreamRendererProps {
10
12
  /** 增量追加的 A2UI v0.9 消息 */
@@ -12,6 +14,10 @@ export interface SkoponA2uiStreamRendererProps {
12
14
  surfaceId?: string
13
15
  emptyHint?: ReactNode
14
16
  interactive?: boolean
17
+ /** 简历多选组件:注入搜索函数 */
18
+ resumeSearch?: ResumeSearchFn | null
19
+ /** 案例单选组件:注入搜索函数 */
20
+ caseSearch?: CaseSearchFn | null
15
21
  }
16
22
 
17
23
  function createProcessor(): MessageProcessor<ReactComponentImplementation> {
@@ -23,20 +29,55 @@ export default function SkoponA2uiStreamRenderer({
23
29
  surfaceId = 'skopon-form-stream',
24
30
  emptyHint = null,
25
31
  interactive = true,
32
+ resumeSearch = null,
33
+ caseSearch = null,
26
34
  }: SkoponA2uiStreamRendererProps) {
27
35
  useEffect(() => {
28
36
  injectStyles()
29
37
  injectBasicCatalogStyles()
30
38
  }, [])
31
39
 
32
- const processorRef = useRef<MessageProcessor<ReactComponentImplementation>>(createProcessor())
40
+ const processorRef = useRef<MessageProcessor<ReactComponentImplementation> | null>(null)
41
+ if (processorRef.current === null) {
42
+ processorRef.current = createProcessor()
43
+ }
33
44
  const processedCountRef = useRef(0)
45
+ const lastProcessedMessagesRef = useRef('')
34
46
  const mountedRef = useRef(true)
35
47
  const surfaceIdRef = useRef(surfaceId)
36
48
 
37
- const [processor, setProcessor] = useState(() => processorRef.current)
49
+ function commitProcessor(
50
+ next: MessageProcessor<ReactComponentImplementation>,
51
+ processedMessages: A2uiMessage[],
52
+ ) {
53
+ processorRef.current = next
54
+ processedCountRef.current = processedMessages.length
55
+ lastProcessedMessagesRef.current = JSON.stringify(processedMessages)
56
+ if (!mountedRef.current) return
57
+ setProcessor(next)
58
+ setSurfaces(Array.from(next.model.surfacesMap.values()))
59
+ }
60
+
61
+ function processedPrefixChanged(messages: A2uiMessage[], prefixLen: number): boolean {
62
+ if (prefixLen <= 0) return false
63
+ const newPrefix = JSON.stringify(messages.slice(0, prefixLen))
64
+ const stored = lastProcessedMessagesRef.current
65
+ if (!stored) return true
66
+ try {
67
+ const parsed = JSON.parse(stored) as A2uiMessage[]
68
+ return JSON.stringify(parsed.slice(0, prefixLen)) !== newPrefix
69
+ } catch {
70
+ return true
71
+ }
72
+ }
73
+
74
+ const [processor, setProcessor] = useState(
75
+ () => processorRef.current as MessageProcessor<ReactComponentImplementation>,
76
+ )
38
77
  const [surfaces, setSurfaces] = useState(() =>
39
- Array.from(processorRef.current.model.surfacesMap.values()),
78
+ Array.from(
79
+ (processorRef.current as MessageProcessor<ReactComponentImplementation>).model.surfacesMap.values(),
80
+ ),
40
81
  )
41
82
 
42
83
  useEffect(() => {
@@ -70,18 +111,17 @@ export default function SkoponA2uiStreamRenderer({
70
111
 
71
112
  if (surfaceIdChanged) {
72
113
  const next = createProcessor()
73
- processorRef.current = next
74
- processedCountRef.current = 0
75
114
  if (Array.isArray(messages) && messages.length > 0) {
76
115
  next.processMessages(messages)
77
- processedCountRef.current = messages.length
116
+ if (!cancelled) commitProcessor(next, messages)
117
+ } else {
118
+ processorRef.current = next
119
+ processedCountRef.current = 0
120
+ lastProcessedMessagesRef.current = ''
78
121
  if (!cancelled) {
79
122
  setProcessor(next)
80
- setSurfaces(Array.from(next.model.surfacesMap.values()))
123
+ setSurfaces([])
81
124
  }
82
- } else if (!cancelled) {
83
- setProcessor(next)
84
- setSurfaces([])
85
125
  }
86
126
  return () => {
87
127
  cancelled = true
@@ -93,6 +133,7 @@ export default function SkoponA2uiStreamRenderer({
93
133
  const next = createProcessor()
94
134
  processorRef.current = next
95
135
  processedCountRef.current = 0
136
+ lastProcessedMessagesRef.current = ''
96
137
  if (!cancelled) {
97
138
  setProcessor(next)
98
139
  setSurfaces([])
@@ -103,24 +144,22 @@ export default function SkoponA2uiStreamRenderer({
103
144
  }
104
145
  }
105
146
 
106
- const activeProcessor = processorRef.current
147
+ const activeProcessor = processorRef.current as MessageProcessor<ReactComponentImplementation>
148
+ const processedCount = processedCountRef.current
107
149
 
108
- if (messages.length < processedCountRef.current) {
150
+ if (
151
+ messages.length < processedCount ||
152
+ processedPrefixChanged(messages, processedCount)
153
+ ) {
109
154
  const next = createProcessor()
110
- processorRef.current = next
111
- processedCountRef.current = 0
112
155
  next.processMessages(messages)
113
- processedCountRef.current = messages.length
114
- if (!cancelled) {
115
- setProcessor(next)
116
- setSurfaces(Array.from(next.model.surfacesMap.values()))
117
- }
156
+ if (!cancelled) commitProcessor(next, messages)
118
157
  return () => {
119
158
  cancelled = true
120
159
  }
121
160
  }
122
161
 
123
- const pending = messages.slice(processedCountRef.current)
162
+ const pending = messages.slice(processedCount)
124
163
  if (pending.length === 0) {
125
164
  return () => {
126
165
  cancelled = true
@@ -128,6 +167,7 @@ export default function SkoponA2uiStreamRenderer({
128
167
  }
129
168
  activeProcessor.processMessages(pending)
130
169
  processedCountRef.current = messages.length
170
+ lastProcessedMessagesRef.current = JSON.stringify(messages)
131
171
  if (!cancelled) {
132
172
  setSurfaces(Array.from(activeProcessor.model.surfacesMap.values()))
133
173
  }
@@ -140,12 +180,16 @@ export default function SkoponA2uiStreamRenderer({
140
180
  if (surfaces.length === 0) return <>{emptyHint}</>
141
181
 
142
182
  return (
143
- <A2uiPreviewModeProvider interactive={interactive}>
144
- <div className="a2ui-surface a2ui-container">
145
- {surfaces.map((surface) => (
146
- <A2uiSurface key={surface.id} surface={surface} />
147
- ))}
148
- </div>
149
- </A2uiPreviewModeProvider>
183
+ <ResumeSearchProvider resumeSearch={resumeSearch}>
184
+ <CaseSearchProvider caseSearch={caseSearch}>
185
+ <A2uiPreviewModeProvider interactive={interactive}>
186
+ <div className="a2ui-surface a2ui-container">
187
+ {surfaces.map((surface) => (
188
+ <A2uiSurface key={surface.id} surface={surface} />
189
+ ))}
190
+ </div>
191
+ </A2uiPreviewModeProvider>
192
+ </CaseSearchProvider>
193
+ </ResumeSearchProvider>
150
194
  )
151
195
  }
@@ -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,13 +57,23 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
49
57
  const fieldNamesRef = useRef(fieldNames)
50
58
  fieldNamesRef.current = fieldNames
51
59
 
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
+
52
69
  const renderResult = useMemo(() => {
53
- if (isA2uiSurfaceEmpty(doc)) {
70
+ if (!docKey) {
54
71
  return { surfaces: [], processor: null as MessageProcessor<ReactComponentImplementation> | null }
55
72
  }
56
73
  try {
74
+ const resolvedDoc = JSON.parse(docKey) as A2uiSurfaceDoc
57
75
  const processor = new MessageProcessor([buildSkoponCatalog()])
58
- const messages = surfaceDocToMessages(doc!, {
76
+ const messages = surfaceDocToMessages(resolvedDoc, {
59
77
  surfaceId,
60
78
  catalogId: SKOPON_CATALOG_ID,
61
79
  }) as unknown as A2uiMessage[]
@@ -68,7 +86,7 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
68
86
  console.error('[SkoponFormRenderer] 渲染 A2UI surface 失败', error)
69
87
  return { surfaces: [], processor: null as MessageProcessor<ReactComponentImplementation> | null }
70
88
  }
71
- }, [doc, surfaceId])
89
+ }, [docKey, surfaceId])
72
90
 
73
91
  const { surfaces, processor } = renderResult
74
92
  processorRef.current = processor
@@ -89,13 +107,17 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
89
107
  if (surfaces.length === 0) return <>{emptyHint}</>
90
108
 
91
109
  return (
92
- <A2uiPreviewModeProvider interactive={interactive}>
93
- <div className="a2ui-surface a2ui-container">
94
- {surfaces.map((surface) => (
95
- <A2uiSurface key={surface.id} surface={surface} />
96
- ))}
97
- </div>
98
- </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>
99
121
  )
100
122
  },
101
123
  )
package/src/index.ts CHANGED
@@ -63,6 +63,8 @@ export { createFormClient, type FormClient, type FormClientOptions } from './cli
63
63
 
64
64
  export { default as SkoponFormRenderer } from './components/SkoponFormRenderer'
65
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'
66
68
 
67
69
  export { default as SkoponA2uiStreamRenderer } from './components/SkoponA2uiStreamRenderer'
68
70
  export type { SkoponA2uiStreamRendererProps } from './components/SkoponA2uiStreamRenderer'
@@ -73,4 +75,25 @@ export type { AskUserFormCardProps } from './components/AskUserFormCard'
73
75
  export { default as CurlSubmitBlock } from './components/CurlSubmitBlock'
74
76
  export type { CurlSubmitBlockProps } from './components/CurlSubmitBlock'
75
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
+
76
99
  import './styles/index.css'
@@ -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
  }
@@ -193,3 +193,194 @@
193
193
  white-space: pre-wrap;
194
194
  word-break: break-all;
195
195
  }
196
+
197
+ .skopon-resume-select-list {
198
+ display: flex;
199
+ flex-direction: row;
200
+ flex-wrap: nowrap;
201
+ gap: var(--space-3, 12px);
202
+ margin-top: var(--space-2, 8px);
203
+ overflow-x: auto;
204
+ padding-bottom: var(--space-1, 4px);
205
+ -webkit-overflow-scrolling: touch;
206
+ }
207
+ .skopon-resume-select-card {
208
+ display: flex;
209
+ flex: 0 0 auto;
210
+ box-sizing: border-box;
211
+ padding: 12px;
212
+ border: 1px solid var(--color-border, #d9d9d9);
213
+ border-radius: 4px;
214
+ background: var(--color-surface, #fff);
215
+ text-align: left;
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;
251
+ }
252
+ .skopon-resume-select-card:hover:not(:disabled) {
253
+ border-color: var(--color-primary, #1677ff);
254
+ }
255
+ .skopon-resume-select-card--selected {
256
+ border-color: var(--color-primary, #1677ff);
257
+ background: color-mix(in srgb, var(--color-primary, #1677ff) 8%, transparent);
258
+ }
259
+ .skopon-resume-select-card:disabled {
260
+ cursor: not-allowed;
261
+ opacity: 0.72;
262
+ }
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;
268
+ }
269
+ .skopon-resume-select-card-name {
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 {
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));
304
+ }
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 {
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));
359
+ overflow: hidden;
360
+ }
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;
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;
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;
378
+ }
379
+ .skopon-resume-select-actions {
380
+ margin-top: var(--space-2, 8px);
381
+ }
382
+ .skopon-resume-select-empty,
383
+ .skopon-resume-select-status {
384
+ padding: 12px 0;
385
+ color: var(--color-text-secondary, rgba(0, 0, 0, 0.45));
386
+ }
@@ -12,33 +12,12 @@ export function buildCurlStatement(payload: unknown, callbackUrl?: string | null
12
12
  ].join('\n')
13
13
  }
14
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 段标注「额外字段(未在卡片展示)」 */
15
+ /** 构建合法 JSON body(card + extra 合并,便于 curl 直接执行) */
20
16
  export function buildAskUserCurlBodyJson(
21
17
  cardValues: Record<string, unknown>,
22
18
  extraValues: Record<string, unknown>,
23
19
  ): 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')
20
+ return JSON.stringify({ ...cardValues, ...extraValues }, null, 2)
42
21
  }
43
22
 
44
23
  export interface BuildAskUserCurlStatementOptions {