返回 AiToEarn
index.tsx
1 /**
2 * 发布作品弹框
3 * 支持多平台内容发布,包含内容管理、发布编辑等功能
4 */
5
6 import type { ForwardedRef } from 'react'
7 import type { SocialAccount } from '@/api/accounts/account.types'
8
9 import {
10 forwardRef,
11 memo,
12 useCallback,
13 useEffect,
14 useImperativeHandle,
15 useMemo,
16 useRef,
17 } from 'react'
18 import { DndProvider } from 'react-dnd'
19 import { HTML5Backend } from 'react-dnd-html5-backend'
20 import { useShallow } from 'zustand/react/shallow'
21
22 import { useTransClient } from '@/app/i18n/client'
23 import DesktopPublishContent from '@/components/PublishDialog/compoents/DesktopPublishContent'
24 import MobilePublishContent from '@/components/PublishDialog/compoents/mobile/MobilePublishContent'
25 import { PublishDialogSkeleton } from '@/components/PublishDialog/compoents/PublishDialogSkeleton'
26 import { usePublishManageUpload } from '@/components/PublishDialog/compoents/PublishManageUpload/usePublishManageUpload'
27 import PublishModals from '@/components/PublishDialog/compoents/PublishModals'
28 import { useAutoPublishOnReady } from '@/components/PublishDialog/hooks/useAutoPublishOnReady'
29 import { useCloseDialog } from '@/components/PublishDialog/hooks/useCloseDialog'
30 import { usePublishActions } from '@/components/PublishDialog/hooks/usePublishActions'
31 import {
32 usePublishDetailModalActions,
33 usePublishModalState,
34 } from '@/components/PublishDialog/hooks/usePublishState'
35 import usePubParamsVerify from '@/components/PublishDialog/hooks/usePubParamsVerify'
36 import { useUploadSync } from '@/components/PublishDialog/hooks/useUploadSync'
37 import { useValidatedPublishTrigger } from '@/components/PublishDialog/hooks/useValidatedPublishTrigger'
38 import { usePublishDialog } from '@/components/PublishDialog/usePublishDialog'
39 import { usePublishDialogStorageStore } from '@/components/PublishDialog/usePublishDialogStorageStore'
40 import { Modal } from '@/components/ui/modal'
41 import { useIsMobile } from '@/hooks/useIsMobile'
42 import { useAccountStore } from '@/store/account'
43
44 // ============ 类型定义 ============
45
46 export interface IPublishDialogRef {
47 // 设置发布时间
48 setPubTime: (pubTime?: string) => void
49 }
50
51 export interface IPublishDialogProps {
52 open: boolean
53 onClose: () => void
54 accounts: SocialAccount[]
55 // 发布成功事件
56 onPubSuccess?: () => void
57 // 默认选中的账户Id列表
58 defaultAccountIds?: string[]
59 // 是否抑制自动发布(用于从任务页面打开,先让用户确认后再发布)
60 suppressAutoPublish?: boolean
61 // 关联的任务ID(如果是从任务流程打开)
62 taskIdForPublish?: string
63 // 关联的素材组 ID(如果是从任务流程打开)
64 materialGroupIdForPublish?: string
65 // 关联的草稿素材 ID(如果是从任务流程打开且存在推荐草稿)
66 materialIdForPublish?: string
67 // 发布确认回调(发布完成时触发,并携带 taskIdForPublish)
68 onPublishConfirmed?: (taskId?: string, publishRecordId?: string) => void
69 // 发布开始回调(pubClick 开头触发,用于提前设置外部 loading)
70 onPublishStart?: () => void
71 // 发布完成后是否自动关闭详情弹框(默认 false)
72 autoCloseOnComplete?: boolean
73 // 弹框内容就绪后自动触发一次发布
74 autoPublishOnReady?: boolean
75 // 账号列表首次加载中
76 accountListInitialLoading?: boolean
77 }
78
79 // ============ 主组件 ============
80
81 const PublishDialog = memo(
82 forwardRef(
83 (
84 {
85 open,
86 onClose,
87 accounts,
88 onPubSuccess,
89 defaultAccountIds,
90 suppressAutoPublish,
91 taskIdForPublish,
92 materialGroupIdForPublish,
93 materialIdForPublish,
94 onPublishConfirmed,
95 onPublishStart,
96 autoCloseOnComplete,
97 autoPublishOnReady,
98 accountListInitialLoading = false,
99 }: IPublishDialogProps,
100 ref: ForwardedRef<IPublishDialogRef>,
101 ) => {
102 const isMobile = useIsMobile()
103 const { t, ready: publishI18nReady } = useTransClient('publish', {
104 useSuspense: false,
105 })
106 const {} = useTransClient('brandPromotion', {
107 useSuspense: false,
108 })
109
110 // ============ Store Hooks ============
111
112 const {
113 latestAccountMap,
114 accountPluginAuthLoading,
115 } = useAccountStore(
116 useShallow(state => ({
117 latestAccountMap: state.accountMap,
118 accountPluginAuthLoading: state.accountPluginAuthLoading,
119 })),
120 )
121 const latestAccounts = useMemo(
122 () => accounts.map(account => latestAccountMap.get(account.id) ?? account),
123 [accounts, latestAccountMap],
124 )
125
126 // 持久化存储
127 const { setPubData, restorePubData, _hasHydrated, setPubListData }
128 = usePublishDialogStorageStore(
129 useShallow(state => ({
130 setPubData: state.setPubData,
131 restorePubData: state.restorePubData,
132 _hasHydrated: state._hasHydrated,
133 setPubListData: state.setPubListData,
134 })),
135 )
136
137 // 发布弹框核心状态
138 const {
139 pubListChoosed,
140 setPubListChoosed,
141 init,
142 syncAccounts,
143 clear,
144 pubList,
145 setStep,
146 setExpandedPubItem,
147 expandedPubItem,
148 setErrParamsMap,
149 setPubTime,
150 pubTime,
151 setWarningParamsMap,
152 prefillLoading,
153 } = usePublishDialog(
154 useShallow(state => ({
155 pubListChoosed: state.pubListChoosed,
156 setPubListChoosed: state.setPubListChoosed,
157 init: state.init,
158 syncAccounts: state.syncAccounts,
159 clear: state.clear,
160 pubList: state.pubList,
161 setStep: state.setStep,
162 setExpandedPubItem: state.setExpandedPubItem,
163 expandedPubItem: state.expandedPubItem,
164 setErrParamsMap: state.setErrParamsMap,
165 setWarningParamsMap: state.setWarningParamsMap,
166 setPubTime: state.setPubTime,
167 pubTime: state.pubTime,
168 prefillLoading: state.prefillLoading,
169 })),
170 )
171
172 // 上传管理
173 const { tasks, md5Cache } = usePublishManageUpload(
174 useShallow(state => ({
175 tasks: state.tasks,
176 md5Cache: state.md5Cache,
177 })),
178 )
179
180 // 参数校验
181 const { errParamsMap, warningParamsMap } = usePubParamsVerify(pubListChoosed)
182
183 // ============ Local State ============
184
185 // 各种弹窗状态
186 const modalState = usePublishModalState()
187 const { closePublishDetailModal } = usePublishDetailModalActions(
188 modalState.setPublishDetailVisible,
189 modalState.setCurrentPublishTaskId,
190 )
191
192 // 控制标记
193 const isClear = useRef(true)
194 const isInit = useRef(false)
195 const hasInitRef = useRef(false)
196 const hasAppliedDefaultSelectionRef = useRef(false)
197 const hasRequestedRestoreRef = useRef(false)
198 const previousOpenRef = useRef(open)
199 const publishPluginLoading = open && !accountListInitialLoading && accountPluginAuthLoading
200 const dialogLoading = accountListInitialLoading || publishPluginLoading || prefillLoading || !publishI18nReady
201
202 // ============ Custom Hooks ============
203
204 // 发布操作
205 const { pubClick } = usePublishActions({
206 pubListChoosed,
207 pubTime,
208 suppressAutoPublish,
209 taskIdForPublish,
210 materialGroupIdForPublish,
211 materialIdForPublish,
212 onPublishConfirmed,
213 onPublishStart,
214 onClose,
215 onPubSuccess,
216 setCreateLoading: modalState.setCreateLoading,
217 setCurrentPublishTaskId: modalState.setCurrentPublishTaskId,
218 setPublishDetailVisible: modalState.setPublishDetailVisible,
219 t,
220 })
221 const { triggerPublish } = useValidatedPublishTrigger(pubClick)
222
223 useAutoPublishOnReady({
224 enabled: autoPublishOnReady && !isMobile,
225 open,
226 ready: !dialogLoading && !modalState.createLoading,
227 selectedCount: pubListChoosed.length,
228 triggerPublish,
229 })
230
231 // 上传结果同步
232 useUploadSync({
233 pubListChoosed,
234 tasks,
235 md5Cache,
236 setPubListChoosed,
237 })
238
239 // ============ Effects ============
240
241 // 同步错误和警告到 store
242 useEffect(() => {
243 if (!open) {
244 return
245 }
246
247 setErrParamsMap(errParamsMap)
248 }, [errParamsMap, open, setErrParamsMap])
249
250 useEffect(() => {
251 if (!open) {
252 return
253 }
254
255 setWarningParamsMap(warningParamsMap)
256 }, [open, setWarningParamsMap, warningParamsMap])
257
258 // 恢复持久化数据
259 useEffect(() => {
260 if (!open) {
261 hasRequestedRestoreRef.current = false
262 return
263 }
264
265 if (
266 !_hasHydrated
267 || accountListInitialLoading
268 || pubList.length === 0
269 || hasRequestedRestoreRef.current
270 ) {
271 return
272 }
273
274 hasRequestedRestoreRef.current = true
275 isClear.current = true
276 restorePubData()
277 }, [accountListInitialLoading, open, pubList.length, _hasHydrated, restorePubData])
278
279 // 实时保存数据
280 useEffect(() => {
281 if (!open)
282 return
283 if (
284 !expandedPubItem?.params.des
285 && expandedPubItem?.params.images?.length === 0
286 && !expandedPubItem?.params.video
287 ) {
288 return
289 }
290 if (isClear.current) {
291 isClear.current = false
292 return
293 }
294 setPubData(pubListChoosed)
295 }, [pubListChoosed, open, expandedPubItem, setPubData])
296
297 useEffect(() => {
298 if (!expandedPubItem || pubList.length === 0)
299 return
300 if (isInit.current) {
301 isInit.current = false
302 return
303 }
304 setPubListData(pubList)
305 }, [pubList, expandedPubItem, setPubListData])
306
307 // 弹窗打开/关闭逻辑
308 useEffect(() => {
309 const wasOpen = previousOpenRef.current
310
311 if (open) {
312 previousOpenRef.current = true
313 if (hasInitRef.current) {
314 const hasSelected = syncAccounts(latestAccounts, defaultAccountIds, {
315 applyDefaultWhenEmpty: !hasAppliedDefaultSelectionRef.current,
316 })
317 if (hasSelected) {
318 hasAppliedDefaultSelectionRef.current = true
319 }
320 return // 已初始化,跳过重复 init
321 }
322 hasInitRef.current = true
323 isInit.current = true
324 hasAppliedDefaultSelectionRef.current = init(latestAccounts, defaultAccountIds)
325 }
326 else {
327 previousOpenRef.current = false
328 if (!wasOpen) {
329 return
330 }
331 hasInitRef.current = false // 关闭时重置,下次打开可重新 init
332 hasAppliedDefaultSelectionRef.current = false
333 isClear.current = true
334 setPubListChoosed([])
335 setStep(0)
336 setExpandedPubItem(undefined)
337 clear()
338 }
339 }, [
340 open,
341 latestAccounts,
342 defaultAccountIds,
343 init,
344 syncAccounts,
345 clear,
346 setPubListChoosed,
347 setStep,
348 setExpandedPubItem,
349 ])
350
351 // ============ Callbacks ============
352
353 // 关闭弹窗确认
354 const { closeDialog } = useCloseDialog({ onClose, t })
355
356 // 处理下一步
357 const handleNextStep = useCallback(() => {
358 setExpandedPubItem(undefined)
359 setStep(1)
360 }, [setExpandedPubItem, setStep])
361
362 // ============ Imperative Handle ============
363
364 useImperativeHandle(ref, () => ({
365 setPubTime,
366 }))
367
368 // ============ Render ============
369
370 const publishModalsNode = (
371 <PublishModals
372 douyinQRCodeVisible={modalState.douyinQRCodeVisible}
373 setDouyinQRCodeVisible={modalState.setDouyinQRCodeVisible}
374 douyinPermalink={modalState.douyinPermalink}
375 publishDetailVisible={modalState.publishDetailVisible}
376 onPublishDetailClose={closePublishDetailModal}
377 currentPublishTaskId={modalState.currentPublishTaskId}
378 autoCloseOnComplete={autoCloseOnComplete}
379 />
380 )
381
382 const hasOpenChildModal = modalState.douyinQRCodeVisible
383 || modalState.publishDetailVisible
384
385 if (!open && !hasOpenChildModal) {
386 return null
387 }
388
389 if (!open) {
390 return publishModalsNode
391 }
392
393 // 移动端渲染
394 if (isMobile) {
395 return (
396 <>
397 <Modal
398 className="!inset-0 !left-0 !top-0 !h-[100dvh] !w-[100dvw] !max-w-none !max-h-none !m-0 !translate-x-0 !translate-y-0 !rounded-none !p-0 !bg-background !border-none !shadow-none !duration-300 data-[state=open]:!animate-in data-[state=closed]:!animate-out data-[state=open]:!fade-in-0 data-[state=closed]:!fade-out-0 data-[state=open]:!zoom-in-95 data-[state=closed]:!zoom-out-95 [&>div]:!p-0 [&>div]:!rounded-none"
399 bodyClassName="!m-0 !h-full !w-full !p-4"
400 closable={false}
401 maskClosable={false}
402 open={open}
403 onCancel={closeDialog}
404 footer={null}
405 width="auto"
406 modal={false}
407 disableFocusTrap
408 overlayClassName="hidden"
409 >
410 <div className="relative h-full w-full">
411 {publishI18nReady && (
412 <MobilePublishContent
413 open={open}
414 accounts={accounts}
415 onClose={onClose}
416 onPubSuccess={onPubSuccess}
417 defaultAccountIds={defaultAccountIds}
418 suppressAutoPublish={suppressAutoPublish}
419 taskIdForPublish={taskIdForPublish}
420 materialGroupIdForPublish={materialGroupIdForPublish}
421 materialIdForPublish={materialIdForPublish}
422 onPublishConfirmed={onPublishConfirmed}
423 onPublishStart={onPublishStart}
424 autoPublishOnReady={autoPublishOnReady}
425 createLoading={modalState.createLoading}
426 setCreateLoading={modalState.setCreateLoading}
427 setCurrentPublishTaskId={modalState.setCurrentPublishTaskId}
428 setPublishDetailVisible={modalState.setPublishDetailVisible}
429 />
430 )}
431 {dialogLoading && <PublishDialogSkeleton isMobile />}
432 </div>
433 </Modal>
434
435 {publishModalsNode}
436 </>
437 )
438 }
439
440 // PC端渲染
441 return (
442 <>
443 <Modal
444 className="!inset-0 !left-0 !top-0 !h-[100dvh] !w-[100dvw] !min-h-0 !max-h-none !max-w-none !translate-x-0 !translate-y-0 !gap-0 !overflow-hidden !rounded-none !border-none !bg-background !p-0 !shadow-none !duration-300 data-[state=open]:!animate-in data-[state=closed]:!animate-out data-[state=open]:!fade-in-0 data-[state=closed]:!fade-out-0 data-[state=open]:!zoom-in-95 data-[state=closed]:!zoom-out-95 [&>div]:!m-0 [&>div]:!overflow-hidden [&>div]:!p-0"
445 bodyClassName="!m-0 !h-full !min-h-0 !w-full !overflow-hidden !p-0"
446 closable={false}
447 maskClosable={false}
448 open={open}
449 onCancel={closeDialog}
450 footer={null}
451 width="auto"
452 contentStyle={{
453 bottom: 0,
454 height: '100dvh',
455 left: 0,
456 maxHeight: 'none',
457 maxWidth: 'none',
458 right: 0,
459 top: 0,
460 width: '100dvw',
461 }}
462 modal={false}
463 disableFocusTrap
464 overlayClassName="hidden"
465 >
466 <div className="relative h-full min-h-0 w-full">
467 {publishI18nReady && (
468 <DndProvider backend={HTML5Backend}>
469 <DesktopPublishContent
470 onClose={closeDialog}
471 createLoading={modalState.createLoading}
472 onPublish={pubClick}
473 />
474 </DndProvider>
475 )}
476 {dialogLoading && <PublishDialogSkeleton isMobile={false} />}
477 </div>
478 </Modal>
479
480 {publishModalsNode}
481 </>
482 )
483 },
484 ),
485 )
486
487 PublishDialog.displayName = 'PublishDialog'
488
489 export default PublishDialog
490
490 lines Plain Text