返回 AiToEarn
MobilePublishContent.tsx
根目录 / project / aitoearn-web / src / components / PublishDialog / compoents / mobile / MobilePublishContent.tsx
1 /**
2 * MobilePublishContent - 移动端发布内容组件
3 * 专为移动端优化的发布编辑界面,无内容管理左栏
4 */
5 import type { SocialAccount } from '@/api/accounts/account.types'
6 import type { PubItem } from '@/components/PublishDialog/publishDialog.type'
7
8 import { ArrowRight, ChevronDown, FolderOpen, Layers, Plus, X } from 'lucide-react'
9 import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
10
11 import { useShallow } from 'zustand/react/shallow'
12 import { useTransClient } from '@/app/i18n/client'
13 import { useChannelManagerStore } from '@/components/ChannelManager/channelManagerStore'
14 import AvatarPlat from '@/components/common/AvatarPlat'
15 import { DouyinLaunchModal } from '@/components/PublishDialog/compoents/DouyinLaunchModal'
16 import ErrorSummary from '@/components/PublishDialog/compoents/ErrorSummary'
17 import PlatParamsSetting from '@/components/PublishDialog/compoents/PlatParamsSetting'
18 import CommonTitleInput from '@/components/PublishDialog/compoents/PlatParamsSetting/common/CommonTitleInput'
19 import PublishDatePicker from '@/components/PublishDialog/compoents/PublishDatePicker'
20 import { usePublishManageUpload } from '@/components/PublishDialog/compoents/PublishManageUpload/usePublishManageUpload'
21 import PubParmasTextarea from '@/components/PublishDialog/compoents/PubParmasTextarea'
22 import { useAccountClickHandler } from '@/components/PublishDialog/hooks/useAccountClickHandler'
23 import { useAutoPublishOnReady } from '@/components/PublishDialog/hooks/useAutoPublishOnReady'
24 import { useCloseDialog } from '@/components/PublishDialog/hooks/useCloseDialog'
25 import { usePlatformAuth } from '@/components/PublishDialog/hooks/usePlatformAuth'
26 import { usePublishActions } from '@/components/PublishDialog/hooks/usePublishActions'
27 import usePubParamsVerify from '@/components/PublishDialog/hooks/usePubParamsVerify'
28 import { useValidatedPublishTrigger } from '@/components/PublishDialog/hooks/useValidatedPublishTrigger'
29 import { getCommonPublishTitleMax } 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 { cn } from '@/utils/className'
44 import { parseTopicString } from '@/utils/common'
45
46 export interface IMobilePublishContentProps {
47 open: boolean
48 accounts: SocialAccount[]
49 onClose: () => void
50 onPubSuccess?: () => void
51 defaultAccountIds?: string[]
52 suppressAutoPublish?: boolean
53 taskIdForPublish?: string
54 materialGroupIdForPublish?: string
55 materialIdForPublish?: string
56 onPublishConfirmed?: (taskId?: string, publishRecordId?: string) => void
57 onPublishStart?: () => void
58 autoPublishOnReady?: boolean
59 createLoading: boolean
60 setCreateLoading: (loading: boolean) => void
61 setCurrentPublishTaskId: (taskId: string | undefined) => void
62 setPublishDetailVisible: (visible: boolean) => void
63 }
64
65 // 移动端发布内容组件
66 const MobilePublishContent = memo(
67 ({
68 open,
69 accounts,
70 onClose,
71 onPubSuccess,
72 suppressAutoPublish,
73 taskIdForPublish,
74 materialGroupIdForPublish,
75 materialIdForPublish,
76 onPublishConfirmed,
77 onPublishStart,
78 autoPublishOnReady,
79 createLoading,
80 setCreateLoading,
81 setCurrentPublishTaskId,
82 setPublishDetailVisible,
83 }: IMobilePublishContentProps) => {
84 const { t } = useTransClient('publish')
85 const platformInfoMap = usePlatformInfoMap()
86
87 // ============ Store Hooks ============
88
89 // 发布弹框核心状态
90 const {
91 pubListChoosed,
92 setPubListChoosed,
93 pubList,
94 setStep,
95 step,
96 setAccountAllParams,
97 commonPubParams,
98 setExpandedPubItem,
99 expandedPubItem,
100 setErrParamsMap,
101 pubTime,
102 setWarningParamsMap,
103 prefillLoading,
104 } = usePublishDialog(
105 useShallow(state => ({
106 pubListChoosed: state.pubListChoosed,
107 setPubListChoosed: state.setPubListChoosed,
108 pubList: state.pubList,
109 setStep: state.setStep,
110 step: state.step,
111 setAccountAllParams: state.setAccountAllParams,
112 commonPubParams: state.commonPubParams,
113 setExpandedPubItem: state.setExpandedPubItem,
114 expandedPubItem: state.expandedPubItem,
115 setErrParamsMap: state.setErrParamsMap,
116 setWarningParamsMap: state.setWarningParamsMap,
117 pubTime: state.pubTime,
118 prefillLoading: state.prefillLoading,
119 })),
120 )
121
122 // 账户 store
123 const { accountGroupList, getAccountList, activeSpaceId, setActiveSpaceId, accountActive }
124 = useAccountStore(
125 useShallow(state => ({
126 accountGroupList: state.accountGroupList,
127 getAccountList: state.getAccountList,
128 activeSpaceId: state.activeSpaceId,
129 setActiveSpaceId: state.setActiveSpaceId,
130 accountActive: state.accountActive,
131 })),
132 )
133
134 const commonTitleMax = useMemo(() => {
135 return getCommonPublishTitleMax(pubListChoosed)
136 }, [platformInfoMap, pubListChoosed])
137
138 // 上传管理
139 const { tasks, md5Cache } = usePublishManageUpload(
140 useShallow(state => ({
141 tasks: state.tasks,
142 md5Cache: state.md5Cache,
143 })),
144 )
145
146 // 参数校验
147 const { errParamsMap, warningParamsMap } = usePubParamsVerify(pubListChoosed)
148
149 // ============ Local State ============
150
151 const [douyinPermalink, setDouyinPermalink] = useState('')
152 const [douyinQRCodeVisible, setDouyinQRCodeVisible] = useState(false)
153
154 // ============ Computed Values ============
155
156 // 追踪是否是用户主动切换频道
157 const isUserSwitchingSpace = useRef(false)
158 const appliedAccountActiveIdRef = useRef<string | undefined>(undefined)
159
160 // 获取当前选中的频道信息
161 const selectedSpace = useMemo(() => {
162 if (!activeSpaceId)
163 return undefined
164 return accountGroupList.find(g => g.id === activeSpaceId)
165 }, [activeSpaceId, accountGroupList])
166
167 // ============ Custom Hooks ============
168
169 // 关闭弹窗确认
170 const { closeDialog } = useCloseDialog({ onClose, t })
171
172 // 平台授权
173 const { handleOfflineAvatarClick } = usePlatformAuth()
174
175 // 账户点击处理
176 const { handleAccountClick } = useAccountClickHandler({
177 pubListChoosed,
178 step,
179 setStep,
180 setExpandedPubItem,
181 setPubListChoosed,
182 })
183
184 // 发布操作
185 const { pubClick } = usePublishActions({
186 pubListChoosed,
187 pubTime,
188 suppressAutoPublish,
189 taskIdForPublish,
190 materialGroupIdForPublish,
191 materialIdForPublish,
192 onPublishConfirmed,
193 onPublishStart,
194 onClose,
195 onPubSuccess,
196 setCreateLoading,
197 setCurrentPublishTaskId,
198 setPublishDetailVisible,
199 t,
200 })
201 const { triggerPublish } = useValidatedPublishTrigger(pubClick)
202
203 useAutoPublishOnReady({
204 enabled: autoPublishOnReady,
205 open,
206 ready: !prefillLoading && !createLoading,
207 selectedCount: pubListChoosed.length,
208 triggerPublish,
209 })
210
211 // ============ Callbacks ============
212
213 // 处理账户项点击(包装 handleAccountClick,添加离线检查)
214 const handleAccountItemClick = useCallback(
215 (pubItem: PubItem, isOffline: boolean) => {
216 if (isOffline) {
217 return
218 }
219 handleAccountClick(pubItem)
220 },
221 [handleAccountClick],
222 )
223
224 // ============ Effects ============
225
226 // 当选择单个账号时,自动选中该账号
227 useEffect(() => {
228 if (!accountActive) {
229 appliedAccountActiveIdRef.current = undefined
230 return
231 }
232
233 if (pubList.length === 0) {
234 appliedAccountActiveIdRef.current = undefined
235 return
236 }
237
238 if (appliedAccountActiveIdRef.current === accountActive.id) {
239 return
240 }
241
242 // 找到对应的 pubItem
243 const targetPubItem = pubList.find(pubItem => pubItem.account.id === accountActive.id)
244 if (targetPubItem) {
245 setPubListChoosed([targetPubItem])
246 appliedAccountActiveIdRef.current = accountActive.id
247 }
248 }, [accountActive, pubList, setPubListChoosed])
249
250 // 用户主动切换频道的处理函数
251 const handleSpaceChange = useCallback(
252 (spaceId: string | undefined) => {
253 isUserSwitchingSpace.current = true
254 setActiveSpaceId(spaceId)
255 },
256 [setActiveSpaceId],
257 )
258
259 // 当用户主动选择频道时,自动选中该频道下的所有账户
260 useEffect(() => {
261 // 只有用户主动切换频道时才执行
262 if (!isUserSwitchingSpace.current) {
263 return
264 }
265 isUserSwitchingSpace.current = false
266
267 if (!activeSpaceId || !accountGroupList || accountGroupList.length === 0) {
268 return
269 }
270
271 const selectedGroup = accountGroupList.find(g => g.id === activeSpaceId)
272 if (!selectedGroup) {
273 return
274 }
275
276 // 找出该频道下的所有账户
277 const channelAccountIds = new Set(selectedGroup.children.map(child => child.id))
278 const channelAccounts = pubList.filter(pubItem => channelAccountIds.has(pubItem.account.id))
279
280 // 自动选中这些账户
281 if (channelAccounts.length > 0) {
282 setPubListChoosed(channelAccounts)
283 }
284 }, [activeSpaceId, accountGroupList, pubList, setPubListChoosed])
285
286 // ============ Render ============
287
288 return (
289 <div className="flex flex-col h-full bg-background overflow-hidden p-3">
290 {/* 头部 */}
291 <div className="flex flex-col px-4 py-3 border-b border-border flex-shrink-0 gap-3">
292 <div className="flex items-center justify-between">
293 <span className="font-semibold text-base">{t('title')}</span>
294 <X
295 onClick={closeDialog}
296 className="h-5 w-5 cursor-pointer text-muted-foreground hover:text-foreground transition-colors"
297 />
298 </div>
299
300 {/* 频道选择器下拉菜单 */}
301 <DropdownMenu>
302 <DropdownMenuTrigger asChild>
303 <button
304 className={cn(
305 'flex items-center gap-2 px-3 py-2 rounded-md border border-border',
306 'hover:bg-muted cursor-pointer transition-colors',
307 'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
308 'w-full',
309 )}
310 >
311 {selectedSpace ? (
312 <>
313 <FolderOpen className="h-4 w-4 shrink-0 text-primary" />
314 <span className="flex-1 truncate text-sm text-foreground text-left">
315 {selectedSpace.name}
316 </span>
317 <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
318 </>
319 ) : (
320 <>
321 <Layers className="h-4 w-4 shrink-0 text-muted-foreground" />
322 <span className="flex-1 text-sm text-foreground text-left">
323 {t('allChannels')}
324 </span>
325 <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
326 </>
327 )}
328 </button>
329 </DropdownMenuTrigger>
330
331 <DropdownMenuContent align="start" className="w-[calc(100vw-2rem)]">
332 {/* 全部频道选项 */}
333 <DropdownMenuItem
334 onClick={() => handleSpaceChange(undefined)}
335 className={cn(
336 'flex items-center gap-3 px-3 py-3 cursor-pointer',
337 !activeSpaceId && 'bg-accent',
338 )}
339 >
340 <Layers className="h-5 w-5 shrink-0 text-muted-foreground" />
341 <span className="flex-1 text-sm">{t('allChannels')}</span>
342 </DropdownMenuItem>
343
344 {accountGroupList.length > 0 && <DropdownMenuSeparator />}
345
346 {/* 频道列表 */}
347 <ScrollArea className="max-h-[300px]">
348 {accountGroupList.map(group => (
349 <DropdownMenuItem
350 key={group.id}
351 onClick={() => handleSpaceChange(group.id)}
352 className={cn(
353 'flex items-center gap-3 px-3 py-3 cursor-pointer',
354 activeSpaceId === group.id && 'bg-accent',
355 )}
356 >
357 <FolderOpen className="h-5 w-5 shrink-0 text-primary" />
358 <span className="flex-1 text-sm truncate">{group.name}</span>
359 <span className="text-xs text-muted-foreground">{group.children.length}</span>
360 </DropdownMenuItem>
361 ))}
362 </ScrollArea>
363 </DropdownMenuContent>
364 </DropdownMenu>
365 </div>
366
367 {/* 内容区域 */}
368 <div className="flex-1 overflow-auto p-4">
369 {/* 账号选择区域 */}
370 <div className="flex flex-wrap gap-2 mb-4">
371 {pubList.map((pubItem) => {
372 const platConfig = platformInfoMap.get(pubItem.account.type)
373 if (!platConfig)
374 return null
375 const isChoosed = pubListChoosed.find(v => v.account.id === pubItem.account.id)
376 const isOffline = pubItem.account.status === 0
377
378 return (
379 <TooltipProvider key={pubItem.account.id}>
380 <Tooltip>
381 <TooltipTrigger asChild>
382 <div
383 className={`border-2 rounded-full transition-all duration-300 ${
384 isChoosed ? '' : 'border-transparent'
385 } ${isChoosed ? 'active:border-primary' : ''}`}
386 style={{
387 borderColor: isChoosed ? platConfig.themeColor : 'transparent',
388 }}
389 onClick={(e) => {
390 e.stopPropagation()
391 handleAccountItemClick(pubItem, isOffline)
392 }}
393 >
394 <div className="relative">
395 <AvatarPlat
396 className={`cursor-pointer transition-all duration-300 p-[1px] ${
397 isChoosed && !isOffline
398 ? '[&>img]:grayscale-0'
399 : '[&>img]:grayscale hover:[&>img]:grayscale-0'
400 }`}
401 account={pubItem.account}
402 size="large"
403 disabled={isOffline || !isChoosed}
404 />
405 {isOffline && (
406 <div
407 onClick={(e) => {
408 e.stopPropagation()
409 handleOfflineAvatarClick(pubItem.account)
410 }}
411 className="absolute inset-0 bg-black/45 rounded-full flex items-center justify-center text-white text-xs font-semibold cursor-pointer"
412 >
413 {t('badges.offline')}
414 </div>
415 )}
416 </div>
417 </div>
418 </TooltipTrigger>
419 {isOffline && (
420 <TooltipContent>
421 {t('tips.accountOffline')}
422 </TooltipContent>
423 )}
424 </Tooltip>
425 </TooltipProvider>
426 )
427 })}
428 </div>
429
430 {/* 错误汇总组件 - 展示所有账号的校验错误和警告 */}
431 <ErrorSummary
432 pubListChoosed={pubListChoosed}
433 errParamsMap={errParamsMap}
434 warningParamsMap={warningParamsMap}
435 onAccountClick={(accountId) => {
436 const pubItem = pubListChoosed.find(v => v.account.id === accountId)
437 if (pubItem) {
438 // 如果是多账号模式(step=1),展开对应账号设置
439 if (step === 1) {
440 setExpandedPubItem(pubItem)
441 }
442 }
443 }}
444 />
445
446 {/* 编辑区域 */}
447 <div className="mt-4">
448 {step === 0 ? (
449 <>
450 {pubListChoosed.length === 1 && (
451 <PlatParamsSetting
452 pubItem={pubListChoosed[0]}
453 isMobile
454 />
455 )}
456 {pubListChoosed.length >= 2 && (
457 <PubParmasTextarea
458 key={`${commonPubParams.images?.length || 0}-${commonPubParams.video ? 'video' : 'no-video'}`}
459 platType={pubListChoosed[0].account.type}
460 rows={10}
461 desValue={commonPubParams.des}
462 videoFileValue={commonPubParams.video}
463 imageFileListValue={commonPubParams.images}
464 onChange={(values) => {
465 const { topics } = parseTopicString(values.value || '')
466 setAccountAllParams({
467 des: values.value,
468 images: values.imgs,
469 video: values.video,
470 topics,
471 })
472 }}
473 extend={commonTitleMax !== undefined && (
474 <CommonTitleInput
475 titleValue={commonPubParams.title || ''}
476 titleMax={commonTitleMax}
477 onTitleChange={title => setAccountAllParams({ title })}
478 isMobile
479 />
480 )}
481 isMobile
482 />
483 )}
484 </>
485 ) : (
486 <>
487 {pubListChoosed.map(v => (
488 <PlatParamsSetting
489 key={v.account.id}
490 pubItem={v}
491 style={{ marginBottom: '12px' }}
492 isMobile
493 />
494 ))}
495 </>
496 )}
497
498 {pubList.length === 0 ? (
499 <div className="flex flex-col items-center justify-center text-center py-10 gap-3">
500 <p className="text-muted-foreground text-sm">{t('tips.noAccount')}</p>
501 <Button
502 variant="outline"
503 size="sm"
504 className="cursor-pointer"
505 onClick={() => useChannelManagerStore.getState().openConnectList()}
506 >
507 <Plus className="h-4 w-4 mr-1" />
508 {t('tips.addChannel')}
509 </Button>
510 </div>
511 ) : pubListChoosed.length === 0
512 && pubList.some(
513 v =>
514 v.params.des || v.params.video || (v.params.images && v.params.images.length > 0),
515 ) ? (
516 <div className="flex items-center justify-center text-center text-muted-foreground h-[150px]">
517 {t('tips.workSaved')}
518 </div>
519 ) : (
520 <>
521 {pubListChoosed.length === 0 && (
522 <div className="flex items-center justify-center text-center text-muted-foreground py-10">
523 {t('tips.selectAccount')}
524 </div>
525 )}
526 </>
527 )}
528 </div>
529 </div>
530
531 {/* 底部操作栏 */}
532 <div className="flex items-center border-t border-border justify-between px-4 py-3 flex-shrink-0 bg-background">
533 <div className="flex w-full justify-end gap-3">
534 {step === 0 && pubListChoosed.length >= 2 ? (
535 <Button
536 size="lg"
537 variant="outline"
538 onClick={() => {
539 setExpandedPubItem(undefined)
540 setStep(1)
541 }}
542 className="gap-2 cursor-pointer flex-1"
543 >
544 {t('buttons.customizePerAccount')}
545 <ArrowRight className="h-4 w-4" />
546 </Button>
547 ) : (
548 <div className="flex-1">
549 <PublishDatePicker
550 loading={createLoading}
551 onClick={triggerPublish}
552 isMobile
553 />
554 </div>
555 )}
556 </div>
557 </div>
558
559 {/* 移动端抖音唤起引导弹窗 */}
560 {douyinQRCodeVisible && (
561 <DouyinLaunchModal
562 open={douyinQRCodeVisible}
563 permalink={douyinPermalink}
564 onClose={() => setDouyinQRCodeVisible(false)}
565 />
566 )}
567 </div>
568 )
569 },
570 )
571
572 MobilePublishContent.displayName = 'MobilePublishContent'
573
574 export default MobilePublishContent
575
575 lines Plain Text