@skopon-cool/form-sdk 0.2.5 → 0.3.1

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 (37) 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 +8 -0
  7. package/dist/client/authStorage.d.ts.map +1 -1
  8. package/dist/components/AskUserFormCard.d.ts +6 -1
  9. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  10. package/dist/components/SkoponFormRenderer.d.ts +1 -0
  11. package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1520 -1081
  15. package/dist/runtime/formAutoControl.d.ts +16 -0
  16. package/dist/runtime/formAutoControl.d.ts.map +1 -0
  17. package/dist/runtime/formRuntimeContext.d.ts +21 -0
  18. package/dist/runtime/formRuntimeContext.d.ts.map +1 -0
  19. package/dist/runtime/formRuntimeWs.d.ts +27 -0
  20. package/dist/runtime/formRuntimeWs.d.ts.map +1 -0
  21. package/dist/runtime/types.d.ts +20 -0
  22. package/dist/runtime/types.d.ts.map +1 -0
  23. package/package.json +1 -1
  24. package/src/catalog/a2uiCustomCatalog.tsx +41 -16
  25. package/src/catalog/skoponCaseSelect.tsx +10 -4
  26. package/src/catalog/skoponResumeSelect.tsx +10 -4
  27. package/src/catalog/useSkoponBoundField.ts +100 -4
  28. package/src/client/authStorage.test.ts +34 -0
  29. package/src/client/authStorage.ts +27 -0
  30. package/src/components/AskUserFormCard.tsx +164 -31
  31. package/src/components/SkoponFormRenderer.tsx +12 -0
  32. package/src/index.ts +2 -0
  33. package/src/runtime/formAutoControl.test.ts +127 -0
  34. package/src/runtime/formAutoControl.ts +110 -0
  35. package/src/runtime/formRuntimeContext.tsx +129 -0
  36. package/src/runtime/formRuntimeWs.ts +158 -0
  37. package/src/runtime/types.ts +26 -0
@@ -1,7 +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 { resolveSubmitAuthHeaders } from '../client/authStorage'
4
+ import {
5
+ resolveRealtimeWsUrl,
6
+ resolveSubmitAuthHeaders,
7
+ } from '../client/authStorage'
5
8
  import { buildAskUserSurface } from '../adapter/resolveSurface'
6
9
  import {
7
10
  buildAnswerAgentBody,
@@ -23,6 +26,10 @@ import {
23
26
  import { validateFormSubmitValues } from '../submit/validateFormSubmit'
24
27
  import type { CaseSearchFn } from '../catalog/caseSearchContext'
25
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'
26
33
  import SkoponFormRenderer, { type SkoponFormRendererRef } from './SkoponFormRenderer'
27
34
  import CurlSubmitBlock from './CurlSubmitBlock'
28
35
  import SkoponFormProvider from './SkoponFormProvider'
@@ -44,6 +51,11 @@ export interface AskUserFormCardProps {
44
51
  payload: unknown
45
52
  /** 可选;无则跳过拉取 vt_forms,仅用 payload blocks fallback */
46
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
47
59
  callbackUrl?: string | null
48
60
  submitMode?: SubmitMode
49
61
  answerAgentContext?: AskUserAnswerAgentContext
@@ -58,9 +70,15 @@ export interface AskUserFormCardProps {
58
70
  caseSearch?: CaseSearchFn | null
59
71
  }
60
72
 
61
- 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({
62
79
  payload,
63
80
  formUniqueId,
81
+ askUserMessageId,
64
82
  callbackUrl,
65
83
  submitMode = 'curl',
66
84
  answerAgentContext,
@@ -69,17 +87,22 @@ export default function AskUserFormCard({
69
87
  onNotify,
70
88
  resumeSearch = null,
71
89
  caseSearch = null,
72
- }: AskUserFormCardProps) {
90
+ onSurfaceReadyChange,
91
+ onAutoControlReady,
92
+ }: AskUserFormCardBodyProps) {
73
93
  const [formDef, setFormDef] = useState<FormSchema | null>(null)
74
94
  const [formDisabled, setFormDisabled] = useState(false)
75
95
  const [loading, setLoading] = useState(true)
76
96
  const [submitting, setSubmitting] = useState(false)
77
97
  const rendererRef = useRef<SkoponFormRendererRef>(null)
98
+ const cardRootRef = useRef<HTMLDivElement>(null)
78
99
  const mountedRef = useRef(true)
79
100
  const onNotifyRef = useRef(onNotify)
80
101
  onNotifyRef.current = onNotify
102
+ const runtime = useFormRuntime()
81
103
 
82
104
  const resolvedFormUniqueId = formUniqueId?.trim() ?? ''
105
+ const resolvedAskUserMessageId = askUserMessageId?.trim() ?? ''
83
106
 
84
107
  const resolvedSubmitHeaders = useMemo(
85
108
  () => resolveSubmitAuthHeaders(submitHeaders),
@@ -180,32 +203,14 @@ export default function AskUserFormCard({
180
203
  ? `ask-user-${resolvedFormUniqueId}`
181
204
  : 'ask-user-payload'
182
205
 
183
- if (loading) {
184
- return (
185
- <SkoponFormProvider>
186
- <div className="ask-user-form-card">
187
- <Spin size="small" />
188
- </div>
189
- </SkoponFormProvider>
190
- )
191
- }
206
+ const hasFormSurface = Boolean(!loading && payloadDef && renderBlocks.length > 0 && surfaceDoc)
192
207
 
193
- if (!payloadDef || renderBlocks.length === 0) {
194
- return (
195
- <SkoponFormProvider>
196
- <CurlSubmitBlock
197
- payload={payload}
198
- callbackUrl={callbackUrl}
199
- answerAgentContext={resolvedAnswerAgentContext}
200
- submitHeaders={submitHeaders}
201
- unpublishedFormId={formDisabled && resolvedFormUniqueId ? resolvedFormUniqueId : undefined}
202
- onNotify={onNotify}
203
- />
204
- </SkoponFormProvider>
205
- )
206
- }
208
+ useEffect(() => {
209
+ onSurfaceReadyChange(hasFormSurface)
210
+ return () => onSurfaceReadyChange(false)
211
+ }, [hasFormSurface, onSurfaceReadyChange])
207
212
 
208
- async function handleSubmit() {
213
+ const handleSubmit = useCallback(async () => {
209
214
  if (submitting) return
210
215
  setSubmitting(true)
211
216
  try {
@@ -217,6 +222,12 @@ export default function AskUserFormCard({
217
222
  }
218
223
  const answer = { ...cardValues, ...extraValues }
219
224
 
225
+ runtime.reportData(answer)
226
+ runtime.reportEvent({
227
+ action: 'submit',
228
+ component: 'SubmitButton',
229
+ })
230
+
220
231
  if (submitMode === 'post') {
221
232
  const url = (callbackUrl ?? '').trim()
222
233
  if (!url) {
@@ -254,11 +265,79 @@ export default function AskUserFormCard({
254
265
  } finally {
255
266
  if (mountedRef.current) setSubmitting(false)
256
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
+ )
257
333
  }
258
334
 
259
335
  return (
260
- <SkoponFormProvider>
261
- <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
+ >
262
341
  {formDisabled && resolvedFormUniqueId ? (
263
342
  <div className="ask-user-form-card-status">
264
343
  <Tooltip title={resolvedFormUniqueId}>
@@ -296,7 +375,61 @@ export default function AskUserFormCard({
296
375
  </Button>
297
376
  </Tooltip>
298
377
  </div>
299
- </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>
300
433
  </SkoponFormProvider>
301
434
  )
302
435
  }
@@ -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,12 +79,14 @@ export {
79
79
  } from './submit/submitAnswerAgent'
80
80
  export {
81
81
  resolveAnswerAgentCallbackUrl,
82
+ resolveRealtimeWsUrl,
82
83
  resolveSkoponAuthHeaders,
83
84
  resolveSubmitAuthHeaders,
84
85
  resolveClientHeaders,
85
86
  DEFAULT_AUTH_STORAGE_KEY,
86
87
  DEFAULT_AUTH_TOKEN_FIELD,
87
88
  DEFAULT_AUTH_ANSWER_AGENT_CALLBACK_URL_FIELD,
89
+ DEFAULT_AUTH_REALTIME_WS_URL_FIELD,
88
90
  DEFAULT_ANSWER_AGENT_CALLBACK_URL,
89
91
  type SkoponAuthStorageOptions,
90
92
  type ClientHeadersOptions,
@@ -0,0 +1,127 @@
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
+ /** 模拟 FormRuntimeProvider.runAutoControl:Promise settle 前保持 depth。 */
9
+ function createDepthTrackingRunAutoControl(depthRef: { current: number }) {
10
+ return <T,>(fn: () => T): T => {
11
+ depthRef.current += 1
12
+ let syncResult: T
13
+ try {
14
+ syncResult = fn()
15
+ } catch (error) {
16
+ depthRef.current = Math.max(0, depthRef.current - 1)
17
+ throw error
18
+ }
19
+ if (
20
+ syncResult != null &&
21
+ (typeof syncResult === 'object' || typeof syncResult === 'function') &&
22
+ typeof (syncResult as PromiseLike<unknown>).then === 'function'
23
+ ) {
24
+ const promise = Promise.resolve(syncResult).finally(() => {
25
+ depthRef.current = Math.max(0, depthRef.current - 1)
26
+ })
27
+ return promise as T
28
+ }
29
+ depthRef.current = Math.max(0, depthRef.current - 1)
30
+ return syncResult
31
+ }
32
+ }
33
+
34
+ describe('executeFormAutoControl', () => {
35
+ it('writes values and reports bac change event', () => {
36
+ const setValues = vi.fn()
37
+ const reportEvent = vi.fn()
38
+ const runAutoControl = vi.fn((fn: () => unknown) => fn())
39
+ const data: FormAutoControlEventData = {
40
+ action: 'change',
41
+ field: 'name',
42
+ value: 'Alice',
43
+ component: 'TextField',
44
+ }
45
+ executeFormAutoControl(
46
+ data,
47
+ {
48
+ setValues,
49
+ getValues: () => ({}),
50
+ submit: vi.fn(),
51
+ rootElement: null,
52
+ },
53
+ { reportEvent, runAutoControl },
54
+ )
55
+ expect(runAutoControl).toHaveBeenCalled()
56
+ expect(setValues).toHaveBeenCalledWith({ name: 'Alice' })
57
+ expect(reportEvent).toHaveBeenCalledWith({
58
+ action: 'change',
59
+ component: 'TextField',
60
+ field: 'name',
61
+ value: 'Alice',
62
+ })
63
+ })
64
+
65
+ it('ignores change without field', () => {
66
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
67
+ const setValues = vi.fn()
68
+ executeFormAutoControl(
69
+ { action: 'change', value: 'x' },
70
+ {
71
+ setValues,
72
+ getValues: () => ({}),
73
+ submit: vi.fn(),
74
+ rootElement: null,
75
+ },
76
+ { reportEvent: vi.fn(), runAutoControl: (fn) => fn() },
77
+ )
78
+ expect(setValues).not.toHaveBeenCalled()
79
+ expect(warn).toHaveBeenCalled()
80
+ warn.mockRestore()
81
+ })
82
+
83
+ it('invokes submit for submit action', () => {
84
+ const submit = vi.fn()
85
+ executeFormAutoControl(
86
+ { action: 'submit' },
87
+ {
88
+ setValues: vi.fn(),
89
+ getValues: () => ({}),
90
+ submit,
91
+ rootElement: null,
92
+ },
93
+ { reportEvent: vi.fn(), runAutoControl: (fn) => fn() },
94
+ )
95
+ expect(submit).toHaveBeenCalled()
96
+ })
97
+
98
+ it('keeps autoControl depth until async submit settles', async () => {
99
+ const depthRef = { current: 0 }
100
+ const runAutoControl = createDepthTrackingRunAutoControl(depthRef)
101
+ let resolveSubmit!: () => void
102
+ const submit = vi.fn(
103
+ () =>
104
+ new Promise<void>((resolve) => {
105
+ resolveSubmit = resolve
106
+ }),
107
+ )
108
+
109
+ executeFormAutoControl(
110
+ { action: 'submit' },
111
+ {
112
+ setValues: vi.fn(),
113
+ getValues: () => ({}),
114
+ submit,
115
+ rootElement: null,
116
+ },
117
+ { reportEvent: vi.fn(), runAutoControl },
118
+ )
119
+
120
+ expect(submit).toHaveBeenCalled()
121
+ expect(depthRef.current).toBe(1)
122
+ resolveSubmit()
123
+ await Promise.resolve()
124
+ await Promise.resolve()
125
+ expect(depthRef.current).toBe(0)
126
+ })
127
+ })
@@ -0,0 +1,110 @@
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
+ /** 支持同步或返回 Promise(async submit 期间保持 autoControlDepth) */
14
+ runAutoControl: <T>(fn: () => T) => T
15
+ }
16
+
17
+ function escapeAttrSelector(value: string): string {
18
+ if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
19
+ return CSS.escape(value)
20
+ }
21
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
22
+ }
23
+
24
+ function focusField(root: HTMLElement | null, field: string): boolean {
25
+ if (!root) return false
26
+ const container = root.querySelector(`[data-skopon-field="${escapeAttrSelector(field)}"]`)
27
+ if (!(container instanceof HTMLElement)) return false
28
+ const focusable =
29
+ container.querySelector<HTMLElement>(
30
+ 'input, textarea, select, button, [tabindex]:not([tabindex="-1"])',
31
+ ) ?? container
32
+ focusable.focus()
33
+ return true
34
+ }
35
+
36
+ function clickField(root: HTMLElement | null, field: string): boolean {
37
+ if (!root) return false
38
+ const container = root.querySelector(`[data-skopon-field="${escapeAttrSelector(field)}"]`)
39
+ if (!(container instanceof HTMLElement)) return false
40
+ const clickable =
41
+ container.querySelector<HTMLElement>(
42
+ 'button, [role="button"], .skopon-resume-select-card, input, label',
43
+ ) ?? container
44
+ clickable.click()
45
+ return true
46
+ }
47
+
48
+ /** 执行下行 form_auto_control_event;缺参或未知 action 时 warn 并忽略。 */
49
+ export function executeFormAutoControl(
50
+ data: FormAutoControlEventData,
51
+ targets: FormAutoControlTargets,
52
+ helpers: FormAutoControlHelpers,
53
+ ): void {
54
+ const action = data.action
55
+ if (
56
+ action !== 'change' &&
57
+ action !== 'select' &&
58
+ action !== 'focus' &&
59
+ action !== 'click' &&
60
+ action !== 'submit'
61
+ ) {
62
+ console.warn('[FormAutoControl] unknown action', action)
63
+ return
64
+ }
65
+
66
+ helpers.runAutoControl(() => {
67
+ if (action === 'submit') {
68
+ // 返回 Promise,使 runAutoControl 在 settle 前保持 depth
69
+ return targets.submit()
70
+ }
71
+
72
+ const field = typeof data.field === 'string' ? data.field.trim() : ''
73
+ if (!field) {
74
+ console.warn('[FormAutoControl] missing field for action', action)
75
+ return
76
+ }
77
+
78
+ const component = data.component?.trim() || 'Unknown'
79
+
80
+ if (action === 'change' || action === 'select') {
81
+ if (data.value === undefined) {
82
+ console.warn('[FormAutoControl] missing value for action', action)
83
+ return
84
+ }
85
+ targets.setValues({ [field]: data.value })
86
+ helpers.reportEvent({
87
+ action,
88
+ component,
89
+ field,
90
+ value: data.value,
91
+ })
92
+ return
93
+ }
94
+
95
+ if (action === 'focus') {
96
+ focusField(targets.rootElement, field)
97
+ helpers.reportEvent({
98
+ action: 'focus',
99
+ component,
100
+ field,
101
+ })
102
+ return
103
+ }
104
+
105
+ // click:由组件 onClick 打点回传(autoControlDepth 生效)
106
+ if (!clickField(targets.rootElement, field)) {
107
+ console.warn('[FormAutoControl] click target not found', field)
108
+ }
109
+ })
110
+ }
@@ -0,0 +1,129 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useMemo,
6
+ useRef,
7
+ type ReactNode,
8
+ } from 'react'
9
+ import type { FormRuntimeWsClient } from './formRuntimeWs'
10
+ import type { FormRuntimeEventData } from './types'
11
+
12
+ export interface FormRuntimeContextValue {
13
+ reportEvent: (event: FormRuntimeEventData) => void
14
+ reportData: (values: Record<string, unknown>) => void
15
+ /**
16
+ * 在自动控上下文中执行 fn。若 fn 返回 Promise,depth 保持到 settle,
17
+ * 使异步 submit 内的 reportEvent 仍为 form_auto_control_bac_event。
18
+ */
19
+ runAutoControl: <T>(fn: () => T) => T
20
+ isAutoControl: () => boolean
21
+ }
22
+
23
+ const FormRuntimeContext = createContext<FormRuntimeContextValue | null>(null)
24
+
25
+ function isThenable(value: unknown): value is PromiseLike<unknown> {
26
+ return (
27
+ value != null &&
28
+ (typeof value === 'object' || typeof value === 'function') &&
29
+ typeof (value as PromiseLike<unknown>).then === 'function'
30
+ )
31
+ }
32
+
33
+ const noopRuntime: FormRuntimeContextValue = {
34
+ reportEvent: () => {},
35
+ reportData: () => {},
36
+ runAutoControl: (fn) => fn(),
37
+ isAutoControl: () => false,
38
+ }
39
+
40
+ function omitUndefinedValues(
41
+ values: Record<string, unknown>,
42
+ ): Record<string, unknown> {
43
+ const out: Record<string, unknown> = {}
44
+ for (const [key, value] of Object.entries(values)) {
45
+ if (value !== undefined) out[key] = value
46
+ }
47
+ return out
48
+ }
49
+
50
+ export interface FormRuntimeProviderProps {
51
+ client: FormRuntimeWsClient | null
52
+ children: ReactNode
53
+ }
54
+
55
+ export function FormRuntimeProvider({
56
+ client,
57
+ children,
58
+ }: FormRuntimeProviderProps) {
59
+ const autoControlDepthRef = useRef(0)
60
+ const clientRef = useRef(client)
61
+ clientRef.current = client
62
+
63
+ const reportEvent = useCallback((event: FormRuntimeEventData) => {
64
+ const ws = clientRef.current
65
+ if (!ws) return
66
+ const data: Record<string, unknown> = {
67
+ action: event.action,
68
+ component: event.component,
69
+ }
70
+ if (event.field !== undefined) data.field = event.field
71
+ if (event.value !== undefined) data.value = event.value
72
+ const type =
73
+ autoControlDepthRef.current > 0
74
+ ? 'form_auto_control_bac_event'
75
+ : 'form_runtime_event'
76
+ ws.send(type, data)
77
+ }, [])
78
+
79
+ const reportData = useCallback((values: Record<string, unknown>) => {
80
+ const ws = clientRef.current
81
+ if (!ws) return
82
+ ws.send('form_runtime_data', omitUndefinedValues(values))
83
+ }, [])
84
+
85
+ const runAutoControl = useCallback(<T,>(fn: () => T): T => {
86
+ autoControlDepthRef.current += 1
87
+ let syncResult: T
88
+ try {
89
+ syncResult = fn()
90
+ } catch (error) {
91
+ autoControlDepthRef.current = Math.max(0, autoControlDepthRef.current - 1)
92
+ throw error
93
+ }
94
+ if (isThenable(syncResult)) {
95
+ const promise = Promise.resolve(syncResult).finally(() => {
96
+ autoControlDepthRef.current = Math.max(0, autoControlDepthRef.current - 1)
97
+ })
98
+ return promise as T
99
+ }
100
+ autoControlDepthRef.current = Math.max(0, autoControlDepthRef.current - 1)
101
+ return syncResult
102
+ }, [])
103
+
104
+ const isAutoControl = useCallback(
105
+ () => autoControlDepthRef.current > 0,
106
+ [],
107
+ )
108
+
109
+ const value = useMemo(
110
+ (): FormRuntimeContextValue => ({
111
+ reportEvent,
112
+ reportData,
113
+ runAutoControl,
114
+ isAutoControl,
115
+ }),
116
+ [reportEvent, reportData, runAutoControl, isAutoControl],
117
+ )
118
+
119
+ return (
120
+ <FormRuntimeContext.Provider value={value}>
121
+ {children}
122
+ </FormRuntimeContext.Provider>
123
+ )
124
+ }
125
+
126
+ /** 无 Provider 时 no-op(表单管理端预览不受影响)。 */
127
+ export function useFormRuntime(): FormRuntimeContextValue {
128
+ return useContext(FormRuntimeContext) ?? noopRuntime
129
+ }