返回 AiToEarn
1 /**
2 * PC端发布弹框主内容组件
3 * 包含 AI 内容创作、账户选择、内容编辑等核心功能
4 */
5
6 import type { IImgFile, IVideoFile, PubItem } from '@/components/PublishDialog/publishDialog.type'
7 import type { PublishDialogDragItem } from '@/components/PublishDialog/PublishDialog.util'
8
9 import { ChevronDown, FolderOpen, Layers, PanelLeftClose, PanelLeftOpen, Plus, UploadCloud, X } from 'lucide-react'
10 import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
11 import { useDrop } from 'react-dnd'
12 import { useShallow } from 'zustand/react/shallow'
13 import { PubType } from '@/app/config/publishConfig'
14 import { useTransClient } from '@/app/i18n/client'
15 import { useChannelManagerStore } from '@/components/ChannelManager/channelManagerStore'
16 import AccountSelector from '@/components/PublishDialog/compoents/AccountSelector'
17 import ErrorSummary from '@/components/PublishDialog/compoents/ErrorSummary'
18 import PlatParamsSetting from '@/components/PublishDialog/compoents/PlatParamsSetting'
19 import CommonTitleInput from '@/components/PublishDialog/compoents/PlatParamsSetting/common/CommonTitleInput'
20 import PublishFooter from '@/components/PublishDialog/compoents/PublishFooter'
21 import PubParmasTextarea from '@/components/PublishDialog/compoents/PubParmasTextarea'
22 import { useAccountClickHandler } from '@/components/PublishDialog/hooks/useAccountClickHandler'
23 import { usePlatformAuth } from '@/components/PublishDialog/hooks/usePlatformAuth'
24 import {
25 buildPublishParamsFromDraft,
26 buildPublishParamsFromMedia,
27 getCommonPublishTitleMax,
28 PUBLISH_DIALOG_DND_TYPE,
29 } from '@/components/PublishDialog/PublishDialog.util'
30 import { usePublishDialog } from '@/components/PublishDialog/usePublishDialog'
31 import { Button } from '@/components/ui/button'
32 import {
33 DropdownMenu,
34 DropdownMenuContent,
35 DropdownMenuItem,
36 DropdownMenuSeparator,
37 DropdownMenuTrigger,
38 } from '@/components/ui/dropdown-menu'
39 import { ScrollArea } from '@/components/ui/scroll-area'
40 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
41 import { usePlatformInfoMap } from '@/hooks/usePlatformMetadata'
42 import { useAccountStore } from '@/store/account'
43 import { getPlatformInfoSync } from '@/store/platformMetadata'
44 import { cn } from '@/utils/className'
45 import { parseTopicString } from '@/utils/common'
46 import { notification } from '@/utils/ui/notification'
47 import PublishDialogDraftPanel from './PublishDialogDraftPanel'
48 import { PublishDialogDragPreviewLayer } from './PublishDialogDragPreviewLayer'
49 import { useDesktopPublishLayout } from './useDesktopPublishLayout'
50
51 /**
52 * 外部必须传入的 props(无法从 store 获取的)
53 */
54 interface DesktopPublishContentProps {
55 // 外部回调
56 onClose: () => void
57 createLoading: boolean
58 // 发布
59 onPublish: () => void
60 }
61
62 const DRAG_UNSUPPORTED_NOTIFICATION_ID = 'publish-dialog-drag-unsupported'
63
64 function getDragItemPubType(item: PublishDialogDragItem) {
65 if (item.kind === 'media')
66 return item.media.type === 'video' ? PubType.VIDEO : PubType.ImageText
67
68 const mediaList = item.material.mediaList || []
69 if (mediaList.some(media => media.type === 'video'))
70 return PubType.VIDEO
71 if (mediaList.some(media => media.type === 'img'))
72 return PubType.ImageText
73
74 return undefined
75 }
76
77 function getPublishDropTargetItems(pubListChoosed: PubItem[], pubList: PubItem[]) {
78 return pubListChoosed.length > 0 ? pubListChoosed : pubList
79 }
80
81 function getUnsupportedTargetPlatformNames(pubItems: PubItem[], pubType: PubType) {
82 return pubItems.reduce<string[]>((names, pubItem) => {
83 const platformInfo = getPlatformInfoSync(pubItem.account.type)
84 if (platformInfo?.pubTypes.has(pubType))
85 return names
86
87 names.push(platformInfo?.name || pubItem.account.type)
88 return names
89 }, [])
90 }
91
92 /**
93 * PC端发布弹框主内容组件
94 */
95 export const DesktopPublishContent = memo(
96 ({
97 onClose,
98 createLoading,
99 onPublish,
100 }: DesktopPublishContentProps) => {
101 const { t } = useTransClient('publish')
102 const platformInfoMap = usePlatformInfoMap()
103 const {
104 containerRef,
105 layout,
106 resizeHandleWidth,
107 isDraftPanelOpen,
108 isResizing,
109 setDraftPanelOpen,
110 handleResizeStart,
111 } = useDesktopPublishLayout()
112
113 // ============ 从 Store 获取状态(不再通过 props 传递)============
114
115 const {
116 pubList,
117 pubListChoosed,
118 commonPubParams,
119 step,
120 expandedPubItem,
121 errParamsMap,
122 warningParamsMap,
123 setExpandedPubItem,
124 setStep,
125 setPubListChoosed,
126 setAccountAllParams,
127 } = usePublishDialog(
128 useShallow(state => ({
129 pubList: state.pubList,
130 pubListChoosed: state.pubListChoosed,
131 commonPubParams: state.commonPubParams,
132 step: state.step,
133 expandedPubItem: state.expandedPubItem,
134 errParamsMap: state.errParamsMap,
135 warningParamsMap: state.warningParamsMap,
136 setExpandedPubItem: state.setExpandedPubItem,
137 setStep: state.setStep,
138 setPubListChoosed: state.setPubListChoosed,
139 setAccountAllParams: state.setAccountAllParams,
140 })),
141 )
142
143 // 账户 store
144 const { accountGroupList, getAccountList, activeSpaceId, setActiveSpaceId, accountActive }
145 = useAccountStore(
146 useShallow(state => ({
147 accountGroupList: state.accountGroupList,
148 getAccountList: state.getAccountList,
149 activeSpaceId: state.activeSpaceId,
150 setActiveSpaceId: state.setActiveSpaceId,
151 accountActive: state.accountActive,
152 })),
153 )
154
155 // ============ Custom Hooks ============
156
157 // 账户点击处理
158 const { handleAccountClick } = useAccountClickHandler({
159 pubListChoosed,
160 step,
161 setStep,
162 setExpandedPubItem,
163 setPubListChoosed,
164 })
165
166 // 平台授权
167 const { handleOfflineAvatarClick } = usePlatformAuth()
168
169 // 追踪是否是用户主动切换频道(而非初始化或数据变化)
170 const isUserSwitchingSpace = useRef(false)
171 const appliedAccountActiveIdRef = useRef<string | undefined>(undefined)
172
173 // ============ Computed Values ============
174
175 // 获取当前选中的频道信息
176 const selectedSpace = useMemo(() => {
177 if (!activeSpaceId)
178 return undefined
179 return accountGroupList.find(g => g.id === activeSpaceId)
180 }, [activeSpaceId, accountGroupList])
181
182 const handlePublishDrop = useCallback(async (item: PublishDialogDragItem) => {
183 const publishDialogStore = usePublishDialog.getState()
184
185 const targetPubType = getDragItemPubType(item)
186 const targetPubItems = getPublishDropTargetItems(
187 publishDialogStore.pubListChoosed,
188 publishDialogStore.pubList,
189 )
190
191 if (targetPubType && targetPubItems.length > 0) {
192 const unsupportedPlatformNames = getUnsupportedTargetPlatformNames(targetPubItems, targetPubType)
193 if (unsupportedPlatformNames.length > 0) {
194 const allUnsupported = unsupportedPlatformNames.length === targetPubItems.length
195 const platformNames = unsupportedPlatformNames.join(', ')
196 const warningText = targetPubType === PubType.ImageText
197 ? t(allUnsupported ? 'validation.dragImageUnsupported' : 'validation.dragImagePartiallyUnsupported', { platformNames })
198 : t(allUnsupported ? 'validation.dragVideoUnsupported' : 'validation.dragVideoPartiallyUnsupported', { platformNames })
199
200 notification.warning({
201 key: DRAG_UNSUPPORTED_NOTIFICATION_ID,
202 duration: 5,
203 content: (
204 <div className="space-y-1">
205 <p className="text-sm font-semibold text-foreground">
206 {t('validation.dragUnsupportedTitle')}
207 </p>
208 <p className="text-sm font-normal text-muted-foreground">
209 {warningText}
210 </p>
211 </div>
212 ),
213 })
214
215 if (allUnsupported)
216 return
217 }
218 }
219
220 publishDialogStore.setPublishContentLoading(true)
221 try {
222 const params = item.kind === 'draft'
223 ? await buildPublishParamsFromDraft(item.material)
224 : await buildPublishParamsFromMedia(item.media, publishDialogStore.commonPubParams.images || [])
225
226 publishDialogStore.setAccountAllParams(params)
227 }
228 finally {
229 usePublishDialog.getState().setPublishContentLoading(false)
230 }
231 }, [t])
232
233 const [{ isOver, canDrop }, drop] = useDrop(
234 () => ({
235 accept: PUBLISH_DIALOG_DND_TYPE,
236 drop: (item: PublishDialogDragItem) => {
237 void handlePublishDrop(item)
238 },
239 collect: monitor => ({
240 isOver: monitor.isOver({ shallow: true }),
241 canDrop: monitor.canDrop(),
242 }),
243 }),
244 [handlePublishDrop],
245 )
246
247 // ============ Handlers ============
248
249 // 处理下一步
250 const handleNextStep = useCallback(() => {
251 setExpandedPubItem(undefined)
252 setStep(1)
253 }, [setExpandedPubItem, setStep])
254
255 // 处理错误点击,展开对应账号
256 const handleErrorAccountClick = useCallback(
257 (accountId: string) => {
258 const pubItem = pubListChoosed.find(v => v.account.id === accountId)
259 if (pubItem) {
260 if (step === 1) {
261 setExpandedPubItem(pubItem)
262 }
263 }
264 },
265 [pubListChoosed, step, setExpandedPubItem],
266 )
267
268 // 处理参数变更
269 const handleParamsChange = useCallback(
270 (values: { value?: string, imgs?: IImgFile[], video?: IVideoFile }) => {
271 const { topics } = parseTopicString(values.value || '')
272 setAccountAllParams({
273 des: values.value,
274 images: values.imgs,
275 video: values.video,
276 topics,
277 })
278 },
279 [setAccountAllParams],
280 )
281
282 const commonTitleMax = useMemo(() => {
283 return getCommonPublishTitleMax(pubListChoosed)
284 }, [platformInfoMap, pubListChoosed])
285
286 // ============ Effects ============
287
288 // 当选择单个账号时,自动选中该账号
289 useEffect(() => {
290 if (!accountActive) {
291 appliedAccountActiveIdRef.current = undefined
292 return
293 }
294
295 if (pubList.length === 0) {
296 appliedAccountActiveIdRef.current = undefined
297 return
298 }
299
300 if (appliedAccountActiveIdRef.current === accountActive.id) {
301 return
302 }
303
304 // 找到对应的 pubItem
305 const targetPubItem = pubList.find(pubItem => pubItem.account.id === accountActive.id)
306 if (targetPubItem) {
307 setPubListChoosed([targetPubItem])
308 appliedAccountActiveIdRef.current = accountActive.id
309 }
310 }, [accountActive, pubList, setPubListChoosed])
311
312 // 用户主动切换频道的处理函数
313 const handleSpaceChange = useCallback(
314 (spaceId: string | undefined) => {
315 isUserSwitchingSpace.current = true
316 setActiveSpaceId(spaceId)
317 },
318 [setActiveSpaceId],
319 )
320
321 // 当用户主动选择频道时,自动选中该频道下的所有账户
322 useEffect(() => {
323 // 只有用户主动切换频道时才执行
324 if (!isUserSwitchingSpace.current) {
325 return
326 }
327 isUserSwitchingSpace.current = false
328
329 // 如果没有选择任何频道(全部频道),选中所有账号
330 if (!activeSpaceId) {
331 if (pubList.length > 0) {
332 setPubListChoosed(pubList)
333 }
334 return
335 }
336
337 if (!accountGroupList || accountGroupList.length === 0) {
338 return
339 }
340
341 const selectedGroup = accountGroupList.find(g => g.id === activeSpaceId)
342 if (!selectedGroup) {
343 return
344 }
345
346 // 找出该频道下的所有账户
347 const channelAccountIds = new Set(selectedGroup.children.map(child => child.id))
348 const channelAccounts = pubList.filter(pubItem => channelAccountIds.has(pubItem.account.id))
349
350 // 如果频道下没有账户,回退到全部频道
351 if (channelAccounts.length === 0) {
352 setActiveSpaceId(undefined)
353 return
354 }
355
356 // 自动选中这些账户
357 setPubListChoosed(channelAccounts)
358 }, [activeSpaceId, accountGroupList, pubList, setPubListChoosed, setActiveSpaceId])
359
360 // ============ Render ============
361
362 return (
363 <div
364 ref={containerRef}
365 className="flex h-full min-h-0 w-full overflow-hidden bg-background"
366 data-testid="publish-dialog-container"
367 >
368 <PublishDialogDragPreviewLayer />
369
370 {/* 左侧 AI 内容创作区域 */}
371 <div
372 className={cn(
373 'min-h-0 shrink-0 overflow-hidden border-r border-border bg-background',
374 isResizing ? 'transition-none' : 'transition-[width,opacity] duration-300 ease-in-out',
375 isDraftPanelOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
376 )}
377 style={{ width: isDraftPanelOpen ? layout?.draftPanelWidth ?? '52%' : 0 }}
378 aria-hidden={!isDraftPanelOpen}
379 >
380 <div className="h-full min-w-[320px]">
381 <PublishDialogDraftPanel />
382 </div>
383 </div>
384
385 {/* 双栏拖拽条 */}
386 <div
387 role="separator"
388 aria-orientation="vertical"
389 className={cn(
390 'group relative shrink-0 overflow-hidden bg-border/60 transition-[width,background-color] duration-300 ease-in-out hover:bg-primary/30',
391 isResizing && 'transition-none',
392 isDraftPanelOpen ? 'cursor-col-resize' : 'cursor-default',
393 )}
394 style={{ width: isDraftPanelOpen ? resizeHandleWidth : 0 }}
395 onPointerDown={handleResizeStart}
396 >
397 <div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border transition-colors group-hover:bg-primary" />
398 </div>
399
400 {/* 右侧发布内容区域 */}
401 <div
402 ref={(node) => {
403 drop(node)
404 }}
405 className={cn(
406 'relative z-10 flex min-h-0 min-w-0 flex-1 flex-col bg-background transition-colors',
407 isOver && canDrop && 'bg-primary/5 ring-2 ring-inset ring-primary/50',
408 )}
409 onClick={() => {
410 if (step === 1) {
411 setExpandedPubItem(undefined)
412 }
413 }}
414 >
415 {isOver && canDrop && (
416 <div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-background/70 backdrop-blur-sm">
417 <div className="flex flex-col items-center gap-3 rounded-2xl border-2 border-dashed border-primary bg-card px-8 py-6 shadow-xl">
418 <UploadCloud className="h-10 w-10 text-primary" />
419 <p className="text-base font-medium text-foreground">
420 {t('upload.dropPublishContent')}
421 </p>
422 </div>
423 </div>
424 )}
425
426 <div className="box-border p-5 flex-1 min-h-0 overflow-auto">
427 {/* 头部标题和频道选择器 */}
428 <div className="flex items-center justify-between mb-5">
429 <div className="flex items-center gap-3">
430 <TooltipProvider delayDuration={200}>
431 <Tooltip>
432 <TooltipTrigger asChild>
433 <Button
434 variant="outline"
435 size="sm"
436 className="gap-1.5 shadow-sm"
437 aria-label={isDraftPanelOpen
438 ? t('layout.hideContentManagement')
439 : t('layout.showContentManagement')}
440 onClick={() => setDraftPanelOpen(!isDraftPanelOpen)}
441 >
442 {isDraftPanelOpen
443 ? <PanelLeftClose className="h-4 w-4" />
444 : <PanelLeftOpen className="h-4 w-4" />}
445 {t('layout.showContentManagement')}
446 </Button>
447 </TooltipTrigger>
448 <TooltipContent side="bottom" align="start" className="max-w-72 text-xs leading-relaxed">
449 {t('layout.contentManagementTooltip')}
450 </TooltipContent>
451 </Tooltip>
452 </TooltipProvider>
453 <span className="font-semibold text-base">{t('title')}</span>
454
455 {/* 频道选择器下拉菜单 */}
456 <DropdownMenu>
457 <DropdownMenuTrigger asChild>
458 <button
459 className={cn(
460 'flex items-center gap-2 px-2 py-1.5 rounded-md border border-border',
461 'hover:bg-muted cursor-pointer transition-colors',
462 'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
463 'min-w-[120px]',
464 )}
465 data-testid="publish-channel-selector"
466 >
467 {selectedSpace ? (
468 <>
469 <FolderOpen className="h-4 w-4 shrink-0 text-primary" />
470 <span className="flex-1 truncate text-xs text-foreground text-left">
471 {selectedSpace.name}
472 </span>
473 <ChevronDown className="h-3 w-3 text-muted-foreground shrink-0" />
474 </>
475 ) : (
476 <>
477 <Layers className="h-4 w-4 shrink-0 text-muted-foreground" />
478 <span className="flex-1 text-xs text-foreground text-left">
479 {t('allChannels')}
480 </span>
481 <ChevronDown className="h-3 w-3 text-muted-foreground shrink-0" />
482 </>
483 )}
484 </button>
485 </DropdownMenuTrigger>
486
487 <DropdownMenuContent align="start" className="w-[240px]">
488 {/* 全部频道选项 */}
489 <DropdownMenuItem
490 onClick={() => handleSpaceChange(undefined)}
491 className={cn(
492 'flex items-center gap-3 px-3 py-2.5 cursor-pointer',
493 !activeSpaceId && 'bg-accent',
494 )}
495 >
496 <Layers className="h-5 w-5 shrink-0 text-muted-foreground" />
497 <span className="flex-1 text-sm">{t('allChannels')}</span>
498 </DropdownMenuItem>
499
500 {accountGroupList.length > 0 && <DropdownMenuSeparator />}
501
502 {/* 频道列表 */}
503 <ScrollArea className="max-h-[300px]">
504 {accountGroupList.map(group => (
505 <DropdownMenuItem
506 key={group.id}
507 onClick={() => handleSpaceChange(group.id)}
508 className={cn(
509 'flex items-center gap-3 px-3 py-2.5 cursor-pointer',
510 activeSpaceId === group.id && 'bg-accent',
511 )}
512 >
513 <FolderOpen className="h-5 w-5 shrink-0 text-primary" />
514 <span className="flex-1 text-sm truncate">{group.name}</span>
515 <span className="text-xs text-muted-foreground">
516 {group.children.length}
517 </span>
518 </DropdownMenuItem>
519 ))}
520 </ScrollArea>
521 </DropdownMenuContent>
522 </DropdownMenu>
523 </div>
524
525 <Button
526 variant="outline"
527 size="icon"
528 onClick={onClose}
529 className="h-9 w-9 rounded-full bg-background shadow-sm hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive"
530 aria-label={t('layout.closeDialog')}
531 title={t('layout.closeDialog')}
532 data-testid="publish-close-button"
533 >
534 <X className="h-5 w-5" />
535 </Button>
536 </div>
537
538 {/* 账户选择器 */}
539 <AccountSelector
540 onOfflineClick={handleOfflineAvatarClick}
541 />
542
543 {/* 错误汇总 */}
544 <ErrorSummary
545 pubListChoosed={pubListChoosed}
546 errParamsMap={errParamsMap}
547 warningParamsMap={warningParamsMap}
548 onAccountClick={handleErrorAccountClick}
549 />
550
551 {/* 内容编辑区域 */}
552 <div className="mt-5">
553 {step === 0 ? (
554 <>
555 {pubListChoosed.length === 1 && (
556 <PlatParamsSetting
557 pubItem={pubListChoosed[0]}
558 />
559 )}
560 {pubListChoosed.length >= 2 && (
561 <PubParmasTextarea
562 key={`${commonPubParams.images?.length || 0}-${
563 commonPubParams.video ? 'video' : 'no-video'
564 }`}
565 platType={pubListChoosed[0].account.type}
566 rows={16}
567 desValue={commonPubParams.des}
568 videoFileValue={commonPubParams.video}
569 imageFileListValue={commonPubParams.images}
570 onChange={handleParamsChange}
571 extend={commonTitleMax !== undefined && (
572 <CommonTitleInput
573 titleValue={commonPubParams.title || ''}
574 titleMax={commonTitleMax}
575 onTitleChange={title => setAccountAllParams({ title })}
576 />
577 )}
578 />
579 )}
580 </>
581 ) : (
582 <>
583 {pubListChoosed.map(v => (
584 <PlatParamsSetting
585 key={v.account.id}
586 pubItem={v}
587 style={{ marginBottom: '12px' }}
588 />
589 ))}
590 </>
591 )}
592
593 {/* 空状态提示 */}
594 {pubList.length === 0 ? (
595 <div className="flex flex-col items-center justify-center text-center py-10 gap-3">
596 <p className="text-muted-foreground text-sm">{t('tips.noAccount')}</p>
597 <Button
598 variant="outline"
599 size="sm"
600 className="cursor-pointer"
601 onClick={() => useChannelManagerStore.getState().openConnectList()}
602 >
603 <Plus className="h-4 w-4 mr-1" />
604 {t('tips.addChannel')}
605 </Button>
606 </div>
607 ) : pubListChoosed.length === 0
608 && pubList.some(
609 v =>
610 v.params.des || v.params.video || (v.params.images && v.params.images.length > 0),
611 ) ? (
612 <div className="flex items-center justify-center text-center text-muted-foreground h-[200px]">
613 {t('tips.workSaved')}
614 </div>
615 ) : (
616 <>
617 {pubListChoosed.length === 0 && (
618 <div className="flex items-center justify-center text-center text-muted-foreground py-10">
619 {t('tips.selectAccount')}
620 </div>
621 )}
622 </>
623 )}
624 </div>
625 </div>
626
627 {/* 底部操作栏 */}
628 <PublishFooter
629 createLoading={createLoading}
630 onPublish={onPublish}
631 onNextStep={handleNextStep}
632 />
633 </div>
634
635 </div>
636 )
637 },
638 )
639
640 DesktopPublishContent.displayName = 'DesktopPublishContent'
641
642 export default DesktopPublishContent
643
643 lines Plain Text