@skopon-cool/form-sdk 0.2.5 → 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 (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 +1514 -1081
  15. package/dist/runtime/formAutoControl.d.ts +15 -0
  16. package/dist/runtime/formAutoControl.d.ts.map +1 -0
  17. package/dist/runtime/formRuntimeContext.d.ts +17 -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 +71 -0
  34. package/src/runtime/formAutoControl.ts +109 -0
  35. package/src/runtime/formRuntimeContext.tsx +107 -0
  36. package/src/runtime/formRuntimeWs.ts +158 -0
  37. package/src/runtime/types.ts +26 -0
@@ -0,0 +1,15 @@
1
+ import type { FormAutoControlEventData, FormRuntimeEventData } from './types';
2
+ export interface FormAutoControlTargets {
3
+ setValues: (values: Record<string, unknown>) => void;
4
+ getValues: (fieldNames?: string[]) => Record<string, unknown>;
5
+ submit: () => void | Promise<void>;
6
+ /** 卡片根节点,用于按 data-skopon-field 定位 DOM */
7
+ rootElement: HTMLElement | null;
8
+ }
9
+ export interface FormAutoControlHelpers {
10
+ reportEvent: (event: FormRuntimeEventData) => void;
11
+ runAutoControl: <T>(fn: () => T) => T;
12
+ }
13
+ /** 执行下行 form_auto_control_event;缺参或未知 action 时 warn 并忽略。 */
14
+ export declare function executeFormAutoControl(data: FormAutoControlEventData, targets: FormAutoControlTargets, helpers: FormAutoControlHelpers): void;
15
+ //# sourceMappingURL=formAutoControl.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"formAutoControl.d.ts","sourceRoot":"","sources":["../../src/runtime/formAutoControl.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,wBAAwB,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAA;AAE7E,MAAM,WAAW,sBAAsB;IACrC,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAA;IACpD,SAAS,EAAE,CAAC,UAAU,CAAC,EAAE,MAAM,EAAE,KAAK,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC7D,MAAM,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAClC,yCAAyC;IACzC,WAAW,EAAE,WAAW,GAAG,IAAI,CAAA;CAChC;AAED,MAAM,WAAW,sBAAsB;IACrC,WAAW,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK,IAAI,CAAA;IAClD,cAAc,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,KAAK,CAAC,CAAA;CACtC;AAiCD,4DAA4D;AAC5D,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,wBAAwB,EAC9B,OAAO,EAAE,sBAAsB,EAC/B,OAAO,EAAE,sBAAsB,GAC9B,IAAI,CAyDN"}
@@ -0,0 +1,17 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { FormRuntimeWsClient } from './formRuntimeWs';
3
+ import type { FormRuntimeEventData } from './types';
4
+ export interface FormRuntimeContextValue {
5
+ reportEvent: (event: FormRuntimeEventData) => void;
6
+ reportData: (values: Record<string, unknown>) => void;
7
+ runAutoControl: <T>(fn: () => T) => T;
8
+ isAutoControl: () => boolean;
9
+ }
10
+ export interface FormRuntimeProviderProps {
11
+ client: FormRuntimeWsClient | null;
12
+ children: ReactNode;
13
+ }
14
+ export declare function FormRuntimeProvider({ client, children, }: FormRuntimeProviderProps): import("react").JSX.Element;
15
+ /** 无 Provider 时 no-op(表单管理端预览不受影响)。 */
16
+ export declare function useFormRuntime(): FormRuntimeContextValue;
17
+ //# sourceMappingURL=formRuntimeContext.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"formRuntimeContext.d.ts","sourceRoot":"","sources":["../../src/runtime/formRuntimeContext.tsx"],"names":[],"mappings":"AAAA,OAAO,EAML,KAAK,SAAS,EACf,MAAM,OAAO,CAAA;AACd,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAA;AAC1D,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAA;AAEnD,MAAM,WAAW,uBAAuB;IACtC,WAAW,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK,IAAI,CAAA;IAClD,UAAU,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAA;IACrD,cAAc,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,KAAK,CAAC,CAAA;IACrC,aAAa,EAAE,MAAM,OAAO,CAAA;CAC7B;AAqBD,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,mBAAmB,GAAG,IAAI,CAAA;IAClC,QAAQ,EAAE,SAAS,CAAA;CACpB;AAED,wBAAgB,mBAAmB,CAAC,EAClC,MAAM,EACN,QAAQ,GACT,EAAE,wBAAwB,+BAwD1B;AAED,uCAAuC;AACvC,wBAAgB,cAAc,IAAI,uBAAuB,CAExD"}
@@ -0,0 +1,27 @@
1
+ import type { FormAutoControlEventData, FormRuntimeEnvelope, FormRuntimeOutboundType } from './types';
2
+ export type FormAutoControlHandler = (data: FormAutoControlEventData, envelope: FormRuntimeEnvelope<FormAutoControlEventData>) => void;
3
+ export interface FormRuntimeWsClientOptions {
4
+ url: string;
5
+ askUserMessageId: string;
6
+ onAutoControl?: FormAutoControlHandler;
7
+ }
8
+ export declare class FormRuntimeWsClient {
9
+ private readonly url;
10
+ private readonly askUserMessageId;
11
+ private readonly onAutoControl?;
12
+ private ws;
13
+ private closed;
14
+ private reconnectAttempt;
15
+ private reconnectTimer;
16
+ private sendQueue;
17
+ constructor(options: FormRuntimeWsClientOptions);
18
+ connect(): void;
19
+ send(type: FormRuntimeOutboundType, data: Record<string, unknown>): void;
20
+ close(): void;
21
+ private enqueue;
22
+ private flushQueue;
23
+ private scheduleReconnect;
24
+ private clearReconnectTimer;
25
+ private handleMessage;
26
+ }
27
+ //# sourceMappingURL=formRuntimeWs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"formRuntimeWs.d.ts","sourceRoot":"","sources":["../../src/runtime/formRuntimeWs.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,wBAAwB,EACxB,mBAAmB,EACnB,uBAAuB,EACxB,MAAM,SAAS,CAAA;AAKhB,MAAM,MAAM,sBAAsB,GAAG,CACnC,IAAI,EAAE,wBAAwB,EAC9B,QAAQ,EAAE,mBAAmB,CAAC,wBAAwB,CAAC,KACpD,IAAI,CAAA;AAET,MAAM,WAAW,0BAA0B;IACzC,GAAG,EAAE,MAAM,CAAA;IACX,gBAAgB,EAAE,MAAM,CAAA;IACxB,aAAa,CAAC,EAAE,sBAAsB,CAAA;CACvC;AAED,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAQ;IAC5B,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAQ;IACzC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAwB;IACvD,OAAO,CAAC,EAAE,CAAyB;IACnC,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,gBAAgB,CAAI;IAC5B,OAAO,CAAC,cAAc,CAA6C;IACnE,OAAO,CAAC,SAAS,CAAe;gBAEpB,OAAO,EAAE,0BAA0B;IAM/C,OAAO,IAAI,IAAI;IA0Bf,IAAI,CACF,IAAI,EAAE,uBAAuB,EAC7B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC5B,IAAI;IAqBP,KAAK,IAAI,IAAI;IAkBb,OAAO,CAAC,OAAO;IAOf,OAAO,CAAC,UAAU;IASlB,OAAO,CAAC,iBAAiB;IAczB,OAAO,CAAC,mBAAmB;IAO3B,OAAO,CAAC,aAAa;CAgBtB"}
@@ -0,0 +1,20 @@
1
+ export type FormRuntimeAction = 'focus' | 'change' | 'select' | 'click' | 'submit';
2
+ export interface FormRuntimeEventData {
3
+ action: FormRuntimeAction;
4
+ component: string;
5
+ field?: string;
6
+ value?: unknown;
7
+ }
8
+ export interface FormAutoControlEventData {
9
+ action: FormRuntimeAction;
10
+ field?: string;
11
+ value?: unknown;
12
+ component?: string;
13
+ }
14
+ export type FormRuntimeOutboundType = 'form_runtime_data' | 'form_runtime_event' | 'form_auto_control_bac_event';
15
+ export interface FormRuntimeEnvelope<T = Record<string, unknown>> {
16
+ type: string;
17
+ ask_user_message_id: string;
18
+ data: T;
19
+ }
20
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/runtime/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GAAG,OAAO,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,GAAG,QAAQ,CAAA;AAElF,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,iBAAiB,CAAA;IACzB,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,OAAO,CAAA;CAChB;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,iBAAiB,CAAA;IACzB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,MAAM,uBAAuB,GAC/B,mBAAmB,GACnB,oBAAoB,GACpB,6BAA6B,CAAA;AAEjC,MAAM,WAAW,mBAAmB,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAC9D,IAAI,EAAE,MAAM,CAAA;IACZ,mBAAmB,EAAE,MAAM,CAAA;IAC3B,IAAI,EAAE,CAAC,CAAA;CACR"}
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.2.5",
6
+ "version": "0.3.0",
7
7
  "description": "Skopon form rendering SDK (A2UI + form_definition) with submit helpers",
8
8
  "type": "module",
9
9
  "sideEffects": [
@@ -242,7 +242,10 @@ const SkoponSelectApi = {
242
242
 
243
243
  function SkoponSelectPreview({ context }: { context: ComponentContext }) {
244
244
  const { interactive } = useA2uiPreviewMode()
245
- const { value, setValue } = useSkoponBoundField(context)
245
+ const { value, setValue, setValueSilent, fieldName } = useSkoponBoundField(context, 'value', {
246
+ component: 'SkoponSelect',
247
+ changeAction: 'select',
248
+ })
246
249
  const props = context.componentModel.properties as Record<string, unknown>
247
250
  const label = readString(props.label)
248
251
  const placeholder = readString(props.placeholder)
@@ -251,11 +254,11 @@ function SkoponSelectPreview({ context }: { context: ComponentContext }) {
251
254
 
252
255
  useEffect(() => {
253
256
  const normalized = normalizeSelectStoredValue(value, isMultiple)
254
- if (normalized !== null) setValue(normalized)
255
- }, [isMultiple, value, setValue])
257
+ if (normalized !== null) setValueSilent(normalized)
258
+ }, [isMultiple, value, setValueSilent])
256
259
 
257
260
  return (
258
- <div className="form-block-preview">
261
+ <div className="form-block-preview" data-skopon-field={fieldName || undefined}>
259
262
  {label ? <div className="form-block-preview-label">{label}</div> : null}
260
263
  <Select
261
264
  mode={isMultiple ? 'multiple' : undefined}
@@ -279,7 +282,10 @@ const SkoponSelectImpl = createBinderlessComponentImplementation(
279
282
 
280
283
  function ChoicePickerPreview({ context }: { context: ComponentContext }) {
281
284
  const { interactive } = useA2uiPreviewMode()
282
- const { value, setValue } = useSkoponBoundField(context)
285
+ const { value, setValue, setValueSilent, fieldName } = useSkoponBoundField(context, 'value', {
286
+ component: 'ChoicePicker',
287
+ changeAction: 'select',
288
+ })
283
289
  const props = context.componentModel.properties as Record<string, unknown>
284
290
  const label = readString(props.label)
285
291
  const variant = readString(props.variant) || 'mutuallyExclusive'
@@ -301,11 +307,11 @@ function ChoicePickerPreview({ context }: { context: ComponentContext }) {
301
307
 
302
308
  useEffect(() => {
303
309
  const normalized = normalizeSelectStoredValue(value, isMultiplePicker)
304
- if (normalized !== null) setValue(normalized)
305
- }, [isMultiplePicker, value, setValue])
310
+ if (normalized !== null) setValueSilent(normalized)
311
+ }, [isMultiplePicker, value, setValueSilent])
306
312
 
307
313
  return (
308
- <div className="form-block-preview">
314
+ <div className="form-block-preview" data-skopon-field={fieldName || undefined}>
309
315
  {label ? <div className="form-block-preview-label">{label}</div> : null}
310
316
  {effectiveType === 'select' ? (
311
317
  <Select
@@ -370,12 +376,15 @@ const ChoicePickerImpl = createBinderlessComponentImplementation(
370
376
 
371
377
  function ToggleSwitchPreview({ context }: { context: ComponentContext }) {
372
378
  const { interactive } = useA2uiPreviewMode()
373
- const { value, setValue } = useSkoponBoundField(context)
379
+ const { value, setValue, fieldName } = useSkoponBoundField(context, 'value', {
380
+ component: 'CheckBox',
381
+ changeAction: 'change',
382
+ })
374
383
  const props = context.componentModel.properties as Record<string, unknown>
375
384
  const label = readString(props.label)
376
385
 
377
386
  return (
378
- <div className="form-block-preview">
387
+ <div className="form-block-preview" data-skopon-field={fieldName || undefined}>
379
388
  {label ? <div className="form-block-preview-label">{label}</div> : null}
380
389
  <Switch
381
390
  disabled={!interactive}
@@ -409,7 +418,10 @@ function toDateTimePickerValue(
409
418
 
410
419
  function DateTimeInputPreview({ context }: { context: ComponentContext }) {
411
420
  const { interactive } = useA2uiPreviewMode()
412
- const { value, setValue } = useSkoponBoundField(context)
421
+ const { value, setValue, fieldName, reportFocus } = useSkoponBoundField(context, 'value', {
422
+ component: 'DateTimeInput',
423
+ changeAction: 'change',
424
+ })
413
425
  const props = context.componentModel.properties as Record<string, unknown>
414
426
  const label = readString(props.label)
415
427
  const placeholder = readString(props.placeholder)
@@ -434,7 +446,7 @@ function DateTimeInputPreview({ context }: { context: ComponentContext }) {
434
446
  }
435
447
 
436
448
  return (
437
- <div className="form-block-preview">
449
+ <div className="form-block-preview" data-skopon-field={fieldName || undefined}>
438
450
  {label ? <div className="form-block-preview-label">{label}</div> : null}
439
451
  {!enableDate && enableTime ? (
440
452
  <TimePicker
@@ -444,6 +456,7 @@ function DateTimeInputPreview({ context }: { context: ComponentContext }) {
444
456
  placeholder={placeholder || '选择时间'}
445
457
  value={pickerValue}
446
458
  onChange={handleChange}
459
+ onFocus={reportFocus}
447
460
  />
448
461
  ) : (
449
462
  <DatePicker
@@ -457,6 +470,7 @@ function DateTimeInputPreview({ context }: { context: ComponentContext }) {
457
470
  }
458
471
  value={pickerValue}
459
472
  onChange={handleChange}
473
+ onFocus={reportFocus}
460
474
  />
461
475
  )}
462
476
  </div>
@@ -498,7 +512,10 @@ const FileUploadApi = {
498
512
 
499
513
  function FileUploadPreview({ context }: { context: ComponentContext }) {
500
514
  const { interactive } = useA2uiPreviewMode()
501
- const { value, setValue } = useSkoponBoundField(context)
515
+ const { value, setValue, fieldName } = useSkoponBoundField(context, 'value', {
516
+ component: 'FileUpload',
517
+ changeAction: 'change',
518
+ })
502
519
  const props = context.componentModel.properties as Record<string, unknown>
503
520
  const label = readString(props.label)
504
521
  const accept = readString(props.accept)
@@ -524,7 +541,7 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
524
541
  const belowMinCount = selectedNames.length < minCount
525
542
 
526
543
  return (
527
- <div className="form-block-preview">
544
+ <div className="form-block-preview" data-skopon-field={fieldName || undefined}>
528
545
  {label ? <div className="form-block-preview-label">{label}</div> : null}
529
546
  <Upload.Dragger
530
547
  disabled={!interactive || atMaxLimit}
@@ -585,7 +602,11 @@ const FileUploadImpl = createBinderlessComponentImplementation(
585
602
 
586
603
  function TextFieldPreview({ context }: { context: ComponentContext }) {
587
604
  const { interactive } = useA2uiPreviewMode()
588
- const { value, setValue } = useSkoponBoundField(context)
605
+ const { value, setValue, fieldName, reportFocus } = useSkoponBoundField(context, 'value', {
606
+ component: 'TextField',
607
+ changeAction: 'change',
608
+ debounceChange: true,
609
+ })
589
610
  const props = context.componentModel.properties as Record<string, unknown>
590
611
  const label = readString(props.label)
591
612
  const placeholder = readString(props.placeholder)
@@ -624,6 +645,7 @@ function TextFieldPreview({ context }: { context: ComponentContext }) {
624
645
  setValue(next)
625
646
  validateFormat(next)
626
647
  }}
648
+ onFocus={reportFocus}
627
649
  onBlur={(e) => validateFormat(e.target.value)}
628
650
  />
629
651
  )
@@ -638,6 +660,7 @@ function TextFieldPreview({ context }: { context: ComponentContext }) {
638
660
  placeholder={placeholder || '数字'}
639
661
  value={numericValue}
640
662
  onChange={(next) => setValue(next == null ? '' : String(next))}
663
+ onFocus={reportFocus}
641
664
  />
642
665
  )
643
666
  } else if (variant === 'obscured') {
@@ -648,6 +671,7 @@ function TextFieldPreview({ context }: { context: ComponentContext }) {
648
671
  placeholder={placeholder}
649
672
  value={stringValue}
650
673
  onChange={(e) => setValue(e.target.value)}
674
+ onFocus={reportFocus}
651
675
  />
652
676
  )
653
677
  } else {
@@ -663,13 +687,14 @@ function TextFieldPreview({ context }: { context: ComponentContext }) {
663
687
  setValue(next)
664
688
  validateFormat(next)
665
689
  }}
690
+ onFocus={reportFocus}
666
691
  onBlur={(e) => validateFormat(e.target.value)}
667
692
  />
668
693
  )
669
694
  }
670
695
 
671
696
  return (
672
- <div className="form-block-preview">
697
+ <div className="form-block-preview" data-skopon-field={fieldName || undefined}>
673
698
  {label ? <div className="form-block-preview-label">{label}</div> : null}
674
699
  {control}
675
700
  {formatError ? (
@@ -110,7 +110,10 @@ function CaseCard({
110
110
  function SkoponCaseSelectPreview({ context }: { context: ComponentContext }) {
111
111
  const { interactive } = useA2uiPreviewMode()
112
112
  const caseSearch = useCaseSearch()
113
- const { value, setValue } = useSkoponBoundField(context)
113
+ const { value, setValue, setValueSilent, fieldName } = useSkoponBoundField(context, 'value', {
114
+ component: 'SkoponCaseSelect',
115
+ changeAction: 'click',
116
+ })
114
117
  const props = context.componentModel.properties as Record<string, unknown>
115
118
 
116
119
  const label = readString(props.label)
@@ -134,8 +137,8 @@ function SkoponCaseSelectPreview({ context }: { context: ComponentContext }) {
134
137
 
135
138
  useEffect(() => {
136
139
  const clamped = clampStringArraySelection(selectedIds, maxCount)
137
- if (clamped !== null) setValue(clamped)
138
- }, [maxCount, selectedIds, setValue])
140
+ if (clamped !== null) setValueSilent(clamped)
141
+ }, [maxCount, selectedIds, setValueSilent])
139
142
 
140
143
  const emptyHint = useMemo(() => {
141
144
  if (!caseSearch) return '未配置案例搜索服务'
@@ -202,7 +205,10 @@ function SkoponCaseSelectPreview({ context }: { context: ComponentContext }) {
202
205
  }
203
206
 
204
207
  return (
205
- <div className="form-block-preview skopon-resume-select">
208
+ <div
209
+ className="form-block-preview skopon-resume-select"
210
+ data-skopon-field={fieldName || undefined}
211
+ >
206
212
  {label ? <div className="form-block-preview-label">{label}</div> : null}
207
213
  {loading && items.length === 0 ? (
208
214
  <div className="skopon-resume-select-status">
@@ -163,7 +163,10 @@ function ResumeCard({
163
163
  function SkoponResumeSelectPreview({ context }: { context: ComponentContext }) {
164
164
  const { interactive } = useA2uiPreviewMode()
165
165
  const resumeSearch = useResumeSearch()
166
- const { value, setValue } = useSkoponBoundField(context)
166
+ const { value, setValue, setValueSilent, fieldName } = useSkoponBoundField(context, 'value', {
167
+ component: 'SkoponResumeSelect',
168
+ changeAction: 'click',
169
+ })
167
170
  const props = context.componentModel.properties as Record<string, unknown>
168
171
 
169
172
  const label = readString(props.label)
@@ -187,8 +190,8 @@ function SkoponResumeSelectPreview({ context }: { context: ComponentContext }) {
187
190
 
188
191
  useEffect(() => {
189
192
  const clamped = clampStringArraySelection(selectedIds, maxCount)
190
- if (clamped !== null) setValue(clamped)
191
- }, [maxCount, selectedIds, setValue])
193
+ if (clamped !== null) setValueSilent(clamped)
194
+ }, [maxCount, selectedIds, setValueSilent])
192
195
 
193
196
  const fetchPage = useCallback(
194
197
  async (targetPage: number) => {
@@ -250,7 +253,10 @@ function SkoponResumeSelectPreview({ context }: { context: ComponentContext }) {
250
253
  const emptyLabel = placeholder || '暂无匹配的简历'
251
254
 
252
255
  return (
253
- <div className="form-block-preview skopon-resume-select">
256
+ <div
257
+ className="form-block-preview skopon-resume-select"
258
+ data-skopon-field={fieldName || undefined}
259
+ >
254
260
  {label ? <div className="form-block-preview-label">{label}</div> : null}
255
261
  {loading && items.length === 0 ? (
256
262
  <div className="skopon-resume-select-status">
@@ -1,8 +1,12 @@
1
- import { useCallback, useSyncExternalStore } from 'react'
1
+ import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react'
2
2
  import type { ComponentContext } from '@a2ui/web_core/v0_9'
3
+ import { useFormRuntime } from '../runtime/formRuntimeContext'
4
+ import type { FormRuntimeAction } from '../runtime/types'
3
5
 
4
6
  type DynamicBinding = Parameters<ComponentContext['dataContext']['resolveDynamicValue']>[0]
5
7
 
8
+ const TEXT_CHANGE_DEBOUNCE_MS = 300
9
+
6
10
  function bindingPath(value: unknown): string | null {
7
11
  if (value && typeof value === 'object' && 'path' in value) {
8
12
  const path = String((value as { path: unknown }).path ?? '').trim()
@@ -11,6 +15,14 @@ function bindingPath(value: unknown): string | null {
11
15
  return null
12
16
  }
13
17
 
18
+ /** 从 path(如 /fieldName)提取字段名。 */
19
+ export function fieldNameFromBindingPath(path: string | null): string | undefined {
20
+ if (!path) return undefined
21
+ const trimmed = path.trim()
22
+ if (!trimmed) return undefined
23
+ return trimmed.startsWith('/') ? trimmed.slice(1) : trimmed
24
+ }
25
+
14
26
  /** 从 A2UI 组件 context 读取当前绑定字段值(不订阅更新,适合事件回调内读最新值)。 */
15
27
  export function readBoundFieldValue(context: ComponentContext, field = 'value'): unknown {
16
28
  const props = context.componentModel.properties as Record<string, unknown>
@@ -20,11 +32,44 @@ export function readBoundFieldValue(context: ComponentContext, field = 'value'):
20
32
  return context.dataContext.resolveDynamicValue(binding)
21
33
  }
22
34
 
35
+ export interface UseSkoponBoundFieldOptions {
36
+ /** catalog 组件名,用于 runtime event */
37
+ component?: string
38
+ /** setValue 上报的 action,默认 change */
39
+ changeAction?: Extract<FormRuntimeAction, 'change' | 'select' | 'click'>
40
+ /** TextField 等文本输入是否对 change 防抖 */
41
+ debounceChange?: boolean
42
+ }
43
+
23
44
  /** 将 skopon 预览控件绑定到 A2UI surface dataModel(与 basicCatalog binder 行为一致)。 */
24
- export function useSkoponBoundField(context: ComponentContext, field = 'value') {
45
+ export function useSkoponBoundField(
46
+ context: ComponentContext,
47
+ field = 'value',
48
+ options: UseSkoponBoundFieldOptions = {},
49
+ ) {
25
50
  const props = context.componentModel.properties as Record<string, unknown>
26
51
  const binding = props[field] as DynamicBinding | undefined
27
52
  const path = bindingPath(binding)
53
+ const fieldName = fieldNameFromBindingPath(path)
54
+ const runtime = useFormRuntime()
55
+ const {
56
+ component = 'Unknown',
57
+ changeAction = 'change',
58
+ debounceChange = false,
59
+ } = options
60
+
61
+ const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
62
+ const runtimeRef = useRef(runtime)
63
+ runtimeRef.current = runtime
64
+
65
+ useEffect(() => {
66
+ return () => {
67
+ if (debounceTimerRef.current != null) {
68
+ clearTimeout(debounceTimerRef.current)
69
+ debounceTimerRef.current = null
70
+ }
71
+ }
72
+ }, [])
28
73
 
29
74
  const subscribe = useCallback(
30
75
  (onStoreChange: () => void) => {
@@ -42,7 +87,33 @@ export function useSkoponBoundField(context: ComponentContext, field = 'value')
42
87
 
43
88
  const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
44
89
 
45
- const setValue = useCallback(
90
+ const reportValueChange = useCallback(
91
+ (next: unknown) => {
92
+ if (!fieldName) return
93
+ const emit = () => {
94
+ runtimeRef.current.reportEvent({
95
+ action: changeAction,
96
+ component,
97
+ field: fieldName,
98
+ value: next,
99
+ })
100
+ }
101
+ if (!debounceChange) {
102
+ emit()
103
+ return
104
+ }
105
+ if (debounceTimerRef.current != null) {
106
+ clearTimeout(debounceTimerRef.current)
107
+ }
108
+ debounceTimerRef.current = setTimeout(() => {
109
+ debounceTimerRef.current = null
110
+ emit()
111
+ }, TEXT_CHANGE_DEBOUNCE_MS)
112
+ },
113
+ [fieldName, changeAction, component, debounceChange],
114
+ )
115
+
116
+ const setValueSilent = useCallback(
46
117
  (next: unknown) => {
47
118
  if (!path || !binding || typeof binding !== 'object' || !('path' in binding)) return
48
119
  context.dataContext.set(String((binding as { path: string }).path), next)
@@ -50,7 +121,32 @@ export function useSkoponBoundField(context: ComponentContext, field = 'value')
50
121
  [context, binding, path],
51
122
  )
52
123
 
53
- return { value, setValue, hasBinding: !!path }
124
+ const setValue = useCallback(
125
+ (next: unknown) => {
126
+ setValueSilent(next)
127
+ reportValueChange(next)
128
+ },
129
+ [setValueSilent, reportValueChange],
130
+ )
131
+
132
+ const reportFocus = useCallback(() => {
133
+ if (!fieldName) return
134
+ runtimeRef.current.reportEvent({
135
+ action: 'focus',
136
+ component,
137
+ field: fieldName,
138
+ })
139
+ }, [fieldName, component])
140
+
141
+ return {
142
+ value,
143
+ setValue,
144
+ /** 写入 dataModel 但不上报(用于模式切换规范化等内部同步) */
145
+ setValueSilent,
146
+ hasBinding: !!path,
147
+ fieldName,
148
+ reportFocus,
149
+ }
54
150
  }
55
151
 
56
152
  export function asOptionalString(value: unknown): string | undefined {
@@ -4,9 +4,11 @@
4
4
  import { afterEach, describe, expect, it } from 'vitest'
5
5
  import {
6
6
  DEFAULT_AUTH_ANSWER_AGENT_CALLBACK_URL_FIELD,
7
+ DEFAULT_AUTH_REALTIME_WS_URL_FIELD,
7
8
  DEFAULT_ANSWER_AGENT_CALLBACK_URL,
8
9
  DEFAULT_AUTH_STORAGE_KEY,
9
10
  resolveAnswerAgentCallbackUrl,
11
+ resolveRealtimeWsUrl,
10
12
  resolveSkoponAuthHeaders,
11
13
  resolveSubmitAuthHeaders,
12
14
  } from './authStorage'
@@ -129,3 +131,35 @@ describe('resolveAnswerAgentCallbackUrl', () => {
129
131
  expect(resolveAnswerAgentCallbackUrl()).toBe(DEFAULT_ANSWER_AGENT_CALLBACK_URL)
130
132
  })
131
133
  })
134
+
135
+ describe('resolveRealtimeWsUrl', () => {
136
+ afterEach(() => {
137
+ sessionStorage.clear()
138
+ localStorage.clear()
139
+ })
140
+
141
+ it('reads realtime_ws_url from sessionStorage auth_user', () => {
142
+ sessionStorage.setItem(
143
+ DEFAULT_AUTH_STORAGE_KEY,
144
+ JSON.stringify({
145
+ token: 'tok',
146
+ [DEFAULT_AUTH_REALTIME_WS_URL_FIELD]: 'wss://example.com/ws',
147
+ }),
148
+ )
149
+ expect(resolveRealtimeWsUrl()).toBe('wss://example.com/ws')
150
+ })
151
+
152
+ it('returns empty string when session is missing', () => {
153
+ expect(resolveRealtimeWsUrl()).toBe('')
154
+ })
155
+
156
+ it('returns empty string when field is missing or blank', () => {
157
+ sessionStorage.setItem(DEFAULT_AUTH_STORAGE_KEY, JSON.stringify({ token: 'tok' }))
158
+ expect(resolveRealtimeWsUrl()).toBe('')
159
+ sessionStorage.setItem(
160
+ DEFAULT_AUTH_STORAGE_KEY,
161
+ JSON.stringify({ token: 'tok', [DEFAULT_AUTH_REALTIME_WS_URL_FIELD]: ' ' }),
162
+ )
163
+ expect(resolveRealtimeWsUrl()).toBe('')
164
+ })
165
+ })
@@ -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
 
@@ -74,6 +75,32 @@ export function resolveAnswerAgentCallbackUrl(
74
75
  return trimmed || DEFAULT_ANSWER_AGENT_CALLBACK_URL
75
76
  }
76
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
+
77
104
  /** 合并 session 鉴权头与宿主显式 headers;显式 headers 优先。 */
78
105
  export function resolveSubmitAuthHeaders(
79
106
  explicit?: Record<string, string>,