@skopon-cool/form-sdk 0.6.1 → 0.6.2

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 (48) hide show
  1. package/README.md +16 -0
  2. package/dist/adapter/formSchema.d.ts +3 -0
  3. package/dist/adapter/formSchema.d.ts.map +1 -1
  4. package/dist/blocks/resume_multiselect/adapter.d.ts.map +1 -1
  5. package/dist/catalog/contentSearchContext.d.ts +35 -0
  6. package/dist/catalog/contentSearchContext.d.ts.map +1 -0
  7. package/dist/catalog/pairResumeContent.d.ts +20 -0
  8. package/dist/catalog/pairResumeContent.d.ts.map +1 -0
  9. package/dist/catalog/skoponResumeSelect.d.ts.map +1 -1
  10. package/dist/client/contentSearchClient.d.ts +36 -0
  11. package/dist/client/contentSearchClient.d.ts.map +1 -0
  12. package/dist/client/resumeSearchClient.d.ts.map +1 -1
  13. package/dist/client/skoponSdkClients.d.ts +4 -0
  14. package/dist/client/skoponSdkClients.d.ts.map +1 -1
  15. package/dist/components/AskUserFormCard.d.ts +3 -0
  16. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  17. package/dist/components/SkoponA2uiStreamRenderer.d.ts +4 -1
  18. package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -1
  19. package/dist/components/SkoponFormRenderer.d.ts +3 -0
  20. package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
  21. package/dist/form-sdk.css +1 -1
  22. package/dist/index.d.ts +4 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1858 -1557
  25. package/dist/types/index.d.ts +25 -1
  26. package/dist/types/index.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/src/adapter/formSchema.test.ts +8 -0
  29. package/src/adapter/formSchema.ts +49 -0
  30. package/src/blocks/resume_multiselect/adapter.ts +31 -0
  31. package/src/catalog/contentSearchContext.tsx +53 -0
  32. package/src/catalog/pairResumeContent.test.ts +34 -0
  33. package/src/catalog/pairResumeContent.ts +55 -0
  34. package/src/catalog/skoponResumeSelect.tsx +348 -153
  35. package/src/catalog/skoponSelectLoading.test.tsx +84 -5
  36. package/src/client/contentSearchClient.test.ts +123 -0
  37. package/src/client/contentSearchClient.ts +156 -0
  38. package/src/client/resumeSearchClient.test.ts +5 -0
  39. package/src/client/resumeSearchClient.ts +5 -0
  40. package/src/client/skoponSdkClients.ts +9 -0
  41. package/src/components/AskUserFormCard.tsx +5 -0
  42. package/src/components/SkoponA2uiStreamRenderer.tsx +15 -9
  43. package/src/components/SkoponFormRenderer.tsx +20 -12
  44. package/src/index.ts +16 -0
  45. package/src/styles/a2ui-preview.css +4 -4
  46. package/src/styles/index.css +158 -13
  47. package/src/submit/submit.test.ts +1 -0
  48. package/src/types/index.ts +26 -1
@@ -1,16 +1,26 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { Avatar, Button, Spin, Tag, Typography } from 'antd'
3
- import { FileImageOutlined, ReloadOutlined, UserOutlined } from '@ant-design/icons'
3
+ import { CaretRightOutlined, FileImageOutlined, ReloadOutlined, UserOutlined } from '@ant-design/icons'
4
4
  import type { ComponentContext } from '@a2ui/web_core/v0_9'
5
5
  import { createBinderlessComponentImplementation } from '@a2ui/react/v0_9'
6
6
  import { z } from 'zod'
7
- import type { FormResumeFilter } from '../types/index'
7
+ import type { FormContentFilter, FormResumeFilter } from '../types/index'
8
+ import type { ContentSearchItem } from './contentSearchContext'
9
+ import { useContentSearch } from './contentSearchContext'
10
+ import {
11
+ advanceCursor,
12
+ nextPageForWindow,
13
+ normalizeDisplayCount,
14
+ pairKey,
15
+ takeWindow,
16
+ windowIndexes,
17
+ type PageCacheSnapshot,
18
+ } from './pairResumeContent'
8
19
  import { useFormLiveOptions } from '../runtime/formLiveOptionsContext'
9
20
  import { useA2uiPreviewMode } from './a2uiPreviewContext'
10
21
  import { asStringArray, clampStringArraySelection, isCardSelectionDisabled, readOptionalSelectCount, toggleStringArraySelection, useSkoponBoundField } from './useSkoponBoundField'
11
22
  import {
12
23
  type ResumeSearchItem,
13
- type ResumeSearchWorkItem,
14
24
  useResumeSearch,
15
25
  } from './resumeSearchContext'
16
26
 
@@ -18,24 +28,29 @@ function readString(value: unknown): string {
18
28
  return typeof value === 'string' ? value : ''
19
29
  }
20
30
 
31
+ function readStringList(raw: unknown): string[] {
32
+ return Array.isArray(raw) ? raw.map(String).filter(Boolean) : []
33
+ }
34
+
21
35
  function readResumeFilter(raw: unknown): FormResumeFilter {
22
36
  if (!raw || typeof raw !== 'object') {
23
- return { names: [], agentUniqueIds: [], resumeUniqueIds: [], pageSize: 20 }
37
+ return { names: [], agentUniqueIds: [], tagUniqueIds: [], resumeUniqueIds: [], pageSize: 20 }
24
38
  }
25
39
  const rec = raw as Record<string, unknown>
26
- const names = Array.isArray(rec.names) ? rec.names.map(String).filter(Boolean) : []
27
- const agentUniqueIds = Array.isArray(rec.agentUniqueIds)
28
- ? rec.agentUniqueIds.map(String).filter(Boolean)
29
- : []
30
- const resumeUniqueIds = Array.isArray(rec.resumeUniqueIds)
31
- ? rec.resumeUniqueIds.map(String).filter(Boolean)
32
- : []
40
+ const agentKind = typeof rec.agentKind === 'string' ? rec.agentKind.trim() : ''
33
41
  const pageSizeRaw = typeof rec.pageSize === 'number' ? rec.pageSize : Number(rec.pageSize)
34
42
  const pageSize =
35
43
  Number.isFinite(pageSizeRaw) && pageSizeRaw >= 1 && pageSizeRaw <= 100
36
44
  ? Math.floor(pageSizeRaw)
37
45
  : 20
38
- return { names, agentUniqueIds, resumeUniqueIds, pageSize }
46
+ return {
47
+ names: readStringList(rec.names),
48
+ ...(agentKind ? { agentKind } : {}),
49
+ agentUniqueIds: readStringList(rec.agentUniqueIds),
50
+ tagUniqueIds: readStringList(rec.tagUniqueIds),
51
+ resumeUniqueIds: readStringList(rec.resumeUniqueIds),
52
+ pageSize,
53
+ }
39
54
  }
40
55
 
41
56
  function filterKey(filter: FormResumeFilter): string {
@@ -51,6 +66,9 @@ const SkoponResumeSelectApi = {
51
66
  help: z.any().optional(),
52
67
  enableRefresh: z.any().optional(),
53
68
  resumeFilter: z.any().optional(),
69
+ resumeDisplayCount: z.any().optional(),
70
+ resumeDisplayMode: z.any().optional(),
71
+ contentFilter: z.any().optional(),
54
72
  minCount: z.any().optional(),
55
73
  maxCount: z.any().optional(),
56
74
  value: z.any().optional(),
@@ -58,112 +76,210 @@ const SkoponResumeSelectApi = {
58
76
  .passthrough(),
59
77
  }
60
78
 
61
- const MAX_TAG_COUNT = 3
62
- const MAX_WORK_PREVIEW = 2
79
+ const DEFAULT_CONTENT_FILTER: FormContentFilter = {
80
+ flowType: 'content-production',
81
+ tagUniqueIds: [],
82
+ pageSize: 20,
83
+ }
63
84
 
64
- function renderTagRow(label: string, tags: string[] | undefined) {
65
- const items = (tags ?? []).filter(Boolean)
66
- const visible = items.slice(0, MAX_TAG_COUNT)
67
- const overflow = items.length - visible.length
68
- return (
69
- <div className="skopon-resume-select-card-tags">
70
- <span className="skopon-resume-select-card-tags-label">{label}</span>
71
- {items.length > 0 ? (
72
- <div className="skopon-resume-select-card-tags-list">
73
- {visible.map((tag) => (
74
- <Tag key={tag} className="skopon-resume-select-card-tag">
75
- {tag}
76
- </Tag>
77
- ))}
78
- {overflow > 0 ? (
79
- <Tag className="skopon-resume-select-card-tag">+{overflow}</Tag>
80
- ) : null}
81
- </div>
82
- ) : (
83
- <span className="skopon-resume-select-card-tags-empty">暂无</span>
84
- )}
85
- </div>
86
- )
85
+ function readContentFilter(raw: unknown): FormContentFilter {
86
+ if (!raw || typeof raw !== 'object') return DEFAULT_CONTENT_FILTER
87
+ const rec = raw as Record<string, unknown>
88
+ const flowType = typeof rec.flowType === 'string' ? rec.flowType.trim() : ''
89
+ const category = typeof rec.category === 'string' ? rec.category.trim() : ''
90
+ const pageSizeRaw = typeof rec.pageSize === 'number' ? rec.pageSize : Number(rec.pageSize)
91
+ const pageSize =
92
+ Number.isFinite(pageSizeRaw) && pageSizeRaw >= 1 && pageSizeRaw <= 100
93
+ ? Math.floor(pageSizeRaw)
94
+ : 20
95
+ return {
96
+ flowType: flowType || 'content-production',
97
+ ...(category ? { category } : {}),
98
+ tagUniqueIds: readStringList(rec.tagUniqueIds),
99
+ tagMatch: rec.tagMatch === 'any' ? 'any' : 'all',
100
+ pageSize,
101
+ }
87
102
  }
88
103
 
89
- function renderSatisfactionRow(satisfaction: number | undefined) {
90
- return (
91
- <div className="skopon-resume-select-card-field">
92
- <span className="skopon-resume-select-card-tags-label">满意度</span>
93
- <span className="skopon-resume-select-card-field-value">
94
- {typeof satisfaction === 'number' ? `${satisfaction}%` : '暂无'}
95
- </span>
96
- </div>
97
- )
104
+ interface RingCache<T> {
105
+ items: T[]
106
+ total: number
107
+ fetchedPage: number
108
+ pageSize: number
109
+ }
110
+
111
+ interface PageResult<T> {
112
+ list: T[]
113
+ total: number
114
+ page: number
115
+ pageSize: number
116
+ }
117
+
118
+ function emptyRing<T>(pageSize: number): RingCache<T> {
119
+ return { items: [], total: 0, fetchedPage: 0, pageSize }
98
120
  }
99
121
 
100
- function ResumeWorkPreview({ work }: { work: ResumeSearchWorkItem }) {
122
+ function snapshotOf<T>(cache: RingCache<T>): PageCacheSnapshot {
123
+ return {
124
+ itemCount: cache.items.length,
125
+ total: cache.total,
126
+ fetchedPage: cache.fetchedPage,
127
+ pageSize: cache.pageSize,
128
+ }
129
+ }
130
+
131
+ async function loadThroughWindow<T>(
132
+ cache: RingCache<T>,
133
+ cursor: number,
134
+ displayCount: number,
135
+ fetchPage: (page: number) => Promise<PageResult<T>>,
136
+ ): Promise<RingCache<T>> {
137
+ let next = cache
138
+ for (let guard = 0; guard < 20; guard += 1) {
139
+ const page = nextPageForWindow(
140
+ snapshotOf(next),
141
+ windowIndexes(cursor, displayCount, next.total),
142
+ )
143
+ if (page == null) return next
144
+ const result = await fetchPage(page)
145
+ const fetchedPage = result.page || page
146
+ next = {
147
+ items: fetchedPage <= 1 ? result.list : next.items.concat(result.list),
148
+ total: result.total,
149
+ fetchedPage,
150
+ pageSize: result.pageSize || next.pageSize,
151
+ }
152
+ if (result.list.length === 0) return next
153
+ }
154
+ return next
155
+ }
156
+
157
+ function ContentStage({ content }: { content: ContentSearchItem }) {
158
+ const [playing, setPlaying] = useState(false)
159
+ const canPlay = Boolean(content.mediaUrl || content.link || content.coverUrl)
160
+
161
+ const play = (event: { stopPropagation: () => void; preventDefault: () => void }) => {
162
+ event.stopPropagation()
163
+ event.preventDefault()
164
+ if (content.mediaKind === 'video' && content.mediaUrl) {
165
+ setPlaying(true)
166
+ return
167
+ }
168
+ const href = content.link || content.mediaUrl || content.coverUrl
169
+ if (href) window.open(href, '_blank', 'noopener,noreferrer')
170
+ }
171
+
101
172
  return (
102
- <div className="skopon-resume-select-card-work">
103
- <div className="skopon-resume-select-card-work-cover">
104
- {work.coverUrl ? (
105
- <img src={work.coverUrl} alt="" className="skopon-resume-select-card-work-img" />
106
- ) : (
107
- <FileImageOutlined className="skopon-resume-select-card-work-placeholder" />
108
- )}
109
- </div>
110
- <div className="skopon-resume-select-card-work-title">{work.title || '未命名作品'}</div>
173
+ <div className="skopon-resume-select-card-stage">
174
+ {playing && content.mediaUrl ? (
175
+ <video
176
+ className="skopon-resume-select-card-stage-video"
177
+ src={content.mediaUrl}
178
+ poster={content.coverUrl ?? undefined}
179
+ controls
180
+ autoPlay
181
+ playsInline
182
+ onClick={(event) => event.stopPropagation()}
183
+ />
184
+ ) : (
185
+ <button
186
+ type="button"
187
+ className="skopon-resume-select-card-stage-button"
188
+ aria-label="播放内容"
189
+ disabled={!canPlay}
190
+ onClick={play}
191
+ >
192
+ {content.coverUrl ? (
193
+ <img src={content.coverUrl} alt="" className="skopon-resume-select-card-cover-img" />
194
+ ) : (
195
+ <FileImageOutlined className="skopon-resume-select-card-cover-placeholder" />
196
+ )}
197
+ {canPlay ? <CaretRightOutlined className="skopon-resume-select-card-play" /> : null}
198
+ </button>
199
+ )}
111
200
  </div>
112
201
  )
113
202
  }
114
203
 
115
204
  function ResumeCard({
116
205
  item,
206
+ content,
117
207
  selected,
118
208
  disabled,
119
209
  onToggle,
120
210
  }: {
121
- item: ResumeSearchItem
211
+ item?: ResumeSearchItem
212
+ content: ContentSearchItem
122
213
  selected: boolean
123
214
  disabled: boolean
124
215
  onToggle: () => void
125
216
  }) {
126
- const works = (item.works ?? []).slice(0, MAX_WORK_PREVIEW)
127
-
217
+ const specialties = (item?.specialties ?? []).filter(Boolean).slice(0, 3)
128
218
  return (
129
- <button
130
- type="button"
131
- className={`skopon-resume-select-card skopon-resume-select-card--profile${selected ? ' skopon-resume-select-card--selected' : ''}`}
132
- disabled={disabled}
133
- onClick={onToggle}
219
+ <div
220
+ role="button"
221
+ tabIndex={disabled ? -1 : 0}
222
+ aria-disabled={disabled || undefined}
223
+ aria-pressed={selected}
224
+ className={`skopon-resume-select-card skopon-resume-select-card--profile${selected ? ' skopon-resume-select-card--selected' : ''}${disabled ? ' skopon-resume-select-card--disabled' : ''}`}
225
+ onClick={() => {
226
+ if (!disabled) onToggle()
227
+ }}
228
+ onKeyDown={(event) => {
229
+ if (disabled) return
230
+ if (event.key === 'Enter' || event.key === ' ') {
231
+ event.preventDefault()
232
+ onToggle()
233
+ }
234
+ }}
134
235
  >
135
- <Avatar
136
- size={48}
137
- src={item.avatarUrl || undefined}
138
- icon={!item.avatarUrl ? <UserOutlined /> : undefined}
139
- className="skopon-resume-select-card-avatar"
140
- />
141
- <div className="skopon-resume-select-card-name">{item.name || '未命名简历'}</div>
142
- <div className="skopon-resume-select-card-body">
143
- {renderSatisfactionRow(item.satisfaction)}
144
- {renderTagRow('性格', item.personality)}
145
- {renderTagRow('专长', item.specialties)}
146
- {renderTagRow('擅长领域', item.traits)}
147
- <div className="skopon-resume-select-card-works">
148
- <span className="skopon-resume-select-card-works-label">过往作品</span>
149
- {works.length > 0 ? (
150
- <div className="skopon-resume-select-card-works-list">
151
- {works.map((work, index) => (
152
- <ResumeWorkPreview key={`${work.title}-${index}`} work={work} />
236
+ {item ? <div className="skopon-resume-select-card-person">
237
+ <Avatar
238
+ size={36}
239
+ src={item.avatarUrl || undefined}
240
+ icon={!item.avatarUrl ? <UserOutlined /> : undefined}
241
+ className="skopon-resume-select-card-avatar"
242
+ />
243
+ <div className="skopon-resume-select-card-person-text">
244
+ <div className="skopon-resume-select-card-person-name">
245
+ {item.name || '未命名简历'}
246
+ </div>
247
+ {specialties.length > 0 ? (
248
+ <div className="skopon-resume-select-card-specialties">
249
+ {specialties.map((tag) => (
250
+ <Tag key={tag} className="skopon-resume-select-card-tag">
251
+ {tag}
252
+ </Tag>
153
253
  ))}
154
254
  </div>
155
- ) : (
156
- <div className="skopon-resume-select-card-works-empty">暂无作品</div>
157
- )}
255
+ ) : null}
158
256
  </div>
159
257
  </div>
160
- </button>
258
+ : null}
259
+ <div className="skopon-resume-select-card-content-title">
260
+ {content.title || '未命名内容'}
261
+ </div>
262
+ {(content.tagLabels ?? []).length > 0 ? (
263
+ <div className="skopon-resume-select-card-content-tags" aria-label="内容标签">
264
+ <span className="skopon-resume-select-card-content-tags-label">内容标签</span>
265
+ <div className="skopon-resume-select-card-content-tags-list">
266
+ {(content.tagLabels ?? []).map((tag) => (
267
+ <Tag key={tag} className="skopon-resume-select-card-content-tag">
268
+ {tag}
269
+ </Tag>
270
+ ))}
271
+ </div>
272
+ </div>
273
+ ) : null}
274
+ <ContentStage content={content} />
275
+ </div>
161
276
  )
162
277
  }
163
278
 
164
279
  function SkoponResumeSelectPreview({ context }: { context: ComponentContext }) {
165
280
  const { interactive } = useA2uiPreviewMode()
166
281
  const resumeSearch = useResumeSearch()
282
+ const contentSearch = useContentSearch()
167
283
  const { publishLiveOptions } = useFormLiveOptions()
168
284
  const { value, setValue, setValueSilent, fieldName } = useSkoponBoundField(context, 'value', {
169
285
  component: 'SkoponResumeSelect',
@@ -177,16 +293,25 @@ function SkoponResumeSelectPreview({ context }: { context: ComponentContext }) {
177
293
  const enableRefresh = props.enableRefresh !== false
178
294
  const minCount = readOptionalSelectCount(props.minCount)
179
295
  const maxCount = readOptionalSelectCount(props.maxCount)
296
+ const contentOnly = props.resumeDisplayMode === 'content-only'
297
+ const displayCount = normalizeDisplayCount(props.resumeDisplayCount)
180
298
  const resumeFilter = useMemo(() => readResumeFilter(props.resumeFilter), [props.resumeFilter])
181
- const filterSig = filterKey(resumeFilter)
299
+ const contentFilter = useMemo(() => readContentFilter(props.contentFilter), [props.contentFilter])
300
+ const resumeSig = filterKey(resumeFilter)
301
+ const contentSig = filterKey(contentFilter)
182
302
 
183
- const [page, setPage] = useState(1)
303
+ const [resumeCache, setResumeCache] = useState<RingCache<ResumeSearchItem>>(() => emptyRing(20))
304
+ const [contentCache, setContentCache] = useState<RingCache<ContentSearchItem>>(() => emptyRing(20))
305
+ const [resumeCursor, setResumeCursor] = useState(0)
306
+ const [contentCursor, setContentCursor] = useState(0)
184
307
  const [loading, setLoading] = useState(false)
185
308
  const [error, setError] = useState('')
186
- const [items, setItems] = useState<ResumeSearchItem[]>([])
187
- const [total, setTotal] = useState(0)
188
- const [pageSize, setPageSize] = useState(resumeFilter.pageSize ?? 20)
189
309
  const requestSeqRef = useRef(0)
310
+ const refreshInFlightRef = useRef(false)
311
+ const resumeCacheRef = useRef(resumeCache)
312
+ const contentCacheRef = useRef(contentCache)
313
+ resumeCacheRef.current = resumeCache
314
+ contentCacheRef.current = contentCache
190
315
 
191
316
  const selectedIds = asStringArray(value)
192
317
 
@@ -195,73 +320,144 @@ function SkoponResumeSelectPreview({ context }: { context: ComponentContext }) {
195
320
  if (clamped !== null) setValueSilent(clamped)
196
321
  }, [maxCount, selectedIds, setValueSilent])
197
322
 
198
- const fetchPage = useCallback(
199
- async (targetPage: number) => {
200
- const filter = readResumeFilter(JSON.parse(filterSig) as FormResumeFilter)
201
- const seq = ++requestSeqRef.current
202
- if (!resumeSearch) {
203
- setItems([])
204
- setTotal(0)
205
- setError('未配置简历搜索服务')
206
- setLoading(false)
207
- return
208
- }
209
- setLoading(true)
210
- setError('')
323
+ const fetchResumePage = useCallback(
324
+ (page: number) => {
325
+ const filter = readResumeFilter(JSON.parse(resumeSig) as FormResumeFilter)
326
+ if (!resumeSearch) return Promise.reject(new Error('未配置简历搜索服务'))
327
+ return resumeSearch({ filter, page })
328
+ },
329
+ [resumeSearch, resumeSig],
330
+ )
331
+
332
+ const fetchContentPage = useCallback(
333
+ (page: number) => {
334
+ const filter = readContentFilter(JSON.parse(contentSig) as FormContentFilter)
335
+ if (!contentSearch) return Promise.reject(new Error('未配置内容搜索服务'))
336
+ return contentSearch({ filter, page })
337
+ },
338
+ [contentSearch, contentSig],
339
+ )
340
+
341
+ useEffect(() => {
342
+ const seq = ++requestSeqRef.current
343
+ if ((!contentOnly && !resumeSearch) || !contentSearch) {
344
+ setResumeCache(emptyRing(resumeFilter.pageSize ?? 20))
345
+ setContentCache(emptyRing(contentFilter.pageSize ?? 20))
346
+ setResumeCursor(0)
347
+ setContentCursor(0)
348
+ setError(!contentOnly && !resumeSearch ? '未配置简历搜索服务' : '未配置内容搜索服务')
349
+ setLoading(false)
350
+ return
351
+ }
352
+ setLoading(true)
353
+ setError('')
354
+ setResumeCursor(0)
355
+ setContentCursor(0)
356
+ void (async () => {
211
357
  try {
212
- const result = await resumeSearch({ filter, page: targetPage })
358
+ const [resumeFirst, contentFirst] = await Promise.all([
359
+ contentOnly ? Promise.resolve({ list: [], total: 0, page: 1, pageSize: 20 }) : fetchResumePage(1),
360
+ fetchContentPage(1),
361
+ ])
213
362
  if (seq !== requestSeqRef.current) return
214
- setItems(result.list)
215
- setTotal(result.total)
216
- setPage(result.page)
217
- setPageSize(result.pageSize)
218
- if (fieldName) {
219
- publishLiveOptions(
220
- fieldName,
221
- result.list.map((item) => ({
222
- value: item.resumeUniqueId,
223
- label: item.name || item.resumeUniqueId,
224
- })),
225
- )
363
+ let resumes: RingCache<ResumeSearchItem> = {
364
+ items: resumeFirst.list,
365
+ total: resumeFirst.total,
366
+ fetchedPage: resumeFirst.page || 1,
367
+ pageSize: resumeFirst.pageSize,
226
368
  }
369
+ let contents: RingCache<ContentSearchItem> = {
370
+ items: contentFirst.list,
371
+ total: contentFirst.total,
372
+ fetchedPage: contentFirst.page || 1,
373
+ pageSize: contentFirst.pageSize,
374
+ }
375
+ if (!contentOnly) resumes = await loadThroughWindow(resumes, 0, displayCount, fetchResumePage)
376
+ contents = await loadThroughWindow(contents, 0, displayCount, fetchContentPage)
377
+ if (seq !== requestSeqRef.current) return
378
+ setResumeCache(resumes)
379
+ setContentCache(contents)
227
380
  } catch (err) {
228
381
  if (seq !== requestSeqRef.current) return
229
- setItems([])
230
- setTotal(0)
231
- setError(err instanceof Error ? err.message : '加载简历列表失败')
382
+ setResumeCache(emptyRing(20))
383
+ setContentCache(emptyRing(20))
384
+ setError(err instanceof Error ? err.message : (contentOnly ? '加载内容失败' : '加载简历和内容失败'))
232
385
  } finally {
233
386
  if (seq === requestSeqRef.current) setLoading(false)
234
387
  }
235
- },
236
- [resumeSearch, filterSig, fieldName, publishLiveOptions],
237
- )
388
+ })()
389
+ }, [
390
+ contentOnly,
391
+ contentFilter.pageSize,
392
+ contentSearch,
393
+ displayCount,
394
+ fetchContentPage,
395
+ fetchResumePage,
396
+ resumeFilter.pageSize,
397
+ resumeSearch,
398
+ ])
238
399
 
400
+ const resumeWindow = takeWindow(resumeCache.items, resumeCursor, displayCount, resumeCache.total)
401
+ const contentWindow = takeWindow(contentCache.items, contentCursor, displayCount, contentCache.total)
402
+ const pairs: { item?: ResumeSearchItem; content: ContentSearchItem; key: string }[] = contentOnly
403
+ ? contentWindow.map(content => ({ content, key: content.flowUniqueId }))
404
+ : resumeWindow.flatMap((item, index) => {
405
+ const content = contentWindow[index]
406
+ if (!content) return []
407
+ return [{ item, content, key: pairKey(item.resumeUniqueId, content.flowUniqueId) }]
408
+ })
409
+
410
+ const pairSig = pairs.map((pair) => `${pair.key}\t${pair.item?.name ?? ''}\t${pair.content.title}`).join('\n')
239
411
  useEffect(() => {
240
- setPage(1)
241
- void fetchPage(1)
242
- }, [filterSig, fetchPage])
412
+ if (!fieldName) return
413
+ publishLiveOptions(
414
+ fieldName,
415
+ pairs.map((pair) => ({
416
+ value: pair.key,
417
+ label: pair.item ? `${pair.item.name || pair.item.resumeUniqueId} / ${pair.content.title}` : pair.content.title,
418
+ })),
419
+ )
420
+ }, [fieldName, pairSig, publishLiveOptions])
243
421
 
244
- const toggleSelect = (resumeUniqueId: string) => {
422
+ const toggleSelect = (key: string) => {
245
423
  if (!interactive) return
246
- const next = toggleStringArraySelection(
247
- selectedIds,
248
- resumeUniqueId,
249
- minCount,
250
- maxCount,
251
- )
424
+ const next = toggleStringArraySelection(selectedIds, key, minCount, maxCount)
252
425
  if (next === null) return
253
426
  setValue(next)
254
427
  }
255
428
 
256
429
  const handleRefresh = () => {
257
- const filter = readResumeFilter(JSON.parse(filterSig) as FormResumeFilter)
258
- const effectivePageSize = pageSize || filter.pageSize || 20
259
- const totalPages = Math.max(1, Math.ceil(total / effectivePageSize))
260
- const nextPage = page >= totalPages ? 1 : page + 1
261
- void fetchPage(nextPage)
430
+ if (refreshInFlightRef.current) return
431
+ if ((!contentOnly && resumeCache.total <= 0) || contentCache.total <= 0) return
432
+ refreshInFlightRef.current = true
433
+ const seq = ++requestSeqRef.current
434
+ const nextResumeCursor = advanceCursor(resumeCursor, displayCount, resumeCache.total)
435
+ const nextContentCursor = advanceCursor(contentCursor, displayCount, contentCache.total)
436
+ setLoading(true)
437
+ setError('')
438
+ void (async () => {
439
+ try {
440
+ const [resumes, contents] = await Promise.all([
441
+ contentOnly ? Promise.resolve(resumeCacheRef.current) : loadThroughWindow(resumeCacheRef.current, nextResumeCursor, displayCount, fetchResumePage),
442
+ loadThroughWindow(contentCacheRef.current, nextContentCursor, displayCount, fetchContentPage),
443
+ ])
444
+ if (seq !== requestSeqRef.current) return
445
+ setResumeCache(resumes)
446
+ setContentCache(contents)
447
+ setResumeCursor(nextResumeCursor)
448
+ setContentCursor(nextContentCursor)
449
+ } catch (err) {
450
+ if (seq !== requestSeqRef.current) return
451
+ setError(err instanceof Error ? err.message : '换一批失败')
452
+ } finally {
453
+ refreshInFlightRef.current = false
454
+ if (seq === requestSeqRef.current) setLoading(false)
455
+ }
456
+ })()
262
457
  }
263
458
 
264
- const emptyLabel = placeholder || '暂无匹配的简历'
459
+ const emptyLabel = placeholder || (contentOnly ? '暂无匹配的内容' : '暂无匹配的简历或内容')
460
+ const showEmpty = !loading && !error && pairs.length === 0
265
461
 
266
462
  return (
267
463
  <div
@@ -269,9 +465,9 @@ function SkoponResumeSelectPreview({ context }: { context: ComponentContext }) {
269
465
  data-skopon-field={fieldName || undefined}
270
466
  >
271
467
  {label ? <div className="form-block-preview-label">{label}</div> : null}
272
- {loading && items.length === 0 ? (
468
+ {loading && pairs.length === 0 ? (
273
469
  <div className="skopon-resume-select-status">
274
- <Spin size="small" /> 加载简历…
470
+ <Spin size="small" /> {contentOnly ? '加载内容…' : '加载简历和内容…'}
275
471
  </div>
276
472
  ) : null}
277
473
  {!loading && error ? (
@@ -279,36 +475,35 @@ function SkoponResumeSelectPreview({ context }: { context: ComponentContext }) {
279
475
  {error}
280
476
  </Typography.Text>
281
477
  ) : null}
282
- {!loading && !error && items.length === 0 ? (
283
- <div className="skopon-resume-select-empty">{emptyLabel}</div>
284
- ) : null}
285
- {items.length > 0 ? (
478
+ {showEmpty ? <div className="skopon-resume-select-empty">{emptyLabel}</div> : null}
479
+ {pairs.length > 0 ? (
286
480
  <div className="skopon-resume-select-list">
287
- {items.map((item) => (
481
+ {pairs.map((pair) => (
288
482
  <ResumeCard
289
- key={item.resumeUniqueId}
290
- item={item}
291
- selected={selectedIds.includes(item.resumeUniqueId)}
483
+ key={pair.key}
484
+ item={pair.item}
485
+ content={pair.content}
486
+ selected={selectedIds.includes(pair.key)}
292
487
  disabled={isCardSelectionDisabled(
293
488
  selectedIds,
294
- item.resumeUniqueId,
489
+ pair.key,
295
490
  interactive,
296
491
  minCount,
297
492
  maxCount,
298
493
  )}
299
- onToggle={() => toggleSelect(item.resumeUniqueId)}
494
+ onToggle={() => toggleSelect(pair.key)}
300
495
  />
301
496
  ))}
302
497
  </div>
303
498
  ) : null}
304
- {enableRefresh && resumeSearch ? (
499
+ {enableRefresh && (contentOnly || resumeSearch) && contentSearch ? (
305
500
  <div className="skopon-resume-select-actions">
306
501
  <Button
307
502
  type="default"
308
503
  size="small"
309
504
  icon={<ReloadOutlined />}
310
505
  loading={loading}
311
- disabled={!interactive}
506
+ disabled={!interactive || (!contentOnly && resumeCache.total <= 0) || contentCache.total <= 0}
312
507
  onClick={handleRefresh}
313
508
  >
314
509
  换一批