| 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 |