返回 AiToEarn
RecordCore.tsx
1 /**
2 * RecordCore 组件
3 *
4 * 功能描述: 发布记录详情组件
5 * - 桌面端:使用 Popover 显示详情
6 * - 移动端:使用全屏 Dialog 显示详情
7 */
8
9 import type { LucideIcon } from 'lucide-react'
10 import type { ForwardedRef } from 'react'
11 import type { ChannelPublishUserActionVo, ChannelWorkMetricsSnapshot } from '@/api/channels/channel.types'
12 import type { PublishRecordEngagement, PublishRecordItem } from '@/api/platforms/publish.types'
13 import type { PlatformPublishTask } from '@/store/plugin/types/baseTypes'
14 import dayjs from 'dayjs'
15 import {
16 Calendar,
17 CalendarClock,
18 CheckCircle2,
19 CircleOff,
20 Clock,
21 ExternalLink,
22 Eye,
23 Heart,
24 Info,
25 Loader2,
26 MessageCircle,
27 MoreVertical,
28 RotateCcw,
29 Send,
30 Share2,
31 Trash2,
32 XCircle,
33 } from 'lucide-react'
34 import Image from 'next/image'
35 import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
36 import { useShallow } from 'zustand/react/shallow'
37 import {
38 cancelChannelPublishTaskApi,
39 deleteChannelPublishRecordApi,
40 getChannelPublishUserActionApi,
41 publishChannelTaskNowApi,
42 retryChannelPublishTaskApi,
43 updateChannelPublishAtApi,
44 } from '@/api/channels/channel.api'
45 import { PublishStatus } from '@/api/platforms/publish.constants'
46 import { ClientType } from '@/app/[lng]/accounts/accounts.enums'
47 import {
48 canCancelPublishRecord,
49 canDeletePublishRecord,
50 canPublishRecordNow,
51 canReschedulePublishRecord,
52 canRetryPublishRecord,
53 getDays,
54 getPublishRecordTaskId,
55 getUtcDays,
56 } from '@/app/[lng]/accounts/components/CalendarTiming/calendarTiming.utils'
57 import { useCalendarTiming } from '@/app/[lng]/accounts/components/CalendarTiming/useCalendarTiming'
58 import { useAccountsWorkAnalyticsCacheStore } from '@/app/[lng]/accounts/workAnalyticsCacheStore'
59 import { PlatType } from '@/app/config/platConfig'
60 import { useTransClient } from '@/app/i18n/client'
61 import AvatarPlat from '@/components/common/AvatarPlat'
62 import { MediaPreview } from '@/components/common/MediaPreview'
63 import { OssImage } from '@/components/common/OssImage'
64 import { PublishDetailModal } from '@/components/Plugin/PublishDetailModal'
65 import PublishDatePicker from '@/components/PublishDialog/compoents/PublishDatePicker'
66 import { Badge } from '@/components/ui/badge'
67 import { Button } from '@/components/ui/button'
68 import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
69 import {
70 DropdownMenu,
71 DropdownMenuContent,
72 DropdownMenuItem,
73 DropdownMenuTrigger,
74 } from '@/components/ui/dropdown-menu'
75 import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
76 import { Skeleton } from '@/components/ui/skeleton'
77 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
78 import { useIsMobile } from '@/hooks/useIsMobile'
79 import { useAccountStore } from '@/store/account'
80 import { getPlatformInfoSync } from '@/store/platformMetadata'
81 import { usePluginStore } from '@/store/plugin'
82 import { PlatformTaskStatus } from '@/store/plugin/types/baseTypes'
83 import { useSystemStore } from '@/store/system'
84 import { cn } from '@/utils/className'
85 import { getOssUrl } from '@/utils/oss'
86 import { confirm } from '@/utils/ui/confirm'
87 import { toast } from '@/utils/ui/toast'
88 import ScrollButtonContainer from './ScrollButtonContainer'
89
90 export interface IRecordCoreRef {
91 closeDetail: () => void
92 }
93
94 export interface IRecordCoreProps {
95 publishRecord: PublishRecordItem
96 dragPreviewWidth?: number
97 }
98
99 interface PublishStatusMeta {
100 labelKey: string
101 icon: LucideIcon
102 className?: string
103 spin?: boolean
104 destructive?: boolean
105 }
106
107 const statusBadgeClassName = {
108 info: 'border-info/25 bg-info/10 text-info',
109 success: 'border-success/25 bg-success/10 text-success',
110 warning: 'border-warning/25 bg-warning/10 text-warning',
111 muted: 'bg-muted text-muted-foreground border-border',
112 }
113
114 const cancelPublishMenuItemClassName = 'cursor-pointer text-warning focus:bg-warning/10 focus:text-warning'
115 const deleteRecordMenuItemClassName = 'cursor-pointer text-destructive focus:bg-destructive/10 focus:text-destructive'
116
117 function hasDouyinUserAction(
118 data: ChannelPublishUserActionVo | null | undefined,
119 ): data is ChannelPublishUserActionVo & { schemeUrl: string, shortLink: string } {
120 return !!data?.schemeUrl && !!data.shortLink
121 }
122
123 function buildUserActionPlatformTask(
124 record: PublishRecordItem,
125 publishRecordId: string,
126 userAction: ChannelPublishUserActionVo & { schemeUrl: string, shortLink: string },
127 ): PlatformPublishTask {
128 return {
129 id: `record-user-action-${publishRecordId}`,
130 platform: record.accountType,
131 accountId: record.accountId,
132 publishMode: 'user_action',
133 publishRecordId,
134 userAction: {
135 schemeUrl: userAction.schemeUrl,
136 shortLink: userAction.shortLink,
137 expiresAt: userAction.expiresAt,
138 },
139 params: {
140 type: record.videoUrl ? 'video' : 'image',
141 title: record.title,
142 desc: record.desc,
143 accountId: record.accountId,
144 platform: record.accountType,
145 video: record.videoUrl,
146 cover: record.coverUrl,
147 images: record.imgUrlList,
148 topics: record.topics,
149 scheduledTime: getDays(record.publishTime).valueOf(),
150 platformConfig: record.option,
151 },
152 status: PlatformTaskStatus.PENDING,
153 progress: null,
154 result: null,
155 startTime: null,
156 endTime: Date.now(),
157 error: null,
158 }
159 }
160
161 function normalizeWorkMetrics(metrics?: ChannelWorkMetricsSnapshot): PublishRecordEngagement | undefined {
162 if (!metrics || !Object.values(metrics).some(value => typeof value === 'number' && Number.isFinite(value))) {
163 return undefined
164 }
165 return {
166 viewCount: metrics.viewCount ?? metrics.playCount ?? 0,
167 commentCount: metrics.commentCount ?? 0,
168 likeCount: metrics.likeCount ?? 0,
169 shareCount: metrics.shareCount ?? 0,
170 clickCount: metrics.clickCount ?? 0,
171 impressionCount: metrics.impressionCount ?? 0,
172 favoriteCount: metrics.collectCount ?? metrics.saveCount ?? 0,
173 }
174 }
175
176 const publishStatusMetaMap: Partial<Record<PublishStatus, PublishStatusMeta>> = {
177 [PublishStatus.FAIL]: {
178 labelKey: 'status.publishFailed',
179 icon: XCircle,
180 destructive: true,
181 },
182 [PublishStatus.PUB_LOADING]: {
183 labelKey: 'status.publishing',
184 icon: Loader2,
185 className: statusBadgeClassName.info,
186 spin: true,
187 },
188 [PublishStatus.RELEASED]: {
189 labelKey: 'status.publishSuccess',
190 icon: CheckCircle2,
191 className: statusBadgeClassName.success,
192 },
193 [PublishStatus.UNPUBLISH]: {
194 labelKey: 'status.waitingPublish',
195 icon: Clock,
196 className: statusBadgeClassName.muted,
197 },
198 [PublishStatus.WAITING_FOR_UPDATE]: {
199 labelKey: 'status.waitingUpdate',
200 icon: Clock,
201 className: statusBadgeClassName.muted,
202 },
203 [PublishStatus.UPDATING]: {
204 labelKey: 'status.updating',
205 icon: Loader2,
206 className: statusBadgeClassName.info,
207 spin: true,
208 },
209 [PublishStatus.UPDATED_FAILED]: {
210 labelKey: 'status.updateFailed',
211 icon: XCircle,
212 destructive: true,
213 },
214 [PublishStatus.QUEUED]: {
215 labelKey: 'status.queued',
216 icon: Clock,
217 className: statusBadgeClassName.warning,
218 },
219 [PublishStatus.PLATFORM_SCHEDULED]: {
220 labelKey: 'status.platformScheduled',
221 icon: Calendar,
222 className: statusBadgeClassName.muted,
223 },
224 [PublishStatus.WAITING_FOR_USER_ACTION]: {
225 labelKey: 'status.waitingUserAction',
226 icon: Clock,
227 className: statusBadgeClassName.warning,
228 },
229 [PublishStatus.CANCELED]: {
230 labelKey: 'status.canceled',
231 icon: XCircle,
232 className: statusBadgeClassName.muted,
233 },
234 }
235
236 // 发布状态组件
237 function PubStatus({ status }: { status: PublishStatus }) {
238 const { t } = useTransClient('publish')
239 const meta = publishStatusMetaMap[status] ?? {
240 labelKey: 'status.unknown',
241 icon: Clock,
242 className: statusBadgeClassName.muted,
243 }
244 const Icon = meta.icon
245
246 return (
247 <div className="inline-flex items-center">
248 <Badge
249 variant={meta.destructive ? 'destructive' : 'secondary'}
250 className={cn('gap-1.5', meta.className)}
251 >
252 {t(meta.labelKey)}
253 <Icon className={cn('h-3 w-3', meta.spin && 'animate-spin')} />
254 </Badge>
255 </div>
256 )
257 }
258
259 const RecordCore = memo(
260 forwardRef(({ publishRecord, dragPreviewWidth }: IRecordCoreProps, ref: ForwardedRef<IRecordCoreRef>) => {
261 const isMobile = useIsMobile()
262 const { calendarCallWidth, refreshCurrentPubRecords, refreshPubRecordDetail, removePubRecord } = useCalendarTiming(
263 useShallow(state => ({
264 calendarCallWidth: state.calendarCallWidth,
265 refreshCurrentPubRecords: state.refreshCurrentPubRecords,
266 refreshPubRecordDetail: state.refreshPubRecordDetail,
267 removePubRecord: state.removePubRecord,
268 })),
269 )
270 const { calendarViewType } = useSystemStore(
271 useShallow(state => ({
272 calendarViewType: state.calendarViewType,
273 })),
274 )
275 const { accountMap } = useAccountStore(
276 useShallow(state => ({
277 accountMap: state.accountMap,
278 })),
279 )
280 const [popoverOpen, setPopoverOpen] = useState(false)
281 const { t } = useTransClient('publish')
282 const [nowPubLoading, setNowPubLoading] = useState(false)
283 const [retryLoading, setRetryLoading] = useState(false)
284 const [rescheduleDialogOpen, setRescheduleDialogOpen] = useState(false)
285 const [rescheduleLoading, setRescheduleLoading] = useState(false)
286 const [reschedulePubTime, setReschedulePubTime] = useState<string>()
287 const [mediaPreviewOpen, setMediaPreviewOpen] = useState(false)
288 const [mediaPreviewIndex, setMediaPreviewIndex] = useState(0)
289 const [workEngagement, setWorkEngagement] = useState<PublishRecordEngagement | null>()
290 const [workMetricsFetchedAt, setWorkMetricsFetchedAt] = useState<string | null>()
291 const [workMetricsLoading, setWorkMetricsLoading] = useState(false)
292 const [continuePublishLoading, setContinuePublishLoading] = useState(false)
293 const [publishDetailTaskId, setPublishDetailTaskId] = useState<string>()
294 const workMetricsRequestIdRef = useRef(0)
295 const activeRecord = publishRecord
296 const activeEngagement = activeRecord.status === PublishStatus.RELEASED
297 ? (workEngagement === undefined ? activeRecord.engagement : workEngagement)
298 : null
299
300 useEffect(() => {
301 workMetricsRequestIdRef.current += 1
302 setWorkEngagement(undefined)
303 setWorkMetricsFetchedAt(undefined)
304 setWorkMetricsLoading(false)
305 }, [publishRecord.id])
306
307 const loadWorkMetrics = useCallback(async (record: PublishRecordItem, requestId: number) => {
308 if (record.status !== PublishStatus.RELEASED || !record.platformWorkId || !record.accountId) {
309 setWorkEngagement(null)
310 setWorkMetricsFetchedAt(null)
311 return
312 }
313
314 const cacheStore = useAccountsWorkAnalyticsCacheStore.getState()
315 const cachedAnalytics = cacheStore.getAnalytics(record.accountType, record.platformWorkId, record.accountId)
316 if (cachedAnalytics) {
317 if (workMetricsRequestIdRef.current === requestId) {
318 setWorkEngagement(normalizeWorkMetrics(cachedAnalytics.metrics) ?? null)
319 setWorkMetricsFetchedAt(cachedAnalytics.fetchedAt ?? null)
320 }
321 return
322 }
323
324 setWorkMetricsLoading(true)
325 try {
326 const data = await cacheStore.fetchAnalytics(record.accountType, record.platformWorkId, record.accountId)
327 if (workMetricsRequestIdRef.current === requestId) {
328 setWorkEngagement(normalizeWorkMetrics(data?.metrics) ?? null)
329 setWorkMetricsFetchedAt(data?.fetchedAt ?? null)
330 }
331 }
332 catch {
333 if (workMetricsRequestIdRef.current === requestId) {
334 setWorkEngagement(null)
335 setWorkMetricsFetchedAt(null)
336 }
337 }
338 finally {
339 if (workMetricsRequestIdRef.current === requestId) {
340 setWorkMetricsLoading(false)
341 }
342 }
343 }, [])
344
345 const handlePopoverOpenChange = useCallback((open: boolean) => {
346 setPopoverOpen(open)
347 if (open) {
348 const requestId = workMetricsRequestIdRef.current + 1
349 workMetricsRequestIdRef.current = requestId
350 setWorkEngagement(undefined)
351 setWorkMetricsFetchedAt(undefined)
352 void loadWorkMetrics(publishRecord, requestId)
353 }
354 else {
355 workMetricsRequestIdRef.current += 1
356 setWorkMetricsLoading(false)
357 }
358 }, [loadWorkMetrics, publishRecord])
359
360 useImperativeHandle(ref, () => ({
361 closeDetail: () => {
362 handlePopoverOpenChange(false)
363 setRescheduleDialogOpen(false)
364 },
365 }))
366
367 /**
368 * 取消发布按钮显示逻辑
369 */
370 const shouldShowCancelPublish = useMemo(() => {
371 return canCancelPublishRecord(activeRecord.status)
372 }, [activeRecord.status])
373
374 const days = useMemo(() => {
375 return getDays(activeRecord.publishTime)
376 }, [activeRecord])
377
378 const account = useMemo(() => {
379 return accountMap.get(activeRecord?.accountId ?? '')
380 }, [accountMap, activeRecord.accountId])
381
382 const platIcon = useMemo(() => {
383 return getPlatformInfoSync(activeRecord?.accountType ?? PlatType.Xhs)?.icon
384 }, [activeRecord])
385
386 const isWxSphRecord = activeRecord.accountType === PlatType.WxSph
387
388 const getClientTypeLabel = (clientType?: ClientType) => {
389 if (!clientType)
390 return null
391 if (clientType === ClientType.WEB) {
392 return t('clientType.web')
393 }
394 if (clientType === ClientType.APP) {
395 return t('clientType.app')
396 }
397 return null
398 }
399
400 const recordInfo = useMemo(() => {
401 return [
402 {
403 label: t('record.metrics.views'),
404 icon: <Eye className="h-3.5 w-3.5 md:h-4 md:w-4" />,
405 key: 'viewCount',
406 },
407 {
408 label: t('record.metrics.comments'),
409 icon: <MessageCircle className="h-3.5 w-3.5 md:h-4 md:w-4" />,
410 key: 'commentCount',
411 },
412 {
413 label: t('record.metrics.likes'),
414 icon: <Heart className="h-3.5 w-3.5 md:h-4 md:w-4" />,
415 key: 'likeCount',
416 },
417 {
418 label: t('record.metrics.shares'),
419 icon: <Share2 className="h-3.5 w-3.5 md:h-4 md:w-4" />,
420 key: 'shareCount',
421 },
422 ]
423 }, [t])
424
425 const desc = useMemo(() => {
426 return `${activeRecord.desc} ${activeRecord.topics ? activeRecord.topics?.map(v => `#${v}`).join(' ') : ''}`
427 }, [activeRecord])
428 const coverThumbnailSrc = activeRecord.coverUrl || activeRecord.imgUrlList?.[0] || ''
429
430 const mediaPreviewItems = useMemo(() => {
431 const items: Array<{ type: 'image' | 'video', src: string }> = []
432
433 if (activeRecord.videoUrl) {
434 items.push({
435 type: 'video',
436 src: getOssUrl(activeRecord.videoUrl),
437 })
438 }
439
440 if (activeRecord.imgUrlList && activeRecord.imgUrlList.length > 0) {
441 activeRecord.imgUrlList.forEach((imgUrl) => {
442 items.push({
443 type: 'image',
444 src: getOssUrl(imgUrl),
445 })
446 })
447 }
448
449 if (items.length === 0 && activeRecord.coverUrl) {
450 items.push({
451 type: 'image',
452 src: getOssUrl(activeRecord.coverUrl),
453 })
454 }
455
456 return items
457 }, [activeRecord])
458
459 const handleCoverClick = (e: React.MouseEvent) => {
460 e.stopPropagation()
461 e.preventDefault()
462 if (mediaPreviewItems.length > 0) {
463 setMediaPreviewIndex(0)
464 setMediaPreviewOpen(true)
465 }
466 }
467
468 const handleCoverMouseDown = (e: React.MouseEvent) => {
469 e.stopPropagation()
470 }
471
472 const handleViewWork = () => {
473 if (!activeRecord.workLink)
474 return
475
476 window.open(activeRecord.workLink, '_blank')
477 }
478
479 const handleCopyWorkLink = async () => {
480 if (!activeRecord.workLink)
481 return
482
483 await navigator.clipboard.writeText(activeRecord.workLink)
484 }
485
486 const shouldShowViewWork = !!activeRecord.workLink
487 const shouldShowDeleteRecord = canDeletePublishRecord(activeRecord.status)
488 const shouldShowRetryPublish = canRetryPublishRecord(activeRecord.status)
489 const shouldShowReschedulePublish = canReschedulePublishRecord(activeRecord.status, activeRecord.publishTime)
490 const shouldShowContinueUserActionPublish
491 = activeRecord.accountType === PlatType.Douyin && activeRecord.status === PublishStatus.WAITING_FOR_USER_ACTION
492 const shouldShowMoreActions
493 = shouldShowViewWork || shouldShowCancelPublish || shouldShowDeleteRecord || shouldShowReschedulePublish
494 const shouldShowWxSphReviewPending
495 = isWxSphRecord && activeRecord.status === PublishStatus.RELEASED && !activeRecord.workLink
496 const wxSphReviewPendingTooltip = activeRecord.linkError || t('record.wxSphReviewPendingDesc')
497 const handleCancelPublish = async () => {
498 await confirm({
499 title: t('record.cancelPublishConfirmTitle'),
500 content: t('record.cancelPublishConfirmContent'),
501 okText: t('buttons.cancelPublish'),
502 icon: <CircleOff className="h-4 w-4 text-warning" />,
503 onOk: async () => {
504 setPopoverOpen(false)
505 const res = await cancelChannelPublishTaskApi(getPublishRecordTaskId(activeRecord))
506 if (!res || res.code !== 0) {
507 return
508 }
509 await refreshPubRecordDetail(activeRecord.id)
510 toast.success(t('record.cancelPublishSuccess'))
511 },
512 })
513 }
514
515 const handleDeleteRecord = async () => {
516 if (!canDeletePublishRecord(activeRecord.status)) {
517 return
518 }
519
520 await confirm({
521 title: t('record.deleteConfirmTitle'),
522 content: t('record.deleteConfirmContent'),
523 okText: t('buttons.delete'),
524 okType: 'destructive',
525 onOk: async () => {
526 setPopoverOpen(false)
527 const res = await deleteChannelPublishRecordApi(activeRecord.id)
528 if (!res || res.code !== 0) {
529 return
530 }
531 toast.success(t('record.deleteSuccess'))
532 removePubRecord(activeRecord.id)
533 },
534 })
535 }
536
537 const handleOpenRescheduleDialog = () => {
538 setReschedulePubTime(getDays(activeRecord.publishTime).format())
539 setRescheduleDialogOpen(true)
540 }
541
542 const handleReschedulePublish = async (nextPubTime?: string) => {
543 if (!nextPubTime || !canReschedulePublishRecord(activeRecord.status, activeRecord.publishTime)) {
544 return
545 }
546
547 setRescheduleLoading(true)
548 try {
549 const res = await updateChannelPublishAtApi(
550 getPublishRecordTaskId(activeRecord),
551 getUtcDays(nextPubTime).format(),
552 )
553 if (!res || res.code !== 0) {
554 return
555 }
556
557 setRescheduleDialogOpen(false)
558 setPopoverOpen(false)
559 await refreshPubRecordDetail(activeRecord.id)
560 toast.success(t('record.rescheduleSuccess'))
561 }
562 finally {
563 setRescheduleLoading(false)
564 }
565 }
566
567 const handlePublishNow = async () => {
568 setNowPubLoading(true)
569 try {
570 const res = await publishChannelTaskNowApi(getPublishRecordTaskId(activeRecord))
571 if (!res || res.code !== 0) {
572 return
573 }
574 await refreshPubRecordDetail(activeRecord.id)
575 toast.success(t('record.publishNowSuccess'))
576 }
577 finally {
578 setNowPubLoading(false)
579 }
580 }
581
582 const handleContinueUserActionPublish = async () => {
583 if (!shouldShowContinueUserActionPublish)
584 return
585
586 setContinuePublishLoading(true)
587 try {
588 const existingTask = usePluginStore.getState().publishTasks.find(task => task.platformTasks.some(platformTask => (
589 platformTask.publishMode === 'user_action'
590 && platformTask.publishRecordId === activeRecord.id
591 && platformTask.status === PlatformTaskStatus.PENDING
592 )))
593
594 if (existingTask) {
595 setPublishDetailTaskId(existingTask.id)
596 setPopoverOpen(false)
597 return
598 }
599
600 const res = await getChannelPublishUserActionApi(activeRecord.id)
601 if (res?.code !== 0 || !hasDouyinUserAction(res.data)) {
602 toast.error(res?.message || t('messages.userActionFetchFailed'))
603 return
604 }
605
606 const publishRecordId = res.data.recordId || activeRecord.id
607 const taskId = usePluginStore.getState().addPublishTask({
608 title: activeRecord.title,
609 description: activeRecord.desc,
610 platformTasks: [buildUserActionPlatformTask(activeRecord, publishRecordId, res.data)],
611 })
612 setPublishDetailTaskId(taskId)
613 setPopoverOpen(false)
614 }
615 finally {
616 setContinuePublishLoading(false)
617 }
618 }
619
620 const handleRetryPublish = async () => {
621 if (!canRetryPublishRecord(activeRecord.status)) {
622 return
623 }
624
625 setRetryLoading(true)
626 try {
627 const res = await retryChannelPublishTaskApi(getPublishRecordTaskId(activeRecord))
628 if (!res || res.code !== 0) {
629 return
630 }
631
632 if (isMobile) {
633 setPopoverOpen(false)
634 setRetryLoading(false)
635 await refreshCurrentPubRecords()
636 toast.success(t('record.retryPublishSuccess'))
637 return
638 }
639
640 await refreshPubRecordDetail(activeRecord.id)
641 toast.success(t('record.retryPublishSuccess'))
642 }
643 finally {
644 setRetryLoading(false)
645 }
646 }
647
648 const shouldShowRecordMetrics = !isWxSphRecord && (workMetricsLoading || !!activeEngagement)
649
650 // 触发按钮
651 const TriggerButton = (
652 <Button
653 data-testid="record-trigger"
654 variant="outline"
655 className={cn(
656 'flex justify-between items-center box-border w-full h-auto',
657 isMobile ? 'px-2.5 py-2.5' : 'px-1.5 py-1.5',
658 'bg-card hover:bg-accent border-border',
659 'rounded-md transition-colors',
660 'text-foreground font-normal',
661 'shadow-none cursor-pointer',
662 )}
663 style={{
664 width: dragPreviewWidth ?? (isMobile || calendarViewType === 'week' ? '100%' : `${calendarCallWidth}px`),
665 }}
666 disabled={retryLoading}
667 >
668 <div className={cn('flex items-center', isMobile ? 'gap-2.5' : 'gap-1.5')}>
669 <Image
670 src={platIcon || ''}
671 width={28}
672 height={28}
673 className={cn(isMobile ? 'w-7 h-7' : 'w-[25px] h-[25px]')}
674 alt="platform"
675 unoptimized
676 />
677 <div className={cn('font-semibold', isMobile ? 'text-base' : 'text-sm')}>
678 {days.format('HH:mm')}
679 </div>
680 </div>
681 {retryLoading ? (
682 <Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />
683 ) : coverThumbnailSrc && (
684 <div className="flex items-center">
685 <OssImage
686 src={coverThumbnailSrc}
687 width={32}
688 height={32}
689 sizes={isMobile ? '32px' : '24px'}
690 thumbnailSize={64}
691 className={cn('rounded object-cover', isMobile ? 'w-8 h-8' : 'w-6 h-6')}
692 alt="cover"
693 />
694 </div>
695 )}
696 </Button>
697 )
698
699 // 详情内容(复用于 Popover 和 Dialog)
700 const RecordContent = ({ inDialog = false }: { inDialog?: boolean }) => (
701 <div
702 className={cn('w-full box-border overflow-hidden', inDialog && 'flex flex-col')}
703 onMouseDown={e => e.stopPropagation()}
704 >
705 {/* 顶部:时间 */}
706 <div
707 className={cn(
708 'flex justify-between items-center border-b border-border p-2.5 md:p-3',
709 inDialog && 'shrink-0 pt-4 pr-10',
710 )}
711 >
712 <div className="flex flex-col gap-1">
713 {/* 发布时间 */}
714 <div className="font-semibold flex items-center gap-2 text-sm md:text-base">
715 {days.format('YYYY-MM-DD HH:mm')}
716 <Calendar className="h-3.5 w-3.5 md:h-4 md:w-4" />
717 </div>
718 {/* 更新时间 */}
719 {workMetricsFetchedAt && (
720 <div className="text-xs text-muted-foreground flex items-center gap-1.5">
721 {t('record.analyticsUpdatedAt')}
722
723 {dayjs(workMetricsFetchedAt).format('YYYY-MM-DD HH:mm')}
724 </div>
725 )}
726 </div>
727 </div>
728
729 {/* 中间:用户信息和内容 */}
730 <div
731 className={cn(
732 'flex flex-col md:flex-row justify-between gap-3 border-b border-border p-2.5 md:p-3 overflow-hidden',
733 inDialog && 'overflow-y-auto',
734 )}
735 >
736 <div className="flex-1 min-w-0">
737 <div className="flex items-center mb-2 md:mb-3">
738 <AvatarPlat account={account} size={isMobile ? 'default' : 'large'} />
739 <span className="ml-2 md:ml-2.5 inline-block font-bold text-sm md:text-base">
740 {account?.nickname}
741 </span>
742 {account?.clientType && (
743 <span
744 className={cn(
745 'inline-block px-1.5 py-0.5 rounded text-[10px] md:text-[11px] font-medium ml-2',
746 account.clientType === 'web'
747 ? 'bg-blue-50 text-blue-600 border border-blue-200 dark:bg-blue-900 dark:text-blue-200 dark:border-blue-700'
748 : 'bg-green-50 text-green-600 border border-green-200 dark:bg-green-900 dark:text-green-200 dark:border-green-700',
749 )}
750 >
751 {getClientTypeLabel(account.clientType)}
752 </span>
753 )}
754 </div>
755 <div
756 title={desc}
757 className="line-clamp-3 md:line-clamp-2 overflow-hidden text-ellipsis mt-2 md:mt-2.5 pr-0 md:pr-2.5 text-sm md:text-base"
758 >
759 {desc}
760 </div>
761 <div className="mt-3 md:mt-4">
762 <span data-testid="record-status-badge"><PubStatus status={activeRecord.status} /></span>
763 </div>
764 {activeRecord.errorMsg && (
765 <div title={activeRecord.errorMsg} className="mt-1 text-xs text-destructive">
766 {activeRecord.errorMsg}
767 </div>
768 )}
769 </div>
770
771 {/* 媒体预览 */}
772 {mediaPreviewItems.length > 0 && coverThumbnailSrc && (
773 <div
774 className={cn('shrink-0', isMobile ? 'w-full' : '')}
775 onClick={e => e.stopPropagation()}
776 onMouseDown={e => e.stopPropagation()}
777 >
778 <div
779 data-cover-preview
780 onClick={(e) => {
781 e.stopPropagation()
782 e.preventDefault()
783 handleCoverClick(e)
784 }}
785 onMouseDown={(e) => {
786 e.stopPropagation()
787 handleCoverMouseDown(e)
788 }}
789 className={cn(
790 'rounded-md overflow-hidden cursor-pointer hover:opacity-90 transition-opacity border border-border',
791 isMobile ? 'w-full aspect-video' : 'w-[145px] h-[145px]',
792 )}
793 >
794 <OssImage
795 src={coverThumbnailSrc}
796 width={290}
797 height={290}
798 sizes={isMobile ? 'calc(100vw - 48px)' : '145px'}
799 thumbnailWidth={isMobile ? 720 : 320}
800 thumbnailHeight={isMobile ? 405 : 320}
801 className="w-full h-full object-cover pointer-events-none"
802 alt="cover"
803 />
804 </div>
805 </div>
806 )}
807 </div>
808
809 {/* 信息指标 */}
810 {shouldShowRecordMetrics && (
811 <ScrollButtonContainer>
812 <div className="flex gap-3 md:gap-4 p-2 md:p-2.5 border-b border-border overflow-x-auto">
813 {workMetricsLoading
814 ? recordInfo.map(v => (
815 <div key={v.label} className="flex-shrink-0 md:flex-1 min-w-[60px] md:min-w-0">
816 <div className="flex items-center gap-1 md:gap-1.5">
817 <Skeleton className="h-4 w-4 rounded-full" />
818 <Skeleton className="h-3 w-12" />
819 </div>
820 <Skeleton className="mt-2 h-4 w-10" />
821 </div>
822 ))
823 : recordInfo.map(v => (
824 <div key={v.label} className="flex-shrink-0 md:flex-1 min-w-[60px] md:min-w-0">
825 <div className="flex items-center gap-1 md:gap-1.5">
826 {v.icon}
827 <span className="text-xs text-muted-foreground whitespace-nowrap">{v.label}</span>
828 </div>
829 {activeEngagement && (
830 <div className="text-sm md:text-base font-semibold mt-0.5 md:mt-1">
831 {activeEngagement[v.key as 'viewCount'] ?? 0}
832 </div>
833 )}
834 </div>
835 ))}
836 </div>
837 </ScrollButtonContainer>
838 )}
839
840 {/* 底部:操作按钮 */}
841 <div
842 className={cn(
843 'flex gap-2 md:gap-2.5 p-2.5 md:p-3',
844 isMobile ? 'flex-col' : 'flex-row justify-end',
845 inDialog && 'shrink-0',
846 )}
847 >
848 {/* 移动端:查看作品 + 更多操作 同一排 */}
849 {isMobile && (shouldShowViewWork || shouldShowRetryPublish || shouldShowMoreActions) ? (
850 <div className="flex gap-2 w-full">
851 {shouldShowViewWork && (
852 <Button
853 data-testid="record-view-work-btn"
854 className="cursor-pointer flex-1"
855 onClick={handleViewWork}
856 >
857 <ExternalLink className="h-4 w-4 mr-2" />
858 {t('record.viewWork')}
859 </Button>
860 )}
861 {shouldShowRetryPublish && (
862 <Button
863 data-testid="record-retry-btn"
864 variant="outline"
865 className={cn(
866 'cursor-pointer border-primary/30 text-primary hover:bg-primary/10 hover:text-primary',
867 shouldShowViewWork ? 'shrink-0' : 'flex-1',
868 )}
869 disabled={retryLoading}
870 onClick={handleRetryPublish}
871 >
872 {retryLoading ? (
873 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
874 ) : (
875 <RotateCcw className="mr-2 h-4 w-4" />
876 )}
877 {t('buttons.retryPublish')}
878 </Button>
879 )}
880 {shouldShowMoreActions && (
881 <DropdownMenu>
882 <DropdownMenuTrigger asChild>
883 <Button data-testid="record-more-btn" variant="outline" size="icon" className="cursor-pointer shrink-0">
884 <MoreVertical className="h-4 w-4" />
885 </Button>
886 </DropdownMenuTrigger>
887 <DropdownMenuContent align="end">
888 {shouldShowViewWork && (
889 <DropdownMenuItem onClick={handleCopyWorkLink}>
890 {t('buttons.copyLink')}
891 </DropdownMenuItem>
892 )}
893 {shouldShowReschedulePublish && (
894 <DropdownMenuItem onClick={handleOpenRescheduleDialog}>
895 <CalendarClock className="h-4 w-4" />
896 {t('buttons.reschedulePublish')}
897 </DropdownMenuItem>
898 )}
899 {shouldShowCancelPublish && (
900 <DropdownMenuItem
901 className={cancelPublishMenuItemClassName}
902 onClick={handleCancelPublish}
903 >
904 <CircleOff className="h-4 w-4" />
905 {t('buttons.cancelPublish')}
906 </DropdownMenuItem>
907 )}
908 {shouldShowDeleteRecord && (
909 <DropdownMenuItem
910 className={deleteRecordMenuItemClassName}
911 onClick={handleDeleteRecord}
912 >
913 <Trash2 className="h-4 w-4" />
914 {t('buttons.delete')}
915 </DropdownMenuItem>
916 )}
917 </DropdownMenuContent>
918 </DropdownMenu>
919 )}
920 </div>
921 ) : (
922 // 桌面端或移动端无更多操作时
923 <>
924 {shouldShowViewWork && (
925 <Button
926 data-testid="record-view-work-btn"
927 className={cn('cursor-pointer', isMobile && 'w-full')}
928 onClick={handleViewWork}
929 >
930 <ExternalLink className="h-4 w-4 mr-2" />
931 {t('record.viewWork')}
932 </Button>
933 )}
934
935 </>
936 )}
937
938 {shouldShowWxSphReviewPending && (
939 <TooltipProvider>
940 <Tooltip>
941 <TooltipTrigger asChild>
942 <span className={cn('inline-flex', isMobile && 'w-full')}>
943 <Button
944 data-testid="record-wx-sph-review-pending-btn"
945 variant="outline"
946 className={cn(
947 'cursor-not-allowed border-primary/30 bg-primary/5 text-primary',
948 isMobile && 'w-full',
949 )}
950 disabled
951 >
952 <Clock className="h-4 w-4 mr-2" />
953 {t('record.wxSphReviewPendingShort')}
954 </Button>
955 </span>
956 </TooltipTrigger>
957 <TooltipContent side="top" align={isMobile ? 'center' : 'end'} className="max-w-72">
958 {wxSphReviewPendingTooltip}
959 </TooltipContent>
960 </Tooltip>
961 </TooltipProvider>
962 )}
963
964 {shouldShowContinueUserActionPublish && (
965 <Button
966 data-testid="record-continue-publish-btn"
967 className={cn('cursor-pointer', isMobile && 'w-full')}
968 disabled={continuePublishLoading}
969 onClick={handleContinueUserActionPublish}
970 >
971 {continuePublishLoading ? (
972 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
973 ) : (
974 <Send className="mr-2 h-4 w-4" />
975 )}
976 {t('record.continuePublish')}
977 </Button>
978 )}
979
980 {canPublishRecordNow(activeRecord.status, activeRecord.publishTime) ? (
981 <Button
982 data-testid="record-publish-now-btn"
983 className={cn('cursor-pointer', isMobile && 'w-full')}
984 disabled={nowPubLoading}
985 onClick={handlePublishNow}
986 >
987 {nowPubLoading ? (
988 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
989 ) : (
990 <Send className="mr-2 h-4 w-4" />
991 )}
992 {t('buttons.publishNow')}
993 </Button>
994 ) : null}
995
996 {!isMobile && shouldShowRetryPublish && (
997 <Button
998 data-testid="record-retry-btn"
999 variant="outline"
1000 className="cursor-pointer border-primary/30 text-primary hover:bg-primary/10 hover:text-primary"
1001 disabled={retryLoading}
1002 onClick={handleRetryPublish}
1003 >
1004 {retryLoading ? (
1005 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
1006 ) : (
1007 <RotateCcw className="mr-2 h-4 w-4" />
1008 )}
1009 {t('buttons.retryPublish')}
1010 </Button>
1011 )}
1012
1013 {!isMobile && shouldShowMoreActions && (
1014 <DropdownMenu>
1015 <DropdownMenuTrigger asChild>
1016 <Button data-testid="record-more-btn" variant="outline" size="icon" className="cursor-pointer">
1017 <MoreVertical className="h-4 w-4" />
1018 </Button>
1019 </DropdownMenuTrigger>
1020 <DropdownMenuContent align="end">
1021 {shouldShowViewWork && (
1022 <DropdownMenuItem onClick={handleCopyWorkLink}>
1023 {t('buttons.copyLink')}
1024 </DropdownMenuItem>
1025 )}
1026 {shouldShowReschedulePublish && (
1027 <DropdownMenuItem onClick={handleOpenRescheduleDialog}>
1028 <CalendarClock className="h-4 w-4" />
1029 {t('buttons.reschedulePublish')}
1030 </DropdownMenuItem>
1031 )}
1032 {shouldShowCancelPublish && (
1033 <DropdownMenuItem
1034 className={cancelPublishMenuItemClassName}
1035 onClick={handleCancelPublish}
1036 >
1037 <CircleOff className="h-4 w-4" />
1038 {t('buttons.cancelPublish')}
1039 </DropdownMenuItem>
1040 )}
1041 {shouldShowDeleteRecord && (
1042 <DropdownMenuItem
1043 className={deleteRecordMenuItemClassName}
1044 onClick={handleDeleteRecord}
1045 >
1046 <Trash2 className="h-4 w-4" />
1047 {t('buttons.delete')}
1048 </DropdownMenuItem>
1049 )}
1050 </DropdownMenuContent>
1051 </DropdownMenu>
1052 )}
1053 </div>
1054 </div>
1055 )
1056
1057 return (
1058 <>
1059 {isMobile ? (
1060 // 移动端:全屏 Dialog
1061 <>
1062 <div onClick={() => handlePopoverOpenChange(true)}>{TriggerButton}</div>
1063 <Dialog open={popoverOpen} onOpenChange={handlePopoverOpenChange}>
1064 <DialogContent
1065 data-testid="record-detail-dialog"
1066 className="w-[calc(100%-24px)] max-h-[85vh] max-w-full p-0 flex flex-col overflow-hidden"
1067 onInteractOutside={(e) => {
1068 if (mediaPreviewOpen) {
1069 e.preventDefault()
1070 }
1071 }}
1072 >
1073 <DialogTitle className="sr-only">{days.format('YYYY-MM-DD HH:mm')}</DialogTitle>
1074 <RecordContent inDialog />
1075 </DialogContent>
1076 </Dialog>
1077 </>
1078 ) : (
1079 // 桌面端:Popover
1080 <Popover open={popoverOpen} onOpenChange={handlePopoverOpenChange}>
1081 <PopoverTrigger asChild>{TriggerButton}</PopoverTrigger>
1082 <PopoverContent
1083 data-testid="record-detail-popover"
1084 side="right"
1085 className="w-[450px] p-0"
1086 align="start"
1087 onInteractOutside={(e) => {
1088 if (mediaPreviewOpen) {
1089 e.preventDefault()
1090 return
1091 }
1092 const target = e.target as HTMLElement
1093 if (target.closest('[data-cover-preview]')) {
1094 e.preventDefault()
1095 }
1096 }}
1097 onPointerDownOutside={(e) => {
1098 if (mediaPreviewOpen) {
1099 e.preventDefault()
1100 return
1101 }
1102 const target = e.target as HTMLElement
1103 if (target.closest('[data-cover-preview]')) {
1104 e.preventDefault()
1105 }
1106 }}
1107 >
1108 <RecordContent />
1109 </PopoverContent>
1110 </Popover>
1111 )}
1112
1113 <Dialog open={rescheduleDialogOpen} onOpenChange={setRescheduleDialogOpen}>
1114 <DialogContent className="w-[calc(100%-24px)] max-w-[400px] gap-0 p-0 sm:p-0 overflow-hidden">
1115 <DialogTitle className="px-4 pt-4 text-base font-semibold">
1116 {t('record.rescheduleTitle')}
1117 </DialogTitle>
1118 <div className="mx-4 mt-3 flex items-start gap-2 rounded-lg border border-info/20 bg-info/10 px-3 py-2 text-xs text-info">
1119 <Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
1120 <span>{t('record.dragToRescheduleTip')}</span>
1121 </div>
1122 <PublishDatePicker
1123 inline
1124 showNowButton={false}
1125 loading={rescheduleLoading}
1126 value={reschedulePubTime}
1127 onValueChange={setReschedulePubTime}
1128 onClick={handleReschedulePublish}
1129 submitText={t('buttons.confirm')}
1130 minLeadMinutes={10}
1131 isMobile={isMobile}
1132 />
1133 </DialogContent>
1134 </Dialog>
1135
1136 {/* 媒体预览 */}
1137 <MediaPreview
1138 open={mediaPreviewOpen}
1139 items={mediaPreviewItems}
1140 initialIndex={mediaPreviewIndex}
1141 onClose={() => setMediaPreviewOpen(false)}
1142 />
1143
1144 <PublishDetailModal
1145 visible={!!publishDetailTaskId}
1146 taskId={publishDetailTaskId}
1147 onClose={() => setPublishDetailTaskId(undefined)}
1148 />
1149 </>
1150 )
1151 }),
1152 )
1153
1154 export default RecordCore
1155
1155 lines Plain Text