@skopon-cool/form-sdk 0.5.2 → 0.6.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 (35) hide show
  1. package/README.md +34 -3
  2. package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
  3. package/dist/adapter/formSchema.d.ts.map +1 -1
  4. package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
  5. package/dist/catalog/fileUploadStatusContext.d.ts +12 -0
  6. package/dist/catalog/fileUploadStatusContext.d.ts.map +1 -0
  7. package/dist/client/authStorage.d.ts +9 -0
  8. package/dist/client/authStorage.d.ts.map +1 -1
  9. package/dist/client/uploadClient.d.ts +17 -0
  10. package/dist/client/uploadClient.d.ts.map +1 -0
  11. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  12. package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -1
  13. package/dist/components/SkoponFormRenderer.d.ts +2 -0
  14. package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1565 -1376
  18. package/package.json +1 -1
  19. package/src/adapter/a2uiAdapter.test.ts +19 -0
  20. package/src/adapter/a2uiAdapter.ts +1 -0
  21. package/src/adapter/formSchema.test.ts +34 -0
  22. package/src/adapter/formSchema.ts +12 -9
  23. package/src/catalog/a2uiCustomCatalog.tsx +187 -35
  24. package/src/catalog/fileUploadPreview.test.tsx +177 -0
  25. package/src/catalog/fileUploadStatusContext.tsx +64 -0
  26. package/src/client/authStorage.test.ts +67 -1
  27. package/src/client/authStorage.ts +67 -0
  28. package/src/client/uploadClient.test.ts +143 -0
  29. package/src/client/uploadClient.ts +72 -0
  30. package/src/components/AskUserFormCard.tsx +8 -1
  31. package/src/components/AskUserFormCard.upload.test.tsx +91 -0
  32. package/src/components/SkoponA2uiStreamRenderer.tsx +14 -11
  33. package/src/components/SkoponFormRenderer.tsx +24 -16
  34. package/src/index.ts +3 -0
  35. package/src/submit/submit.test.ts +25 -0
@@ -1,14 +1,16 @@
1
1
  /**
2
2
  * @vitest-environment happy-dom
3
3
  */
4
- import { afterEach, describe, expect, it } from 'vitest'
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
5
  import {
6
6
  DEFAULT_AUTH_ANSWER_AGENT_CALLBACK_URL_FIELD,
7
7
  DEFAULT_AUTH_REALTIME_WS_URL_FIELD,
8
+ DEFAULT_AUTH_SKOPON_API_BASE_URL_FIELD,
8
9
  DEFAULT_ANSWER_AGENT_CALLBACK_URL,
9
10
  DEFAULT_AUTH_STORAGE_KEY,
10
11
  resolveAnswerAgentCallbackUrl,
11
12
  resolveRealtimeWsUrl,
13
+ resolveSkoponApiBaseUrl,
12
14
  resolveSkoponAuthHeaders,
13
15
  resolveSubmitAuthHeaders,
14
16
  } from './authStorage'
@@ -17,6 +19,7 @@ describe('resolveSkoponAuthHeaders', () => {
17
19
  afterEach(() => {
18
20
  sessionStorage.clear()
19
21
  localStorage.clear()
22
+ vi.unstubAllGlobals()
20
23
  })
21
24
 
22
25
  it('reads Bearer token from sessionStorage auth_user by default', () => {
@@ -66,6 +69,69 @@ describe('resolveSkoponAuthHeaders', () => {
66
69
  })
67
70
  })
68
71
 
72
+ describe('resolveSkoponApiBaseUrl', () => {
73
+ afterEach(() => {
74
+ if (typeof sessionStorage !== 'undefined') sessionStorage.clear()
75
+ if (typeof localStorage !== 'undefined') localStorage.clear()
76
+ vi.unstubAllGlobals()
77
+ })
78
+
79
+ it('reads and normalizes skopon_api_base_url from auth_user', () => {
80
+ sessionStorage.setItem(
81
+ DEFAULT_AUTH_STORAGE_KEY,
82
+ JSON.stringify({
83
+ [DEFAULT_AUTH_SKOPON_API_BASE_URL_FIELD]:
84
+ ' https://admin-test.hibot.lol/api/skopon/api/v1/ ',
85
+ }),
86
+ )
87
+ expect(resolveSkoponApiBaseUrl()).toBe(
88
+ 'https://admin-test.hibot.lol/api/skopon/api/v1',
89
+ )
90
+ })
91
+
92
+ it('falls back to the current origin when session or field is missing', () => {
93
+ expect(resolveSkoponApiBaseUrl()).toBe(`${window.location.origin}/api/v1`)
94
+ sessionStorage.setItem(DEFAULT_AUTH_STORAGE_KEY, JSON.stringify({ token: 'tok' }))
95
+ expect(resolveSkoponApiBaseUrl()).toBe(`${window.location.origin}/api/v1`)
96
+ })
97
+
98
+ it('falls back to a relative API path outside the browser', () => {
99
+ vi.stubGlobal('window', undefined)
100
+ expect(resolveSkoponApiBaseUrl()).toBe('/api/v1')
101
+ })
102
+
103
+ it('falls back when browser storage is inaccessible', () => {
104
+ const origin = window.location.origin
105
+ vi.stubGlobal('window', {
106
+ location: { origin },
107
+ sessionStorage: {
108
+ getItem() {
109
+ throw new Error('blocked')
110
+ },
111
+ },
112
+ })
113
+ expect(resolveSkoponApiBaseUrl()).toBe(`${origin}/api/v1`)
114
+ })
115
+
116
+ it.each([
117
+ '/api/v1',
118
+ 'ftp://example.com/api/v1',
119
+ 'https://user:pass@example.com/api/v1',
120
+ 'https://example.com/api/v1?tenant=1',
121
+ 'https://example.com/api/v1#upload',
122
+ ])('rejects invalid configured base URL: %s', (configured) => {
123
+ sessionStorage.setItem(
124
+ DEFAULT_AUTH_STORAGE_KEY,
125
+ JSON.stringify({
126
+ [DEFAULT_AUTH_SKOPON_API_BASE_URL_FIELD]: configured,
127
+ }),
128
+ )
129
+ expect(() => resolveSkoponApiBaseUrl()).toThrow(
130
+ 'skopon_api_base_url 格式无效,无法上传文件',
131
+ )
132
+ })
133
+ })
134
+
69
135
  describe('resolveSubmitAuthHeaders', () => {
70
136
  afterEach(() => {
71
137
  sessionStorage.clear()
@@ -11,8 +11,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
13
  export const DEFAULT_AUTH_REALTIME_WS_URL_FIELD = 'realtime_ws_url'
14
+ export const DEFAULT_AUTH_SKOPON_API_BASE_URL_FIELD = 'skopon_api_base_url'
14
15
  /** 与 skopon-admin 登录写入的 mock answer-agent 路径一致 */
15
16
  export const DEFAULT_ANSWER_AGENT_CALLBACK_URL = '/api/v1/mock/ask_user_answer_mock'
17
+ export const DEFAULT_SKOPON_API_BASE_PATH = '/api/v1'
16
18
 
17
19
  function readStorage(
18
20
  kind: 'sessionStorage' | 'localStorage',
@@ -35,6 +37,71 @@ function readSessionJson(raw: string): Record<string, unknown> | null {
35
37
  }
36
38
  }
37
39
 
40
+ function defaultSkoponApiBaseUrl(): string {
41
+ if (typeof window === 'undefined') return DEFAULT_SKOPON_API_BASE_PATH
42
+ try {
43
+ const origin = window.location.origin
44
+ const parsed = new URL(origin)
45
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
46
+ return DEFAULT_SKOPON_API_BASE_PATH
47
+ }
48
+ return `${parsed.origin}${DEFAULT_SKOPON_API_BASE_PATH}`
49
+ } catch {
50
+ return DEFAULT_SKOPON_API_BASE_PATH
51
+ }
52
+ }
53
+
54
+ /**
55
+ * 从宿主登录缓存读取 Skopon API 根地址。
56
+ * 缺失时回退当前 Origin 的 /api/v1;非空配置必须是无凭据、Query、Hash 的绝对 HTTP(S) URL。
57
+ */
58
+ export function resolveSkoponApiBaseUrl(
59
+ options: SkoponAuthStorageOptions & {
60
+ skoponApiBaseUrlField?: string
61
+ } = {},
62
+ ): string {
63
+ const storageKind = options.storage ?? 'sessionStorage'
64
+ const storageKey = options.storageKey ?? DEFAULT_AUTH_STORAGE_KEY
65
+ const field =
66
+ options.skoponApiBaseUrlField ?? DEFAULT_AUTH_SKOPON_API_BASE_URL_FIELD
67
+
68
+ const storage = readStorage(storageKind)
69
+ let raw: string | null = null
70
+ try {
71
+ raw = storage?.getItem(storageKey) ?? null
72
+ } catch {
73
+ return defaultSkoponApiBaseUrl()
74
+ }
75
+ const parsedSession = raw ? readSessionJson(raw) : null
76
+ const configured = parsedSession?.[field]
77
+ if (configured === undefined || configured === null) {
78
+ return defaultSkoponApiBaseUrl()
79
+ }
80
+ if (typeof configured !== 'string') {
81
+ throw new Error('skopon_api_base_url 格式无效,无法上传文件')
82
+ }
83
+
84
+ const trimmed = configured.trim()
85
+ if (!trimmed) return defaultSkoponApiBaseUrl()
86
+
87
+ try {
88
+ const parsed = new URL(trimmed)
89
+ if (
90
+ (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
91
+ parsed.username ||
92
+ parsed.password ||
93
+ parsed.search ||
94
+ parsed.hash
95
+ ) {
96
+ throw new Error('invalid Skopon API base URL')
97
+ }
98
+ const pathname = parsed.pathname.replace(/\/+$/, '')
99
+ return `${parsed.origin}${pathname}`
100
+ } catch {
101
+ throw new Error('skopon_api_base_url 格式无效,无法上传文件')
102
+ }
103
+ }
104
+
38
105
  function readTokenFromSessionJson(
39
106
  raw: string,
40
107
  tokenField: string,
@@ -0,0 +1,143 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import {
4
+ CDN_UPLOAD_PATH,
5
+ CDN_UPLOAD_TIMEOUT_MS,
6
+ UploadCancelledError,
7
+ uploadFileToCdn,
8
+ } from './uploadClient'
9
+
10
+ function response(body: unknown, init: Partial<Response> = {}): Response {
11
+ return {
12
+ ok: init.ok ?? true,
13
+ status: init.status ?? 200,
14
+ json: async () => body,
15
+ } as Response
16
+ }
17
+
18
+ describe('uploadFileToCdn', () => {
19
+ afterEach(() => {
20
+ vi.useRealTimers()
21
+ vi.unstubAllGlobals()
22
+ sessionStorage.clear()
23
+ })
24
+
25
+ it('uploads multipart data to the fixed endpoint with session Bearer auth', async () => {
26
+ sessionStorage.setItem(
27
+ 'auth_user',
28
+ JSON.stringify({
29
+ token: 'token-1',
30
+ skopon_api_base_url: 'https://admin-test.hibot.lol/api/skopon/api/v1/',
31
+ }),
32
+ )
33
+ const fetchMock = vi.fn(async () =>
34
+ response({
35
+ success: true,
36
+ data: { url: ' https://cdn.example.com/a.pdf ', file_name: 'a.pdf' },
37
+ }),
38
+ )
39
+ vi.stubGlobal('fetch', fetchMock)
40
+ const file = new File(['pdf'], 'a.pdf', { type: 'application/pdf' })
41
+
42
+ await expect(uploadFileToCdn(file)).resolves.toMatchObject({
43
+ url: 'https://cdn.example.com/a.pdf',
44
+ file_name: 'a.pdf',
45
+ })
46
+ expect(fetchMock).toHaveBeenCalledTimes(1)
47
+ const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
48
+ expect(url).toBe(
49
+ `https://admin-test.hibot.lol/api/skopon/api/v1${CDN_UPLOAD_PATH}`,
50
+ )
51
+ expect(init.method).toBe('POST')
52
+ expect(init.headers).toEqual({ Authorization: 'Bearer token-1' })
53
+ expect((init.headers as Record<string, string>)['Content-Type']).toBeUndefined()
54
+ expect(init.body).toBeInstanceOf(FormData)
55
+ expect((init.body as FormData).get('file')).toBe(file)
56
+ expect((init.body as FormData).get('purpose')).toBeNull()
57
+ })
58
+
59
+ it('falls back to the current origin API base when session config is missing', async () => {
60
+ const fetchMock = vi.fn(async () =>
61
+ response({ success: true, data: { url: 'https://cdn.example.com/a.txt' } }),
62
+ )
63
+ vi.stubGlobal('fetch', fetchMock)
64
+
65
+ await uploadFileToCdn(new File(['x'], 'x.txt'))
66
+
67
+ expect(fetchMock).toHaveBeenCalledWith(
68
+ `${window.location.origin}/api/v1${CDN_UPLOAD_PATH}`,
69
+ expect.objectContaining({ method: 'POST' }),
70
+ )
71
+ })
72
+
73
+ it('rejects an invalid configured base URL without sending a request', async () => {
74
+ sessionStorage.setItem(
75
+ 'auth_user',
76
+ JSON.stringify({
77
+ token: 'token-1',
78
+ skopon_api_base_url: '/api/v1',
79
+ }),
80
+ )
81
+ const fetchMock = vi.fn()
82
+ vi.stubGlobal('fetch', fetchMock)
83
+
84
+ await expect(uploadFileToCdn(new File(['x'], 'x.txt'))).rejects.toThrow(
85
+ 'skopon_api_base_url 格式无效,无法上传文件',
86
+ )
87
+ expect(fetchMock).not.toHaveBeenCalled()
88
+ })
89
+
90
+ it.each([
91
+ [{ success: false, message: '七牛上传失败', data: {} }, '七牛上传失败'],
92
+ [{ success: true, data: { url: ' ' } }, '响应缺少文件地址'],
93
+ ])('rejects invalid API responses', async (body, message) => {
94
+ vi.stubGlobal('fetch', vi.fn(async () => response(body)))
95
+ await expect(uploadFileToCdn(new File(['x'], 'x.txt'))).rejects.toThrow(message)
96
+ })
97
+
98
+ it('rejects non-JSON and non-success HTTP responses', async () => {
99
+ vi.stubGlobal(
100
+ 'fetch',
101
+ vi.fn(async () => ({
102
+ ok: false,
103
+ status: 502,
104
+ json: async () => {
105
+ throw new SyntaxError('bad json')
106
+ },
107
+ } as Response)),
108
+ )
109
+ await expect(uploadFileToCdn(new File(['x'], 'x.txt'))).rejects.toThrow('响应非 JSON (502)')
110
+ })
111
+
112
+ it('turns a caller abort into UploadCancelledError', async () => {
113
+ vi.stubGlobal(
114
+ 'fetch',
115
+ vi.fn((_url: string, init?: RequestInit) =>
116
+ new Promise<Response>((_resolve, reject) => {
117
+ init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')))
118
+ }),
119
+ ),
120
+ )
121
+ const controller = new AbortController()
122
+ const pending = uploadFileToCdn(new File(['x'], 'x.txt'), { signal: controller.signal })
123
+ const assertion = expect(pending).rejects.toBeInstanceOf(UploadCancelledError)
124
+ controller.abort()
125
+ await assertion
126
+ })
127
+
128
+ it('aborts and reports a timeout after 120 seconds', async () => {
129
+ vi.useFakeTimers()
130
+ vi.stubGlobal(
131
+ 'fetch',
132
+ vi.fn((_url: string, init?: RequestInit) =>
133
+ new Promise<Response>((_resolve, reject) => {
134
+ init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')))
135
+ }),
136
+ ),
137
+ )
138
+ const pending = uploadFileToCdn(new File(['x'], 'x.txt'))
139
+ const assertion = expect(pending).rejects.toThrow('上传超时,请稍后重试')
140
+ await vi.advanceTimersByTimeAsync(CDN_UPLOAD_TIMEOUT_MS)
141
+ await assertion
142
+ })
143
+ })
@@ -0,0 +1,72 @@
1
+ import { parseEnvelope } from './apiEnvelope'
2
+ import {
3
+ resolveSkoponApiBaseUrl,
4
+ resolveSkoponAuthHeaders,
5
+ } from './authStorage'
6
+
7
+ export const CDN_UPLOAD_PATH = '/univ/upload/cdn'
8
+ export const CDN_UPLOAD_TIMEOUT_MS = 120_000
9
+
10
+ export interface CdnUploadResult {
11
+ url: string
12
+ file_name?: string
13
+ mime_type?: string | null
14
+ size?: number
15
+ }
16
+
17
+ export class UploadCancelledError extends Error {
18
+ constructor(message = '上传已取消') {
19
+ super(message)
20
+ this.name = 'UploadCancelledError'
21
+ }
22
+ }
23
+
24
+ export function isUploadCancelledError(error: unknown): error is UploadCancelledError {
25
+ return error instanceof UploadCancelledError
26
+ }
27
+
28
+ /** SDK 固定 CDN 上传通道:使用宿主默认 session token 调用后端七牛代理。 */
29
+ export async function uploadFileToCdn(
30
+ file: File,
31
+ options: { signal?: AbortSignal } = {},
32
+ ): Promise<CdnUploadResult> {
33
+ const uploadUrl = `${resolveSkoponApiBaseUrl()}${CDN_UPLOAD_PATH}`
34
+ const controller = new AbortController()
35
+ let timedOut = false
36
+
37
+ const abortFromCaller = () => controller.abort()
38
+ if (options.signal?.aborted) {
39
+ controller.abort()
40
+ } else {
41
+ options.signal?.addEventListener('abort', abortFromCaller, { once: true })
42
+ }
43
+
44
+ const timer = setTimeout(() => {
45
+ timedOut = true
46
+ controller.abort()
47
+ }, CDN_UPLOAD_TIMEOUT_MS)
48
+
49
+ try {
50
+ const formData = new FormData()
51
+ formData.append('file', file)
52
+ const response = await fetch(uploadUrl, {
53
+ method: 'POST',
54
+ headers: resolveSkoponAuthHeaders(),
55
+ body: formData,
56
+ signal: controller.signal,
57
+ })
58
+ const result = await parseEnvelope<CdnUploadResult>(response)
59
+ const url = typeof result.url === 'string' ? result.url.trim() : ''
60
+ if (!url) throw new Error('上传成功但响应缺少文件地址')
61
+ return { ...result, url }
62
+ } catch (error) {
63
+ if (controller.signal.aborted) {
64
+ if (timedOut) throw new Error('上传超时,请稍后重试')
65
+ throw new UploadCancelledError()
66
+ }
67
+ throw error
68
+ } finally {
69
+ clearTimeout(timer)
70
+ options.signal?.removeEventListener('abort', abortFromCaller)
71
+ }
72
+ }
@@ -103,6 +103,7 @@ function AskUserFormCardBody({
103
103
  const [formDisabled, setFormDisabled] = useState(false)
104
104
  const [loading, setLoading] = useState(true)
105
105
  const [submitting, setSubmitting] = useState(false)
106
+ const [uploading, setUploading] = useState(false)
106
107
  const rendererRef = useRef<SkoponFormRendererRef>(null)
107
108
  const cardRootRef = useRef<HTMLDivElement>(null)
108
109
  const mountedRef = useRef(true)
@@ -322,6 +323,10 @@ function AskUserFormCardBody({
322
323
 
323
324
  const handleSubmit = useCallback(async () => {
324
325
  if (submitting) return
326
+ if (uploading) {
327
+ onNotifyRef.current?.('error', '文件正在上传,请稍候')
328
+ return
329
+ }
325
330
  setSubmitting(true)
326
331
  try {
327
332
  const cardValues = rendererRef.current?.getValues(fieldNames) ?? {}
@@ -397,6 +402,7 @@ function AskUserFormCardBody({
397
402
  }
398
403
  }, [
399
404
  submitting,
405
+ uploading,
400
406
  fieldNames,
401
407
  renderBlocks,
402
408
  surfaceTitle,
@@ -486,6 +492,7 @@ function AskUserFormCardBody({
486
492
  wrapProvider={false}
487
493
  resumeSearch={resumeSearch}
488
494
  caseSearch={caseSearch}
495
+ onUploadingChange={setUploading}
489
496
  />
490
497
 
491
498
  <div className="ask-user-form-actions">
@@ -500,7 +507,7 @@ function AskUserFormCardBody({
500
507
  type="primary"
501
508
  size="small"
502
509
  loading={submitting}
503
- disabled={submitting}
510
+ disabled={submitting || uploading}
504
511
  onClick={() => void handleSubmit()}
505
512
  >
506
513
  提交
@@ -0,0 +1,91 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ const uploadMock = vi.hoisted(() => vi.fn())
5
+
6
+ vi.mock('@a2ui/web_core/v0_9/basic_catalog', async (importOriginal) => {
7
+ const actual = await importOriginal<typeof import('@a2ui/web_core/v0_9/basic_catalog')>()
8
+ return { ...actual, injectBasicCatalogStyles: vi.fn() }
9
+ })
10
+
11
+ vi.mock('@a2ui/react/styles', () => ({
12
+ injectStyles: vi.fn(),
13
+ removeStyles: vi.fn(),
14
+ }))
15
+
16
+ vi.mock('../client/uploadClient', () => ({
17
+ uploadFileToCdn: uploadMock,
18
+ isUploadCancelledError: () => false,
19
+ }))
20
+
21
+ import { createElement } from 'react'
22
+ import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
23
+ import AskUserFormCard from './AskUserFormCard'
24
+ import { normalizeFormDefinition } from '../adapter/formSchema'
25
+
26
+ beforeAll(() => {
27
+ class ResizeObserverMock {
28
+ observe() {}
29
+ unobserve() {}
30
+ disconnect() {}
31
+ }
32
+ vi.stubGlobal('ResizeObserver', ResizeObserverMock)
33
+ })
34
+
35
+ beforeEach(() => {
36
+ uploadMock.mockReset()
37
+ sessionStorage.clear()
38
+ })
39
+ afterEach(cleanup)
40
+
41
+ describe('AskUserFormCard file upload guard', () => {
42
+ it('disables submit until every file upload settles', async () => {
43
+ let resolveUpload!: (value: { url: string }) => void
44
+ uploadMock.mockReturnValue(
45
+ new Promise<{ url: string }>((resolve) => {
46
+ resolveUpload = resolve
47
+ }),
48
+ )
49
+ const fileBlock = {
50
+ id: 'file',
51
+ type: 'file' as const,
52
+ name: 'attachments',
53
+ label: '附件',
54
+ fileMinCount: 0,
55
+ fileMaxCount: 1,
56
+ }
57
+ const formDefinition = normalizeFormDefinition({
58
+ title: '',
59
+ description: '',
60
+ blocks: [fileBlock],
61
+ jsonSchema: {},
62
+ })
63
+ const view = render(
64
+ createElement(AskUserFormCard, {
65
+ payload: { title: '', description: '', blocks: [fileBlock] },
66
+ formUniqueId: 'form-upload',
67
+ callbackUrl: '/callback',
68
+ submitMode: 'post',
69
+ fetchFormDetail: async () => ({
70
+ formUniqueId: 'form-upload',
71
+ disabled: false,
72
+ formDefinition,
73
+ }),
74
+ }),
75
+ )
76
+
77
+ const input = await waitFor(() => {
78
+ const node = view.container.querySelector('input[type="file"]') as HTMLInputElement
79
+ expect(node).toBeTruthy()
80
+ return node
81
+ })
82
+ const submit = view.getByRole('button', { name: /提\s*交/ }) as HTMLButtonElement
83
+ expect(submit.disabled).toBe(false)
84
+
85
+ fireEvent.change(input, { target: { files: [new File(['x'], 'a.pdf')] } })
86
+ await waitFor(() => expect(submit.disabled).toBe(true))
87
+
88
+ await act(async () => resolveUpload({ url: 'https://cdn.example.com/a.pdf' }))
89
+ await waitFor(() => expect(submit.disabled).toBe(false))
90
+ })
91
+ })
@@ -5,6 +5,7 @@ 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 { FileUploadStatusProvider } from '../catalog/fileUploadStatusContext'
8
9
  import { CaseSearchProvider, type CaseSearchFn } from '../catalog/caseSearchContext'
9
10
  import { ResumeSearchProvider, type ResumeSearchFn } from '../catalog/resumeSearchContext'
10
11
  import SkoponFormProvider from './SkoponFormProvider'
@@ -182,17 +183,19 @@ export default function SkoponA2uiStreamRenderer({
182
183
 
183
184
  return (
184
185
  <SkoponFormProvider>
185
- <ResumeSearchProvider resumeSearch={resumeSearch}>
186
- <CaseSearchProvider caseSearch={caseSearch}>
187
- <A2uiPreviewModeProvider interactive={interactive}>
188
- <div className="a2ui-surface a2ui-container">
189
- {surfaces.map((surface) => (
190
- <A2uiSurface key={surface.id} surface={surface} />
191
- ))}
192
- </div>
193
- </A2uiPreviewModeProvider>
194
- </CaseSearchProvider>
195
- </ResumeSearchProvider>
186
+ <FileUploadStatusProvider>
187
+ <ResumeSearchProvider resumeSearch={resumeSearch}>
188
+ <CaseSearchProvider caseSearch={caseSearch}>
189
+ <A2uiPreviewModeProvider interactive={interactive}>
190
+ <div className="a2ui-surface a2ui-container">
191
+ {surfaces.map((surface) => (
192
+ <A2uiSurface key={surface.id} surface={surface} />
193
+ ))}
194
+ </div>
195
+ </A2uiPreviewModeProvider>
196
+ </CaseSearchProvider>
197
+ </ResumeSearchProvider>
198
+ </FileUploadStatusProvider>
196
199
  </SkoponFormProvider>
197
200
  )
198
201
  }
@@ -15,6 +15,7 @@ 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 { FileUploadStatusProvider } from '../catalog/fileUploadStatusContext'
18
19
  import { CaseSearchProvider, type CaseSearchFn } from '../catalog/caseSearchContext'
19
20
  import { ResumeSearchProvider, type ResumeSearchFn } from '../catalog/resumeSearchContext'
20
21
  import SkoponFormProvider from './SkoponFormProvider'
@@ -35,6 +36,8 @@ export interface SkoponFormRendererProps {
35
36
  resumeSearch?: ResumeSearchFn | null
36
37
  /** 案例单选组件:注入搜索函数 */
37
38
  caseSearch?: CaseSearchFn | null
39
+ /** 任意文件上传任务的聚合状态 */
40
+ onUploadingChange?: (uploading: boolean) => void
38
41
  /** 外层已包裹 SkoponFormProvider 时可设为 false,避免重复 ConfigProvider */
39
42
  wrapProvider?: boolean
40
43
  }
@@ -49,6 +52,7 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
49
52
  fieldNames = [],
50
53
  resumeSearch = null,
51
54
  caseSearch = null,
55
+ onUploadingChange,
52
56
  wrapProvider = true,
53
57
  },
54
58
  ref,
@@ -122,27 +126,31 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
122
126
 
123
127
  if (surfaces.length === 0) {
124
128
  const empty = (
125
- <ResumeSearchProvider resumeSearch={resumeSearch}>
126
- <CaseSearchProvider caseSearch={caseSearch}>
127
- <A2uiPreviewModeProvider interactive={interactive}>{emptyHint}</A2uiPreviewModeProvider>
128
- </CaseSearchProvider>
129
- </ResumeSearchProvider>
129
+ <FileUploadStatusProvider onUploadingChange={onUploadingChange}>
130
+ <ResumeSearchProvider resumeSearch={resumeSearch}>
131
+ <CaseSearchProvider caseSearch={caseSearch}>
132
+ <A2uiPreviewModeProvider interactive={interactive}>{emptyHint}</A2uiPreviewModeProvider>
133
+ </CaseSearchProvider>
134
+ </ResumeSearchProvider>
135
+ </FileUploadStatusProvider>
130
136
  )
131
137
  return wrapProvider ? <SkoponFormProvider>{empty}</SkoponFormProvider> : empty
132
138
  }
133
139
 
134
140
  const content = (
135
- <ResumeSearchProvider resumeSearch={resumeSearch}>
136
- <CaseSearchProvider caseSearch={caseSearch}>
137
- <A2uiPreviewModeProvider interactive={interactive}>
138
- <div className="a2ui-surface a2ui-container">
139
- {surfaces.map((surface) => (
140
- <A2uiSurface key={surface.id} surface={surface} />
141
- ))}
142
- </div>
143
- </A2uiPreviewModeProvider>
144
- </CaseSearchProvider>
145
- </ResumeSearchProvider>
141
+ <FileUploadStatusProvider onUploadingChange={onUploadingChange}>
142
+ <ResumeSearchProvider resumeSearch={resumeSearch}>
143
+ <CaseSearchProvider caseSearch={caseSearch}>
144
+ <A2uiPreviewModeProvider interactive={interactive}>
145
+ <div className="a2ui-surface a2ui-container">
146
+ {surfaces.map((surface) => (
147
+ <A2uiSurface key={surface.id} surface={surface} />
148
+ ))}
149
+ </div>
150
+ </A2uiPreviewModeProvider>
151
+ </CaseSearchProvider>
152
+ </ResumeSearchProvider>
153
+ </FileUploadStatusProvider>
146
154
  )
147
155
 
148
156
  return wrapProvider ? <SkoponFormProvider>{content}</SkoponFormProvider> : content
package/src/index.ts CHANGED
@@ -106,6 +106,7 @@ export {
106
106
  export {
107
107
  resolveAnswerAgentCallbackUrl,
108
108
  resolveRealtimeWsUrl,
109
+ resolveSkoponApiBaseUrl,
109
110
  resolveSkoponAuthHeaders,
110
111
  resolveSubmitAuthHeaders,
111
112
  resolveClientHeaders,
@@ -113,7 +114,9 @@ export {
113
114
  DEFAULT_AUTH_TOKEN_FIELD,
114
115
  DEFAULT_AUTH_ANSWER_AGENT_CALLBACK_URL_FIELD,
115
116
  DEFAULT_AUTH_REALTIME_WS_URL_FIELD,
117
+ DEFAULT_AUTH_SKOPON_API_BASE_URL_FIELD,
116
118
  DEFAULT_ANSWER_AGENT_CALLBACK_URL,
119
+ DEFAULT_SKOPON_API_BASE_PATH,
117
120
  type SkoponAuthStorageOptions,
118
121
  type ClientHeadersOptions,
119
122
  } from './client/authStorage'