@skopon-cool/form-sdk 0.1.9 → 0.2.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 (43) hide show
  1. package/README.md +24 -6
  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/skoponCaseSelect.d.ts.map +1 -1
  6. package/dist/catalog/skoponResumeSelect.d.ts.map +1 -1
  7. package/dist/catalog/skoponSelectLoading.test.d.ts +2 -0
  8. package/dist/catalog/skoponSelectLoading.test.d.ts.map +1 -0
  9. package/dist/catalog/useSkoponBoundField.d.ts +2 -0
  10. package/dist/catalog/useSkoponBoundField.d.ts.map +1 -1
  11. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  12. package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -1
  13. package/dist/components/SkoponFormProvider.d.ts +11 -0
  14. package/dist/components/SkoponFormProvider.d.ts.map +1 -0
  15. package/dist/components/SkoponFormRenderer.d.ts +2 -0
  16. package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
  17. package/dist/form-sdk.css +1 -1
  18. package/dist/index.d.ts +4 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +981 -857
  21. package/dist/theme/antd-theme.d.ts +7 -0
  22. package/dist/theme/antd-theme.d.ts.map +1 -0
  23. package/dist/theme/brand-colors.d.ts +19 -0
  24. package/dist/theme/brand-colors.d.ts.map +1 -0
  25. package/package.json +2 -1
  26. package/src/adapter/a2uiAdapter.ts +2 -3
  27. package/src/adapter/formSchema.test.ts +5 -1
  28. package/src/adapter/formSchema.ts +3 -1
  29. package/src/catalog/a2uiCustomCatalog.tsx +15 -1
  30. package/src/catalog/skoponCaseSelect.tsx +2 -1
  31. package/src/catalog/skoponResumeSelect.tsx +1 -0
  32. package/src/catalog/skoponSelectLoading.test.tsx +182 -0
  33. package/src/catalog/useSkoponBoundField.test.ts +14 -1
  34. package/src/catalog/useSkoponBoundField.ts +14 -0
  35. package/src/components/AskUserFormCard.tsx +20 -12
  36. package/src/components/SkoponA2uiStreamRenderer.tsx +5 -2
  37. package/src/components/SkoponFormProvider.tsx +23 -0
  38. package/src/components/SkoponFormRenderer.tsx +17 -2
  39. package/src/index.ts +6 -0
  40. package/src/styles/index.css +4 -3
  41. package/src/styles/tokens.css +224 -0
  42. package/src/theme/antd-theme.ts +93 -0
  43. package/src/theme/brand-colors.ts +18 -0
@@ -0,0 +1,7 @@
1
+ import type { ThemeConfig } from 'antd';
2
+ /**
3
+ * Ant Design 主题映射(与 design/ui/UI规范.md §2.4、§2.5 一致)
4
+ * 品牌主色 hex 仅来自 ./brand-colors.ts
5
+ */
6
+ export declare const antdTheme: ThemeConfig;
7
+ //# sourceMappingURL=antd-theme.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"antd-theme.d.ts","sourceRoot":"","sources":["../../src/theme/antd-theme.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,MAAM,CAAA;AAGvC;;;GAGG;AACH,eAAO,MAAM,SAAS,EAAE,WAqFvB,CAAA"}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * 品牌墨绿(Jade)唯一 hex 源
3
+ * CSS 侧通过 Radix --jade-* / 语义变量 --color-* 引用;Ant Design 从此文件同步
4
+ * 规范:front/design/ui/UI规范.md §2.2、§2.5
5
+ */
6
+ export declare const brandColors: {
7
+ readonly jade9: "#29a383";
8
+ readonly jade10: "#26997b";
9
+ readonly jade11: "#208368";
10
+ readonly jade12: "#1d3b31";
11
+ /** 链接、边框、Logo 图形、实心底(与 primarySolid 同色) */
12
+ readonly primary: "#208368";
13
+ /** Primary 实心按钮、Sider 胶囊、发布等 type="primary" */
14
+ readonly primarySolid: "#208368";
15
+ readonly primaryHover: "#26997b";
16
+ readonly primaryActive: "#1d3b31";
17
+ readonly onPrimary: "#ffffff";
18
+ };
19
+ //# sourceMappingURL=brand-colors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"brand-colors.d.ts","sourceRoot":"","sources":["../../src/theme/brand-colors.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,eAAO,MAAM,WAAW;;;;;IAKtB,2CAA2C;;IAE3C,+CAA+C;;;;;CAKvC,CAAA"}
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.1.9",
6
+ "version": "0.2.1",
7
7
  "description": "Skopon form rendering SDK (A2UI + form_definition) with submit helpers",
8
8
  "type": "module",
9
9
  "sideEffects": [
@@ -31,6 +31,7 @@
31
31
  "prepublishOnly": "npm run build && npm test"
32
32
  },
33
33
  "dependencies": {
34
+ "@radix-ui/colors": "^3.0.0",
34
35
  "dayjs": "^1.11.13",
35
36
  "zod": "^3.24.0"
36
37
  },
@@ -8,9 +8,8 @@ import type {
8
8
  FormSchema,
9
9
  } from '../types/index'
10
10
  import { generateId } from './id'
11
- import { coerceToggleValue } from './formSchema'
11
+ import { coerceToggleValue, syncFormDefinition } from './formSchema'
12
12
  import { getMediaUrls, normalizeMediaSize } from './formMedia'
13
- import { syncFormDefinition } from './formSchema'
14
13
  import { getBlockTypeAdapter, getComponentAdapter } from '../blocks/registry'
15
14
 
16
15
  /**
@@ -491,7 +490,7 @@ function applyDataModelDefaults(
491
490
  const raw = dataModel[name]
492
491
  if (raw === undefined) return block
493
492
  if (block.type === 'toggle') {
494
- return { ...block, defaultValue: raw === true || raw === 'true' }
493
+ return { ...block, defaultValue: coerceToggleValue(raw) }
495
494
  }
496
495
  return { ...block, defaultValue: raw as FormBlock['defaultValue'] }
497
496
  })
@@ -5,11 +5,15 @@ import { describe, it, expect } from 'vitest'
5
5
  import { coerceToggleValue, normalizeFormDefinition } from './formSchema'
6
6
 
7
7
  describe('coerceToggleValue', () => {
8
- it('accepts boolean and string true', () => {
8
+ it('accepts boolean, string, and numeric truthy/falsy values', () => {
9
9
  expect(coerceToggleValue(true)).toBe(true)
10
10
  expect(coerceToggleValue('true')).toBe(true)
11
+ expect(coerceToggleValue(1)).toBe(true)
12
+ expect(coerceToggleValue('1')).toBe(true)
11
13
  expect(coerceToggleValue(false)).toBe(false)
12
14
  expect(coerceToggleValue('false')).toBe(false)
15
+ expect(coerceToggleValue(0)).toBe(false)
16
+ expect(coerceToggleValue('0')).toBe(false)
13
17
  expect(coerceToggleValue(undefined)).toBe(false)
14
18
  })
15
19
  })
@@ -14,7 +14,9 @@ import {
14
14
  const DEFAULT_MEDIA_URL_MAX = 10
15
15
 
16
16
  export function coerceToggleValue(raw: unknown): boolean {
17
- return raw === true || raw === 'true'
17
+ if (raw === true || raw === 'true' || raw === 1 || raw === '1') return true
18
+ if (raw === false || raw === 'false' || raw === 0 || raw === '0') return false
19
+ return false
18
20
  }
19
21
 
20
22
  function normalizeFileUploadCount(raw: unknown, fallback = 1): number {
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod'
2
- import type { ReactNode } from 'react'
2
+ import { useEffect, type ReactNode } from 'react'
3
3
  import { Catalog, type ComponentContext } from '@a2ui/web_core/v0_9'
4
4
  import {
5
5
  basicCatalog,
@@ -35,6 +35,7 @@ import { useA2uiPreviewMode } from './a2uiPreviewContext'
35
35
  import {
36
36
  asOptionalString,
37
37
  asStringArray,
38
+ normalizeSelectStoredValue,
38
39
  readBoundFieldValue,
39
40
  useSkoponBoundField,
40
41
  } from './useSkoponBoundField'
@@ -247,6 +248,11 @@ function SkoponSelectPreview({ context }: { context: ComponentContext }) {
247
248
  const options = readOptions(props.options)
248
249
  const isMultiple = readString(props.mode) === 'multiple'
249
250
 
251
+ useEffect(() => {
252
+ const normalized = normalizeSelectStoredValue(value, isMultiple)
253
+ if (normalized !== null) setValue(normalized)
254
+ }, [isMultiple, value, setValue])
255
+
250
256
  return (
251
257
  <div className="form-block-preview">
252
258
  {label ? <div className="form-block-preview-label">{label}</div> : null}
@@ -289,6 +295,13 @@ function ChoicePickerPreview({ context }: { context: ComponentContext }) {
289
295
  : isMultiple
290
296
  ? 'checkbox'
291
297
  : 'radio'
298
+ const isMultiplePicker =
299
+ effectiveType === 'multiselect' || effectiveType === 'checkbox'
300
+
301
+ useEffect(() => {
302
+ const normalized = normalizeSelectStoredValue(value, isMultiplePicker)
303
+ if (normalized !== null) setValue(normalized)
304
+ }, [isMultiplePicker, value, setValue])
292
305
 
293
306
  return (
294
307
  <div className="form-block-preview">
@@ -557,6 +570,7 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
557
570
  允许:{acceptSummary ?? '全部类型'}
558
571
  {minCount > 0 ? `;至少 ${minCount} 个文件` : ''}
559
572
  {hasMaxLimit ? `;最多 ${maxCount} 个文件` : ''}
573
+ ;当前仅收集文件名(不含文件内容,需宿主另行上传)
560
574
  {belowMinCount && interactive ? `(当前 ${selectedNames.length} 个,未达下限)` : ''}
561
575
  </Typography.Text>
562
576
  </div>
@@ -90,7 +90,7 @@ function CaseCard({
90
90
  onClick={onToggle}
91
91
  >
92
92
  <div className="skopon-resume-select-card-avatar">
93
- <FileTextOutlined style={{ fontSize: 24, color: 'var(--color-primary, #1677ff)' }} />
93
+ <FileTextOutlined style={{ fontSize: 24, color: 'var(--color-primary, #208368)' }} />
94
94
  </div>
95
95
  <div className="skopon-resume-select-card-body">
96
96
  <div className="skopon-resume-select-card-name">{item.name}</div>
@@ -152,6 +152,7 @@ function SkoponCaseSelectPreview({ context }: { context: ComponentContext }) {
152
152
  setItems([])
153
153
  setTotal(0)
154
154
  setFetchError('')
155
+ setLoading(false)
155
156
  return
156
157
  }
157
158
  setLoading(true)
@@ -198,6 +198,7 @@ function SkoponResumeSelectPreview({ context }: { context: ComponentContext }) {
198
198
  setItems([])
199
199
  setTotal(0)
200
200
  setError('未配置简历搜索服务')
201
+ setLoading(false)
201
202
  return
202
203
  }
203
204
  setLoading(true)
@@ -0,0 +1,182 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'
3
+
4
+ vi.mock('@a2ui/web_core/v0_9/basic_catalog', async (importOriginal) => {
5
+ const actual = await importOriginal<typeof import('@a2ui/web_core/v0_9/basic_catalog')>()
6
+ return { ...actual, injectBasicCatalogStyles: vi.fn() }
7
+ })
8
+
9
+ vi.mock('@a2ui/react/styles', () => ({
10
+ injectStyles: vi.fn(),
11
+ removeStyles: vi.fn(),
12
+ }))
13
+
14
+ import { createElement } from 'react'
15
+ import { render, cleanup, waitFor } from '@testing-library/react'
16
+ import { blocksToA2ui } from '../adapter/a2uiAdapter'
17
+ import SkoponFormRenderer from '../components/SkoponFormRenderer'
18
+ import type { CaseSearchFn } from './caseSearchContext'
19
+ import type { ResumeSearchFn } from './resumeSearchContext'
20
+
21
+ beforeAll(() => {
22
+ class ResizeObserverMock {
23
+ observe() {}
24
+ unobserve() {}
25
+ disconnect() {}
26
+ }
27
+ vi.stubGlobal('ResizeObserver', ResizeObserverMock)
28
+ })
29
+
30
+ function buildCaseSurface(flowUniqueIds: string[]) {
31
+ return blocksToA2ui(
32
+ {
33
+ title: '',
34
+ description: '',
35
+ blocks: [
36
+ {
37
+ id: 'b-case',
38
+ type: 'case_multiselect',
39
+ name: 'cases',
40
+ label: '案例',
41
+ caseFilter: { flowUniqueIds, pageSize: 20 },
42
+ },
43
+ ],
44
+ },
45
+ { includeHeader: false },
46
+ )
47
+ }
48
+
49
+ function buildResumeSurface() {
50
+ return blocksToA2ui(
51
+ {
52
+ title: '',
53
+ description: '',
54
+ blocks: [
55
+ {
56
+ id: 'b-resume',
57
+ type: 'resume_multiselect',
58
+ name: 'resumes',
59
+ label: '简历',
60
+ resumeFilter: { names: [], agentUniqueIds: [], resumeUniqueIds: [], pageSize: 20 },
61
+ },
62
+ ],
63
+ },
64
+ { includeHeader: false },
65
+ )
66
+ }
67
+
68
+ describe('case/resume select loading state', () => {
69
+ afterEach(() => {
70
+ cleanup()
71
+ })
72
+
73
+ it('clears case loading when caseSearch is removed while fetch is in flight', async () => {
74
+ let resolveSearch!: (value: Awaited<ReturnType<CaseSearchFn>>) => void
75
+ const caseSearch = vi.fn(
76
+ () =>
77
+ new Promise<Awaited<ReturnType<CaseSearchFn>>>((resolve) => {
78
+ resolveSearch = resolve
79
+ }),
80
+ )
81
+
82
+ const doc = buildCaseSurface(['flow-1'])
83
+ const { rerender, queryByText } = render(
84
+ createElement(SkoponFormRenderer, {
85
+ doc,
86
+ surfaceId: 'case-loading-test',
87
+ caseSearch,
88
+ }),
89
+ )
90
+
91
+ expect(queryByText('加载案例…')).toBeTruthy()
92
+
93
+ rerender(
94
+ createElement(SkoponFormRenderer, {
95
+ doc,
96
+ surfaceId: 'case-loading-test',
97
+ caseSearch: null,
98
+ }),
99
+ )
100
+
101
+ await waitFor(() => {
102
+ expect(queryByText('加载案例…')).toBeNull()
103
+ })
104
+ expect(queryByText('未配置案例搜索服务')).toBeTruthy()
105
+
106
+ resolveSearch({ list: [], total: 0, page: 1, pageSize: 20 })
107
+ })
108
+
109
+ it('clears case loading when filter becomes invalid while fetch is in flight', async () => {
110
+ let resolveSearch!: (value: Awaited<ReturnType<CaseSearchFn>>) => void
111
+ const caseSearch = vi.fn(
112
+ () =>
113
+ new Promise<Awaited<ReturnType<CaseSearchFn>>>((resolve) => {
114
+ resolveSearch = resolve
115
+ }),
116
+ )
117
+
118
+ const validDoc = buildCaseSurface(['flow-1'])
119
+ const invalidDoc = buildCaseSurface([])
120
+
121
+ const { rerender, queryByText } = render(
122
+ createElement(SkoponFormRenderer, {
123
+ doc: validDoc,
124
+ surfaceId: 'case-filter-test',
125
+ caseSearch,
126
+ }),
127
+ )
128
+
129
+ expect(queryByText('加载案例…')).toBeTruthy()
130
+
131
+ rerender(
132
+ createElement(SkoponFormRenderer, {
133
+ doc: invalidDoc,
134
+ surfaceId: 'case-filter-test',
135
+ caseSearch,
136
+ }),
137
+ )
138
+
139
+ await waitFor(() => {
140
+ expect(queryByText('加载案例…')).toBeNull()
141
+ })
142
+ expect(queryByText('未配置案例列表筛选条件')).toBeTruthy()
143
+
144
+ resolveSearch({ list: [], total: 0, page: 1, pageSize: 20 })
145
+ })
146
+
147
+ it('clears resume loading when resumeSearch is removed while fetch is in flight', async () => {
148
+ let resolveSearch!: (value: Awaited<ReturnType<ResumeSearchFn>>) => void
149
+ const resumeSearch = vi.fn(
150
+ () =>
151
+ new Promise<Awaited<ReturnType<ResumeSearchFn>>>((resolve) => {
152
+ resolveSearch = resolve
153
+ }),
154
+ )
155
+
156
+ const doc = buildResumeSurface()
157
+ const { rerender, queryByText } = render(
158
+ createElement(SkoponFormRenderer, {
159
+ doc,
160
+ surfaceId: 'resume-loading-test',
161
+ resumeSearch,
162
+ }),
163
+ )
164
+
165
+ expect(queryByText('加载简历…')).toBeTruthy()
166
+
167
+ rerender(
168
+ createElement(SkoponFormRenderer, {
169
+ doc,
170
+ surfaceId: 'resume-loading-test',
171
+ resumeSearch: null,
172
+ }),
173
+ )
174
+
175
+ await waitFor(() => {
176
+ expect(queryByText('加载简历…')).toBeNull()
177
+ })
178
+ expect(queryByText('未配置简历搜索服务')).toBeTruthy()
179
+
180
+ resolveSearch({ list: [], total: 0, page: 1, pageSize: 20 })
181
+ })
182
+ })
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { describe, it, expect } from 'vitest'
5
5
  import type { ComponentContext } from '@a2ui/web_core/v0_9'
6
- import { asStringArray, clampStringArraySelection, readBoundFieldValue, toggleStringArraySelection } from './useSkoponBoundField'
6
+ import { asStringArray, clampStringArraySelection, normalizeSelectStoredValue, readBoundFieldValue, toggleStringArraySelection } from './useSkoponBoundField'
7
7
 
8
8
  function createMockContext(initial: unknown = []): {
9
9
  context: ComponentContext
@@ -29,6 +29,19 @@ function createMockContext(initial: unknown = []): {
29
29
  }
30
30
  }
31
31
 
32
+ describe('normalizeSelectStoredValue', () => {
33
+ it('coerces string to array in multiple mode', () => {
34
+ expect(normalizeSelectStoredValue('a', true)).toEqual(['a'])
35
+ expect(normalizeSelectStoredValue(['a', 'b'], true)).toBeNull()
36
+ })
37
+
38
+ it('coerces array to string in single mode', () => {
39
+ expect(normalizeSelectStoredValue(['a', 'b'], false)).toBe('a')
40
+ expect(normalizeSelectStoredValue([], false)).toBe('')
41
+ expect(normalizeSelectStoredValue('a', false)).toBeNull()
42
+ })
43
+ })
44
+
32
45
  describe('readBoundFieldValue', () => {
33
46
  it('reads current bound value from dataContext', () => {
34
47
  const { context } = createMockContext(['a.pdf'])
@@ -65,6 +65,20 @@ export function asStringArray(value: unknown): string[] {
65
65
  return []
66
66
  }
67
67
 
68
+ /** 单选/多选模式切换时规范化 stored value;无需变更时返回 null。 */
69
+ export function normalizeSelectStoredValue(value: unknown, isMultiple: boolean): unknown | null {
70
+ if (isMultiple) {
71
+ if (Array.isArray(value)) return null
72
+ return asStringArray(value)
73
+ }
74
+ if (Array.isArray(value)) {
75
+ return value.length > 0 ? String(value[0]) : ''
76
+ }
77
+ if (value === undefined || value === null) return null
78
+ if (typeof value === 'string' || typeof value === 'number') return null
79
+ return ''
80
+ }
81
+
68
82
  export function readOptionalSelectCount(value: unknown): number | undefined {
69
83
  if (value === undefined || value === null) return undefined
70
84
  const num = typeof value === 'number' ? value : Number(value)
@@ -16,6 +16,7 @@ import type { CaseSearchFn } from '../catalog/caseSearchContext'
16
16
  import type { ResumeSearchFn } from '../catalog/resumeSearchContext'
17
17
  import SkoponFormRenderer, { type SkoponFormRendererRef } from './SkoponFormRenderer'
18
18
  import CurlSubmitBlock from './CurlSubmitBlock'
19
+ import SkoponFormProvider from './SkoponFormProvider'
19
20
 
20
21
  function formatSubmitErrorMessage(status: number, body: unknown): string {
21
22
  if (body && typeof body === 'object' && body !== null && 'message' in body) {
@@ -154,21 +155,25 @@ export default function AskUserFormCard({
154
155
 
155
156
  if (loading) {
156
157
  return (
157
- <div className="ask-user-form-card">
158
- <Spin size="small" />
159
- </div>
158
+ <SkoponFormProvider>
159
+ <div className="ask-user-form-card">
160
+ <Spin size="small" />
161
+ </div>
162
+ </SkoponFormProvider>
160
163
  )
161
164
  }
162
165
 
163
166
  if (!payloadDef || renderBlocks.length === 0) {
164
167
  return (
165
- <CurlSubmitBlock
166
- payload={payload}
167
- callbackUrl={callbackUrl}
168
- copyMode="submitFields"
169
- unpublishedFormId={formDisabled && resolvedFormUniqueId ? resolvedFormUniqueId : undefined}
170
- onNotify={onNotify}
171
- />
168
+ <SkoponFormProvider>
169
+ <CurlSubmitBlock
170
+ payload={payload}
171
+ callbackUrl={callbackUrl}
172
+ copyMode="submitFields"
173
+ unpublishedFormId={formDisabled && resolvedFormUniqueId ? resolvedFormUniqueId : undefined}
174
+ onNotify={onNotify}
175
+ />
176
+ </SkoponFormProvider>
172
177
  )
173
178
  }
174
179
 
@@ -210,7 +215,8 @@ export default function AskUserFormCard({
210
215
  }
211
216
 
212
217
  return (
213
- <div className="ask-user-form-card">
218
+ <SkoponFormProvider>
219
+ <div className="ask-user-form-card">
214
220
  {formDisabled && resolvedFormUniqueId ? (
215
221
  <div className="ask-user-form-card-status">
216
222
  <Tooltip title={resolvedFormUniqueId}>
@@ -224,6 +230,7 @@ export default function AskUserFormCard({
224
230
  surfaceId={surfaceId}
225
231
  fieldNames={fieldNames}
226
232
  interactive
233
+ wrapProvider={false}
227
234
  resumeSearch={resumeSearch}
228
235
  caseSearch={caseSearch}
229
236
  />
@@ -241,6 +248,7 @@ export default function AskUserFormCard({
241
248
  </Button>
242
249
  </Tooltip>
243
250
  </div>
244
- </div>
251
+ </div>
252
+ </SkoponFormProvider>
245
253
  )
246
254
  }
@@ -7,6 +7,7 @@ import { buildSkoponCatalog } from '../catalog/a2uiCustomCatalog'
7
7
  import { A2uiPreviewModeProvider } from '../catalog/a2uiPreviewContext'
8
8
  import { CaseSearchProvider, type CaseSearchFn } from '../catalog/caseSearchContext'
9
9
  import { ResumeSearchProvider, type ResumeSearchFn } from '../catalog/resumeSearchContext'
10
+ import SkoponFormProvider from './SkoponFormProvider'
10
11
 
11
12
  export interface SkoponA2uiStreamRendererProps {
12
13
  /** 增量追加的 A2UI v0.9 消息 */
@@ -177,10 +178,11 @@ export default function SkoponA2uiStreamRenderer({
177
178
  }
178
179
  }, [messages, surfaceId])
179
180
 
180
- if (surfaces.length === 0) return <>{emptyHint}</>
181
+ if (surfaces.length === 0) return <SkoponFormProvider>{emptyHint}</SkoponFormProvider>
181
182
 
182
183
  return (
183
- <ResumeSearchProvider resumeSearch={resumeSearch}>
184
+ <SkoponFormProvider>
185
+ <ResumeSearchProvider resumeSearch={resumeSearch}>
184
186
  <CaseSearchProvider caseSearch={caseSearch}>
185
187
  <A2uiPreviewModeProvider interactive={interactive}>
186
188
  <div className="a2ui-surface a2ui-container">
@@ -191,5 +193,6 @@ export default function SkoponA2uiStreamRenderer({
191
193
  </A2uiPreviewModeProvider>
192
194
  </CaseSearchProvider>
193
195
  </ResumeSearchProvider>
196
+ </SkoponFormProvider>
194
197
  )
195
198
  }
@@ -0,0 +1,23 @@
1
+ import { ConfigProvider, type ThemeConfig } from 'antd'
2
+ import type { Locale } from 'antd/es/locale'
3
+ import type { ReactNode } from 'react'
4
+ import { antdTheme as skoponAntdTheme } from '../theme/antd-theme'
5
+
6
+ export interface SkoponFormProviderProps {
7
+ children: ReactNode
8
+ locale?: Locale
9
+ /** 默认 Skopon 墨绿主题;传入可覆盖 */
10
+ theme?: ThemeConfig
11
+ }
12
+
13
+ export default function SkoponFormProvider({
14
+ children,
15
+ locale,
16
+ theme,
17
+ }: SkoponFormProviderProps) {
18
+ return (
19
+ <ConfigProvider theme={theme ?? skoponAntdTheme} locale={locale}>
20
+ {children}
21
+ </ConfigProvider>
22
+ )
23
+ }
@@ -17,6 +17,7 @@ import { SKOPON_CATALOG_ID, buildSkoponCatalog } from '../catalog/a2uiCustomCata
17
17
  import { A2uiPreviewModeProvider } from '../catalog/a2uiPreviewContext'
18
18
  import { CaseSearchProvider, type CaseSearchFn } from '../catalog/caseSearchContext'
19
19
  import { ResumeSearchProvider, type ResumeSearchFn } from '../catalog/resumeSearchContext'
20
+ import SkoponFormProvider from './SkoponFormProvider'
20
21
 
21
22
  export interface SkoponFormRendererRef {
22
23
  getValues: (fieldNames?: string[]) => Record<string, unknown>
@@ -33,6 +34,8 @@ export interface SkoponFormRendererProps {
33
34
  resumeSearch?: ResumeSearchFn | null
34
35
  /** 案例单选组件:注入搜索函数 */
35
36
  caseSearch?: CaseSearchFn | null
37
+ /** 外层已包裹 SkoponFormProvider 时可设为 false,避免重复 ConfigProvider */
38
+ wrapProvider?: boolean
36
39
  }
37
40
 
38
41
  const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererProps>(
@@ -45,6 +48,7 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
45
48
  fieldNames = [],
46
49
  resumeSearch = null,
47
50
  caseSearch = null,
51
+ wrapProvider = true,
48
52
  },
49
53
  ref,
50
54
  ) {
@@ -104,9 +108,18 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
104
108
  [surfaceId],
105
109
  )
106
110
 
107
- if (surfaces.length === 0) return <>{emptyHint}</>
111
+ if (surfaces.length === 0) {
112
+ const empty = (
113
+ <ResumeSearchProvider resumeSearch={resumeSearch}>
114
+ <CaseSearchProvider caseSearch={caseSearch}>
115
+ <A2uiPreviewModeProvider interactive={interactive}>{emptyHint}</A2uiPreviewModeProvider>
116
+ </CaseSearchProvider>
117
+ </ResumeSearchProvider>
118
+ )
119
+ return wrapProvider ? <SkoponFormProvider>{empty}</SkoponFormProvider> : empty
120
+ }
108
121
 
109
- return (
122
+ const content = (
110
123
  <ResumeSearchProvider resumeSearch={resumeSearch}>
111
124
  <CaseSearchProvider caseSearch={caseSearch}>
112
125
  <A2uiPreviewModeProvider interactive={interactive}>
@@ -119,6 +132,8 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
119
132
  </CaseSearchProvider>
120
133
  </ResumeSearchProvider>
121
134
  )
135
+
136
+ return wrapProvider ? <SkoponFormProvider>{content}</SkoponFormProvider> : content
122
137
  },
123
138
  )
124
139
 
package/src/index.ts CHANGED
@@ -80,6 +80,12 @@ export type { AskUserFormCardProps } from './components/AskUserFormCard'
80
80
  export { default as CurlSubmitBlock } from './components/CurlSubmitBlock'
81
81
  export type { CurlSubmitBlockProps } from './components/CurlSubmitBlock'
82
82
 
83
+ export { default as SkoponFormProvider } from './components/SkoponFormProvider'
84
+ export type { SkoponFormProviderProps } from './components/SkoponFormProvider'
85
+
86
+ export { antdTheme as skoponAntdTheme } from './theme/antd-theme'
87
+ export { brandColors as skoponBrandColors } from './theme/brand-colors'
88
+
83
89
 
84
90
  export {
85
91
  ResumeSearchProvider,
@@ -1,3 +1,4 @@
1
+ @import './tokens.css';
1
2
  @import './a2ui-preview.css';
2
3
 
3
4
  /* form-block-preview(与 admin form-tally 预览对齐的子集) */
@@ -250,11 +251,11 @@
250
251
  min-width: 220px;
251
252
  }
252
253
  .skopon-resume-select-card:hover:not(:disabled) {
253
- border-color: var(--color-primary, #1677ff);
254
+ border-color: var(--color-primary, #208368);
254
255
  }
255
256
  .skopon-resume-select-card--selected {
256
- border-color: var(--color-primary, #1677ff);
257
- background: color-mix(in srgb, var(--color-primary, #1677ff) 8%, transparent);
257
+ border-color: var(--color-primary, #208368);
258
+ background: color-mix(in srgb, var(--color-primary, #208368) 8%, transparent);
258
259
  }
259
260
  .skopon-resume-select-card:disabled {
260
261
  cursor: not-allowed;