@skopon-cool/form-sdk 0.2.4 → 0.3.0

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 (46) hide show
  1. package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
  2. package/dist/catalog/skoponCaseSelect.d.ts.map +1 -1
  3. package/dist/catalog/skoponResumeSelect.d.ts.map +1 -1
  4. package/dist/catalog/useSkoponBoundField.d.ts +16 -1
  5. package/dist/catalog/useSkoponBoundField.d.ts.map +1 -1
  6. package/dist/client/authStorage.d.ts +14 -1
  7. package/dist/client/authStorage.d.ts.map +1 -1
  8. package/dist/components/AskUserFormCard.d.ts +8 -1
  9. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  10. package/dist/components/CurlSubmitBlock.d.ts +3 -1
  11. package/dist/components/CurlSubmitBlock.d.ts.map +1 -1
  12. package/dist/components/SkoponFormRenderer.d.ts +1 -0
  13. package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1579 -1107
  17. package/dist/runtime/formAutoControl.d.ts +15 -0
  18. package/dist/runtime/formAutoControl.d.ts.map +1 -0
  19. package/dist/runtime/formRuntimeContext.d.ts +17 -0
  20. package/dist/runtime/formRuntimeContext.d.ts.map +1 -0
  21. package/dist/runtime/formRuntimeWs.d.ts +27 -0
  22. package/dist/runtime/formRuntimeWs.d.ts.map +1 -0
  23. package/dist/runtime/types.d.ts +20 -0
  24. package/dist/runtime/types.d.ts.map +1 -0
  25. package/dist/submit/buildCurlStatement.d.ts +4 -3
  26. package/dist/submit/buildCurlStatement.d.ts.map +1 -1
  27. package/dist/submit/submitAnswerAgent.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/src/catalog/a2uiCustomCatalog.tsx +41 -16
  30. package/src/catalog/skoponCaseSelect.tsx +10 -4
  31. package/src/catalog/skoponResumeSelect.tsx +10 -4
  32. package/src/catalog/useSkoponBoundField.ts +100 -4
  33. package/src/client/authStorage.test.ts +74 -1
  34. package/src/client/authStorage.ts +45 -4
  35. package/src/components/AskUserFormCard.tsx +178 -31
  36. package/src/components/CurlSubmitBlock.tsx +14 -3
  37. package/src/components/SkoponFormRenderer.tsx +12 -0
  38. package/src/index.ts +3 -0
  39. package/src/runtime/formAutoControl.test.ts +71 -0
  40. package/src/runtime/formAutoControl.ts +109 -0
  41. package/src/runtime/formRuntimeContext.tsx +107 -0
  42. package/src/runtime/formRuntimeWs.ts +158 -0
  43. package/src/runtime/types.ts +26 -0
  44. package/src/submit/buildCurlStatement.ts +20 -3
  45. package/src/submit/submit.test.ts +81 -0
  46. package/src/submit/submitAnswerAgent.ts +22 -13
@@ -10,6 +10,7 @@ export interface SkoponAuthStorageOptions {
10
10
  export const DEFAULT_AUTH_STORAGE_KEY = 'auth_user'
11
11
  export const DEFAULT_AUTH_TOKEN_FIELD = 'token'
12
12
  export const DEFAULT_AUTH_ANSWER_AGENT_CALLBACK_URL_FIELD = 'answer_agent_callback_url'
13
+ export const DEFAULT_AUTH_REALTIME_WS_URL_FIELD = 'realtime_ws_url'
13
14
  /** 与 skopon-admin 登录写入的 mock answer-agent 路径一致 */
14
15
  export const DEFAULT_ANSWER_AGENT_CALLBACK_URL = '/api/v1/mock/ask_user_answer_mock'
15
16
 
@@ -46,7 +47,10 @@ function readTokenFromSessionJson(
46
47
  : ''
47
48
  }
48
49
 
49
- /** 从宿主登录缓存读取 answer_agent_callback_url(无则返回空串)。 */
50
+ /**
51
+ * 从宿主登录缓存读取 answer_agent_callback_url。
52
+ * 无 session / 无法读取时返回空串;session 存在但字段缺失或为空时回落到默认 mock 路径(与 skopon-admin 登录迁移一致)。
53
+ */
50
54
  export function resolveAnswerAgentCallbackUrl(
51
55
  options: SkoponAuthStorageOptions & {
52
56
  answerAgentCallbackUrlField?: string
@@ -58,19 +62,56 @@ export function resolveAnswerAgentCallbackUrl(
58
62
  options.answerAgentCallbackUrlField ?? DEFAULT_AUTH_ANSWER_AGENT_CALLBACK_URL_FIELD
59
63
 
60
64
  const storage = readStorage(storageKind)
61
- if (!storage) return DEFAULT_ANSWER_AGENT_CALLBACK_URL
65
+ if (!storage) return ''
62
66
 
63
67
  const raw = storage.getItem(storageKey)
64
- if (!raw) return DEFAULT_ANSWER_AGENT_CALLBACK_URL
68
+ if (!raw) return ''
65
69
 
66
70
  const parsed = readSessionJson(raw)
67
- if (!parsed) return DEFAULT_ANSWER_AGENT_CALLBACK_URL
71
+ if (!parsed) return ''
68
72
 
69
73
  const url = parsed[field]
70
74
  const trimmed = typeof url === 'string' ? url.trim() : ''
71
75
  return trimmed || DEFAULT_ANSWER_AGENT_CALLBACK_URL
72
76
  }
73
77
 
78
+ /**
79
+ * 从宿主登录缓存读取 realtime_ws_url。
80
+ * 无 session / 无字段 / 空串时返回 ''(不建连);SDK 不负责写入该字段。
81
+ */
82
+ export function resolveRealtimeWsUrl(
83
+ options: SkoponAuthStorageOptions & {
84
+ realtimeWsUrlField?: string
85
+ } = {},
86
+ ): string {
87
+ const storageKind = options.storage ?? 'sessionStorage'
88
+ const storageKey = options.storageKey ?? DEFAULT_AUTH_STORAGE_KEY
89
+ const field = options.realtimeWsUrlField ?? DEFAULT_AUTH_REALTIME_WS_URL_FIELD
90
+
91
+ const storage = readStorage(storageKind)
92
+ if (!storage) return ''
93
+
94
+ const raw = storage.getItem(storageKey)
95
+ if (!raw) return ''
96
+
97
+ const parsed = readSessionJson(raw)
98
+ if (!parsed) return ''
99
+
100
+ const url = parsed[field]
101
+ return typeof url === 'string' ? url.trim() : ''
102
+ }
103
+
104
+ /** 合并 session 鉴权头与宿主显式 headers;显式 headers 优先。 */
105
+ export function resolveSubmitAuthHeaders(
106
+ explicit?: Record<string, string>,
107
+ authStorage?: SkoponAuthStorageOptions,
108
+ ): Record<string, string> {
109
+ return {
110
+ ...resolveSkoponAuthHeaders(authStorage),
111
+ ...(explicit ?? {}),
112
+ }
113
+ }
114
+
74
115
  /** 从宿主登录缓存读取 Bearer token,返回 Authorization 头(无 token 时返回空对象)。 */
75
116
  export function resolveSkoponAuthHeaders(
76
117
  options: SkoponAuthStorageOptions = {},
@@ -1,6 +1,10 @@
1
- import { useEffect, useMemo, useRef, useState } from 'react'
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { Button, Spin, Tag, Tooltip } from 'antd'
3
3
  import type { FormDetailResult, FormSchema, SubmitMode } from '../types/index'
4
+ import {
5
+ resolveRealtimeWsUrl,
6
+ resolveSubmitAuthHeaders,
7
+ } from '../client/authStorage'
4
8
  import { buildAskUserSurface } from '../adapter/resolveSurface'
5
9
  import {
6
10
  buildAnswerAgentBody,
@@ -22,6 +26,10 @@ import {
22
26
  import { validateFormSubmitValues } from '../submit/validateFormSubmit'
23
27
  import type { CaseSearchFn } from '../catalog/caseSearchContext'
24
28
  import type { ResumeSearchFn } from '../catalog/resumeSearchContext'
29
+ import { FormRuntimeProvider, useFormRuntime } from '../runtime/formRuntimeContext'
30
+ import { FormRuntimeWsClient } from '../runtime/formRuntimeWs'
31
+ import { executeFormAutoControl } from '../runtime/formAutoControl'
32
+ import type { FormAutoControlEventData } from '../runtime/types'
25
33
  import SkoponFormRenderer, { type SkoponFormRendererRef } from './SkoponFormRenderer'
26
34
  import CurlSubmitBlock from './CurlSubmitBlock'
27
35
  import SkoponFormProvider from './SkoponFormProvider'
@@ -43,9 +51,16 @@ export interface AskUserFormCardProps {
43
51
  payload: unknown
44
52
  /** 可选;无则跳过拉取 vt_forms,仅用 payload blocks fallback */
45
53
  formUniqueId?: string | null
54
+ /**
55
+ * ask_source_unique_id(Flow/QA 业务 id)。
56
+ * 有值且 sessionStorage.auth_user.realtime_ws_url 存在、且渲染出表单控件时按卡建连。
57
+ */
58
+ askUserMessageId?: string | null
46
59
  callbackUrl?: string | null
47
60
  submitMode?: SubmitMode
48
61
  answerAgentContext?: AskUserAnswerAgentContext
62
+ /** 显式提交 headers,与 session 鉴权合并;显式优先 */
63
+ submitHeaders?: Record<string, string>
49
64
  /** 需 useCallback 稳定引用,避免重复拉取 */
50
65
  fetchFormDetail: (ref: { formUniqueId: string }) => Promise<FormDetailResult>
51
66
  onNotify?: (type: 'success' | 'error', message: string) => void
@@ -55,27 +70,44 @@ export interface AskUserFormCardProps {
55
70
  caseSearch?: CaseSearchFn | null
56
71
  }
57
72
 
58
- export default function AskUserFormCard({
73
+ interface AskUserFormCardBodyProps extends AskUserFormCardProps {
74
+ onSurfaceReadyChange: (ready: boolean) => void
75
+ onAutoControlReady: (handler: ((data: FormAutoControlEventData) => void) | null) => void
76
+ }
77
+
78
+ function AskUserFormCardBody({
59
79
  payload,
60
80
  formUniqueId,
81
+ askUserMessageId,
61
82
  callbackUrl,
62
83
  submitMode = 'curl',
63
84
  answerAgentContext,
85
+ submitHeaders,
64
86
  fetchFormDetail,
65
87
  onNotify,
66
88
  resumeSearch = null,
67
89
  caseSearch = null,
68
- }: AskUserFormCardProps) {
90
+ onSurfaceReadyChange,
91
+ onAutoControlReady,
92
+ }: AskUserFormCardBodyProps) {
69
93
  const [formDef, setFormDef] = useState<FormSchema | null>(null)
70
94
  const [formDisabled, setFormDisabled] = useState(false)
71
95
  const [loading, setLoading] = useState(true)
72
96
  const [submitting, setSubmitting] = useState(false)
73
97
  const rendererRef = useRef<SkoponFormRendererRef>(null)
98
+ const cardRootRef = useRef<HTMLDivElement>(null)
74
99
  const mountedRef = useRef(true)
75
100
  const onNotifyRef = useRef(onNotify)
76
101
  onNotifyRef.current = onNotify
102
+ const runtime = useFormRuntime()
77
103
 
78
104
  const resolvedFormUniqueId = formUniqueId?.trim() ?? ''
105
+ const resolvedAskUserMessageId = askUserMessageId?.trim() ?? ''
106
+
107
+ const resolvedSubmitHeaders = useMemo(
108
+ () => resolveSubmitAuthHeaders(submitHeaders),
109
+ [submitHeaders],
110
+ )
79
111
 
80
112
  const resolvedAnswerAgentContext = useMemo(
81
113
  (): AskUserAnswerAgentContext => ({
@@ -171,31 +203,14 @@ export default function AskUserFormCard({
171
203
  ? `ask-user-${resolvedFormUniqueId}`
172
204
  : 'ask-user-payload'
173
205
 
174
- if (loading) {
175
- return (
176
- <SkoponFormProvider>
177
- <div className="ask-user-form-card">
178
- <Spin size="small" />
179
- </div>
180
- </SkoponFormProvider>
181
- )
182
- }
206
+ const hasFormSurface = Boolean(!loading && payloadDef && renderBlocks.length > 0 && surfaceDoc)
183
207
 
184
- if (!payloadDef || renderBlocks.length === 0) {
185
- return (
186
- <SkoponFormProvider>
187
- <CurlSubmitBlock
188
- payload={payload}
189
- callbackUrl={callbackUrl}
190
- answerAgentContext={resolvedAnswerAgentContext}
191
- unpublishedFormId={formDisabled && resolvedFormUniqueId ? resolvedFormUniqueId : undefined}
192
- onNotify={onNotify}
193
- />
194
- </SkoponFormProvider>
195
- )
196
- }
208
+ useEffect(() => {
209
+ onSurfaceReadyChange(hasFormSurface)
210
+ return () => onSurfaceReadyChange(false)
211
+ }, [hasFormSurface, onSurfaceReadyChange])
197
212
 
198
- async function handleSubmit() {
213
+ const handleSubmit = useCallback(async () => {
199
214
  if (submitting) return
200
215
  setSubmitting(true)
201
216
  try {
@@ -207,13 +222,21 @@ export default function AskUserFormCard({
207
222
  }
208
223
  const answer = { ...cardValues, ...extraValues }
209
224
 
225
+ runtime.reportData(answer)
226
+ runtime.reportEvent({
227
+ action: 'submit',
228
+ component: 'SubmitButton',
229
+ })
230
+
210
231
  if (submitMode === 'post') {
211
232
  const url = (callbackUrl ?? '').trim()
212
233
  if (!url) {
213
234
  onNotifyRef.current?.('error', 'callback_url 为空,无法提交')
214
235
  return
215
236
  }
216
- const result = await submitFormJson(url, answer)
237
+ const result = await submitFormJson(url, answer, {
238
+ headers: resolvedSubmitHeaders,
239
+ })
217
240
  if (!mountedRef.current) return
218
241
  if (result.ok) {
219
242
  onNotifyRef.current?.('success', '提交成功')
@@ -229,7 +252,9 @@ export default function AskUserFormCard({
229
252
  form: formDef ?? payloadDef,
230
253
  answer,
231
254
  })
232
- const result = await submitAnswerAgent(body)
255
+ const result = await submitAnswerAgent(body, {
256
+ headers: resolvedSubmitHeaders,
257
+ })
233
258
  if (!mountedRef.current) return
234
259
  const notify = formatAnswerAgentSubmitMessage(result)
235
260
  onNotifyRef.current?.(notify.type, notify.message)
@@ -240,11 +265,79 @@ export default function AskUserFormCard({
240
265
  } finally {
241
266
  if (mountedRef.current) setSubmitting(false)
242
267
  }
268
+ }, [
269
+ submitting,
270
+ fieldNames,
271
+ renderBlocks,
272
+ extraValues,
273
+ runtime,
274
+ submitMode,
275
+ callbackUrl,
276
+ resolvedSubmitHeaders,
277
+ resolvedAnswerAgentContext,
278
+ payload,
279
+ formDef,
280
+ payloadDef,
281
+ ])
282
+
283
+ const handleSubmitRef = useRef(handleSubmit)
284
+ handleSubmitRef.current = handleSubmit
285
+
286
+ const applyAutoControl = useCallback(
287
+ (data: FormAutoControlEventData) => {
288
+ executeFormAutoControl(
289
+ data,
290
+ {
291
+ setValues: (values) => rendererRef.current?.setValues(values),
292
+ getValues: (names) => rendererRef.current?.getValues(names) ?? {},
293
+ submit: () => handleSubmitRef.current(),
294
+ rootElement: cardRootRef.current,
295
+ },
296
+ {
297
+ reportEvent: runtime.reportEvent,
298
+ runAutoControl: runtime.runAutoControl,
299
+ },
300
+ )
301
+ },
302
+ [runtime],
303
+ )
304
+
305
+ useEffect(() => {
306
+ if (!hasFormSurface) {
307
+ onAutoControlReady(null)
308
+ return
309
+ }
310
+ onAutoControlReady(applyAutoControl)
311
+ return () => onAutoControlReady(null)
312
+ }, [hasFormSurface, applyAutoControl, onAutoControlReady])
313
+
314
+ if (loading) {
315
+ return (
316
+ <div className="ask-user-form-card">
317
+ <Spin size="small" />
318
+ </div>
319
+ )
320
+ }
321
+
322
+ if (!payloadDef || renderBlocks.length === 0) {
323
+ return (
324
+ <CurlSubmitBlock
325
+ payload={payload}
326
+ callbackUrl={callbackUrl}
327
+ answerAgentContext={resolvedAnswerAgentContext}
328
+ submitHeaders={submitHeaders}
329
+ unpublishedFormId={formDisabled && resolvedFormUniqueId ? resolvedFormUniqueId : undefined}
330
+ onNotify={onNotify}
331
+ />
332
+ )
243
333
  }
244
334
 
245
335
  return (
246
- <SkoponFormProvider>
247
- <div className="ask-user-form-card">
336
+ <div
337
+ className="ask-user-form-card"
338
+ ref={cardRootRef}
339
+ data-ask-user-message-id={resolvedAskUserMessageId || undefined}
340
+ >
248
341
  {formDisabled && resolvedFormUniqueId ? (
249
342
  <div className="ask-user-form-card-status">
250
343
  <Tooltip title={resolvedFormUniqueId}>
@@ -282,7 +375,61 @@ export default function AskUserFormCard({
282
375
  </Button>
283
376
  </Tooltip>
284
377
  </div>
285
- </div>
378
+ </div>
379
+ )
380
+ }
381
+
382
+ export default function AskUserFormCard(props: AskUserFormCardProps) {
383
+ const resolvedAskUserMessageId = props.askUserMessageId?.trim() ?? ''
384
+ const realtimeWsUrl = useMemo(() => resolveRealtimeWsUrl(), [])
385
+ const [surfaceReady, setSurfaceReady] = useState(false)
386
+ const [wsClient, setWsClient] = useState<FormRuntimeWsClient | null>(null)
387
+ const autoControlHandlerRef = useRef<((data: FormAutoControlEventData) => void) | null>(null)
388
+
389
+ const onSurfaceReadyChange = useCallback((ready: boolean) => {
390
+ setSurfaceReady(ready)
391
+ }, [])
392
+
393
+ const onAutoControlReady = useCallback(
394
+ (handler: ((data: FormAutoControlEventData) => void) | null) => {
395
+ autoControlHandlerRef.current = handler
396
+ },
397
+ [],
398
+ )
399
+
400
+ const shouldConnect = Boolean(
401
+ realtimeWsUrl && resolvedAskUserMessageId && surfaceReady,
402
+ )
403
+
404
+ useEffect(() => {
405
+ if (!shouldConnect || !realtimeWsUrl || !resolvedAskUserMessageId) {
406
+ setWsClient(null)
407
+ return
408
+ }
409
+ const client = new FormRuntimeWsClient({
410
+ url: realtimeWsUrl,
411
+ askUserMessageId: resolvedAskUserMessageId,
412
+ onAutoControl: (data) => {
413
+ autoControlHandlerRef.current?.(data)
414
+ },
415
+ })
416
+ client.connect()
417
+ setWsClient(client)
418
+ return () => {
419
+ client.close()
420
+ setWsClient(null)
421
+ }
422
+ }, [shouldConnect, realtimeWsUrl, resolvedAskUserMessageId])
423
+
424
+ return (
425
+ <SkoponFormProvider>
426
+ <FormRuntimeProvider client={wsClient}>
427
+ <AskUserFormCardBody
428
+ {...props}
429
+ onSurfaceReadyChange={onSurfaceReadyChange}
430
+ onAutoControlReady={onAutoControlReady}
431
+ />
432
+ </FormRuntimeProvider>
286
433
  </SkoponFormProvider>
287
434
  )
288
435
  }
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { Button, Tag, Tooltip, Typography } from 'antd'
3
+ import { resolveSubmitAuthHeaders } from '../client/authStorage'
3
4
  import { buildCurlStatement } from '../submit/buildCurlStatement'
4
5
  import {
5
6
  buildAnswerAgentBody,
@@ -25,6 +26,8 @@ export interface CurlSubmitBlockProps {
25
26
  /** display:复制与显示一致;submitFields:复制向 callback 提交返回字段的 curl */
26
27
  copyMode?: CurlSubmitBlockCopyMode
27
28
  answerAgentContext?: AskUserAnswerAgentContext
29
+ /** 显式提交 headers,与 session 鉴权合并;显式优先 */
30
+ submitHeaders?: Record<string, string>
28
31
  onNotify?: (type: 'success' | 'error', message: string) => void
29
32
  }
30
33
 
@@ -44,6 +47,7 @@ export default function CurlSubmitBlock({
44
47
  unpublishedFormId,
45
48
  incompleteFormId,
46
49
  answerAgentContext,
50
+ submitHeaders,
47
51
  onNotify,
48
52
  }: CurlSubmitBlockProps) {
49
53
  const [submitting, setSubmitting] = useState(false)
@@ -56,9 +60,14 @@ export default function CurlSubmitBlock({
56
60
  }
57
61
  }, [])
58
62
 
63
+ const resolvedSubmitHeaders = useMemo(
64
+ () => resolveSubmitAuthHeaders(submitHeaders),
65
+ [submitHeaders],
66
+ )
67
+
59
68
  const displayCurl = useMemo(
60
- () => buildCurlStatement(payload, callbackUrl),
61
- [payload, callbackUrl],
69
+ () => buildCurlStatement(payload, callbackUrl, resolvedSubmitHeaders),
70
+ [payload, callbackUrl, resolvedSubmitHeaders],
62
71
  )
63
72
 
64
73
  const resolvedContext = useMemo(
@@ -79,7 +88,9 @@ export default function CurlSubmitBlock({
79
88
  payload,
80
89
  answer,
81
90
  })
82
- const result = await submitAnswerAgent(body)
91
+ const result = await submitAnswerAgent(body, {
92
+ headers: resolvedSubmitHeaders,
93
+ })
83
94
  if (!mountedRef.current) return
84
95
  const notify = formatAnswerAgentSubmitMessage(result)
85
96
  onNotify?.(notify.type, notify.message)
@@ -21,6 +21,7 @@ import SkoponFormProvider from './SkoponFormProvider'
21
21
 
22
22
  export interface SkoponFormRendererRef {
23
23
  getValues: (fieldNames?: string[]) => Record<string, unknown>
24
+ setValues: (values: Record<string, unknown>) => void
24
25
  }
25
26
 
26
27
  export interface SkoponFormRendererProps {
@@ -104,6 +105,17 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
104
105
  const resolvedNames = (names ?? fieldNamesRef.current).map((n) => n.trim()).filter(Boolean)
105
106
  return extractSurfaceValues(processor, surfaceId, resolvedNames)
106
107
  },
108
+ setValues(values) {
109
+ const processor = processorRef.current
110
+ if (!processor) return
111
+ const surface = processor.model.surfacesMap.get(surfaceId)
112
+ if (!surface) return
113
+ for (const [name, value] of Object.entries(values)) {
114
+ const trimmed = name.trim()
115
+ if (!trimmed) continue
116
+ surface.dataModel.set(`/${trimmed}`, value)
117
+ }
118
+ },
107
119
  }),
108
120
  [surfaceId],
109
121
  )
package/src/index.ts CHANGED
@@ -79,11 +79,14 @@ export {
79
79
  } from './submit/submitAnswerAgent'
80
80
  export {
81
81
  resolveAnswerAgentCallbackUrl,
82
+ resolveRealtimeWsUrl,
82
83
  resolveSkoponAuthHeaders,
84
+ resolveSubmitAuthHeaders,
83
85
  resolveClientHeaders,
84
86
  DEFAULT_AUTH_STORAGE_KEY,
85
87
  DEFAULT_AUTH_TOKEN_FIELD,
86
88
  DEFAULT_AUTH_ANSWER_AGENT_CALLBACK_URL_FIELD,
89
+ DEFAULT_AUTH_REALTIME_WS_URL_FIELD,
87
90
  DEFAULT_ANSWER_AGENT_CALLBACK_URL,
88
91
  type SkoponAuthStorageOptions,
89
92
  type ClientHeadersOptions,
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+ import { describe, it, expect, vi } from 'vitest'
5
+ import { executeFormAutoControl } from './formAutoControl'
6
+ import type { FormAutoControlEventData } from './types'
7
+
8
+ describe('executeFormAutoControl', () => {
9
+ it('writes values and reports bac change event', () => {
10
+ const setValues = vi.fn()
11
+ const reportEvent = vi.fn()
12
+ const runAutoControl = vi.fn((fn: () => unknown) => fn())
13
+ const data: FormAutoControlEventData = {
14
+ action: 'change',
15
+ field: 'name',
16
+ value: 'Alice',
17
+ component: 'TextField',
18
+ }
19
+ executeFormAutoControl(
20
+ data,
21
+ {
22
+ setValues,
23
+ getValues: () => ({}),
24
+ submit: vi.fn(),
25
+ rootElement: null,
26
+ },
27
+ { reportEvent, runAutoControl },
28
+ )
29
+ expect(runAutoControl).toHaveBeenCalled()
30
+ expect(setValues).toHaveBeenCalledWith({ name: 'Alice' })
31
+ expect(reportEvent).toHaveBeenCalledWith({
32
+ action: 'change',
33
+ component: 'TextField',
34
+ field: 'name',
35
+ value: 'Alice',
36
+ })
37
+ })
38
+
39
+ it('ignores change without field', () => {
40
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
41
+ const setValues = vi.fn()
42
+ executeFormAutoControl(
43
+ { action: 'change', value: 'x' },
44
+ {
45
+ setValues,
46
+ getValues: () => ({}),
47
+ submit: vi.fn(),
48
+ rootElement: null,
49
+ },
50
+ { reportEvent: vi.fn(), runAutoControl: (fn) => fn() },
51
+ )
52
+ expect(setValues).not.toHaveBeenCalled()
53
+ expect(warn).toHaveBeenCalled()
54
+ warn.mockRestore()
55
+ })
56
+
57
+ it('invokes submit for submit action', () => {
58
+ const submit = vi.fn()
59
+ executeFormAutoControl(
60
+ { action: 'submit' },
61
+ {
62
+ setValues: vi.fn(),
63
+ getValues: () => ({}),
64
+ submit,
65
+ rootElement: null,
66
+ },
67
+ { reportEvent: vi.fn(), runAutoControl: (fn) => fn() },
68
+ )
69
+ expect(submit).toHaveBeenCalled()
70
+ })
71
+ })
@@ -0,0 +1,109 @@
1
+ import type { FormAutoControlEventData, FormRuntimeEventData } from './types'
2
+
3
+ export interface FormAutoControlTargets {
4
+ setValues: (values: Record<string, unknown>) => void
5
+ getValues: (fieldNames?: string[]) => Record<string, unknown>
6
+ submit: () => void | Promise<void>
7
+ /** 卡片根节点,用于按 data-skopon-field 定位 DOM */
8
+ rootElement: HTMLElement | null
9
+ }
10
+
11
+ export interface FormAutoControlHelpers {
12
+ reportEvent: (event: FormRuntimeEventData) => void
13
+ runAutoControl: <T>(fn: () => T) => T
14
+ }
15
+
16
+ function escapeAttrSelector(value: string): string {
17
+ if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
18
+ return CSS.escape(value)
19
+ }
20
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
21
+ }
22
+
23
+ function focusField(root: HTMLElement | null, field: string): boolean {
24
+ if (!root) return false
25
+ const container = root.querySelector(`[data-skopon-field="${escapeAttrSelector(field)}"]`)
26
+ if (!(container instanceof HTMLElement)) return false
27
+ const focusable =
28
+ container.querySelector<HTMLElement>(
29
+ 'input, textarea, select, button, [tabindex]:not([tabindex="-1"])',
30
+ ) ?? container
31
+ focusable.focus()
32
+ return true
33
+ }
34
+
35
+ function clickField(root: HTMLElement | null, field: string): boolean {
36
+ if (!root) return false
37
+ const container = root.querySelector(`[data-skopon-field="${escapeAttrSelector(field)}"]`)
38
+ if (!(container instanceof HTMLElement)) return false
39
+ const clickable =
40
+ container.querySelector<HTMLElement>(
41
+ 'button, [role="button"], .skopon-resume-select-card, input, label',
42
+ ) ?? container
43
+ clickable.click()
44
+ return true
45
+ }
46
+
47
+ /** 执行下行 form_auto_control_event;缺参或未知 action 时 warn 并忽略。 */
48
+ export function executeFormAutoControl(
49
+ data: FormAutoControlEventData,
50
+ targets: FormAutoControlTargets,
51
+ helpers: FormAutoControlHelpers,
52
+ ): void {
53
+ const action = data.action
54
+ if (
55
+ action !== 'change' &&
56
+ action !== 'select' &&
57
+ action !== 'focus' &&
58
+ action !== 'click' &&
59
+ action !== 'submit'
60
+ ) {
61
+ console.warn('[FormAutoControl] unknown action', action)
62
+ return
63
+ }
64
+
65
+ helpers.runAutoControl(() => {
66
+ if (action === 'submit') {
67
+ void targets.submit()
68
+ return
69
+ }
70
+
71
+ const field = typeof data.field === 'string' ? data.field.trim() : ''
72
+ if (!field) {
73
+ console.warn('[FormAutoControl] missing field for action', action)
74
+ return
75
+ }
76
+
77
+ const component = data.component?.trim() || 'Unknown'
78
+
79
+ if (action === 'change' || action === 'select') {
80
+ if (data.value === undefined) {
81
+ console.warn('[FormAutoControl] missing value for action', action)
82
+ return
83
+ }
84
+ targets.setValues({ [field]: data.value })
85
+ helpers.reportEvent({
86
+ action,
87
+ component,
88
+ field,
89
+ value: data.value,
90
+ })
91
+ return
92
+ }
93
+
94
+ if (action === 'focus') {
95
+ focusField(targets.rootElement, field)
96
+ helpers.reportEvent({
97
+ action: 'focus',
98
+ component,
99
+ field,
100
+ })
101
+ return
102
+ }
103
+
104
+ // click:由组件 onClick 打点回传(autoControlDepth 生效)
105
+ if (!clickField(targets.rootElement, field)) {
106
+ console.warn('[FormAutoControl] click target not found', field)
107
+ }
108
+ })
109
+ }