| 1 | /** |
| 2 | * 草稿列表组件 |
| 3 | * 使用 react-masonry-css 瀑布流布局 + IntersectionObserver 无限滚动展示全部草稿 |
| 4 | * 图片保持原始比例显示 |
| 5 | * 创建按钮卡片作为瀑布流前两个元素 |
| 6 | * 支持搜索筛选、批量删除、条件删除 |
| 7 | * 当传入 materialGroupId 时,集成 Tab(草稿箱/视频/图片) |
| 8 | */ |
| 9 | |
| 10 | 'use client' |
| 11 | |
| 12 | import type { PromotionMaterial } from '@/api/materials/material.types' |
| 13 | import type { PlatType } from '@/app/config/platConfig' |
| 14 | import { ArrowRightLeft, Check, ListChecks, Plus, Trash2 } from 'lucide-react' |
| 15 | import { memo, useCallback, useEffect, useMemo, useState } from 'react' |
| 16 | import Masonry from 'react-masonry-css' |
| 17 | import { useShallow } from 'zustand/react/shallow' |
| 18 | import { useTransClient } from '@/app/i18n/client' |
| 19 | import { OssImage } from '@/components/common/OssImage' |
| 20 | import { Badge } from '@/components/ui/badge' |
| 21 | |
| 22 | import { Button } from '@/components/ui/button' |
| 23 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' |
| 24 | import { Skeleton } from '@/components/ui/skeleton' |
| 25 | import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' |
| 26 | import { usePlanDetailStore } from '@/store/draft-box/planDetailStore' |
| 27 | import { getPlatformInfoSync } from '@/store/platformMetadata' |
| 28 | import { cn } from '@/utils/className' |
| 29 | import { useContainerMasonryColumns } from '../hooks/useContainerMasonryColumns' |
| 30 | import { getMaterialUseCountLabels } from '../utils/materialUseCount' |
| 31 | import { AllListSection } from './AllListSection' |
| 32 | import { BatchActionBar } from './BatchActionBar' |
| 33 | import { ConditionalDeleteDialog } from './ConditionalDeleteDialog' |
| 34 | import { useMediaTabStore } from './ContentTabs/mediaTabStore' |
| 35 | import { DraftListToolbar } from './DraftListToolbar' |
| 36 | import { |
| 37 | GeneratingTaskCard, |
| 38 | getDraftGenerationTaskTarget, |
| 39 | shouldShowDraftGenerationTaskCard, |
| 40 | } from './GeneratingCard' |
| 41 | import { LazyImage } from './LazyImage' |
| 42 | import { LOAD_MORE_OBSERVER_OPTIONS } from './loadMoreObserver' |
| 43 | import { MediaListSection } from './MediaListSection' |
| 44 | import { PublishDialogDragSource } from './PublishDialogDragSource' |
| 45 | |
| 46 | /** |
| 47 | * 瀑布流断点配置 |
| 48 | */ |
| 49 | const MASONRY_BREAKPOINTS = { |
| 50 | default: 5, // > 1280px |
| 51 | 1280: 4, // <= 1280px |
| 52 | 1024: 3, // <= 1024px |
| 53 | 768: 3, // <= 768px |
| 54 | 640: 2, // <= 640px |
| 55 | } |
| 56 | |
| 57 | // 手动创建按钮卡片 |
| 58 | const ManualCreateCard = memo(({ onClick }: { onClick: () => void }) => { |
| 59 | const { t } = useTransClient('brandPromotion') |
| 60 | return ( |
| 61 | <div |
| 62 | data-testid="draftbox-manual-create-card" |
| 63 | className="mb-4 rounded-lg border-2 border-dashed border-foreground/20 bg-muted/30 overflow-hidden transition-all duration-300 hover:border-foreground/40 hover:bg-muted/50 cursor-pointer" |
| 64 | onClick={onClick} |
| 65 | > |
| 66 | <div className="flex flex-col items-center justify-center p-8 gap-4"> |
| 67 | <div className="flex h-14 w-14 items-center justify-center rounded-full bg-foreground/10"> |
| 68 | <Plus className="h-7 w-7 text-foreground/70" /> |
| 69 | </div> |
| 70 | <span className="text-base font-medium">{t('detail.manualGenerate')}</span> |
| 71 | </div> |
| 72 | </div> |
| 73 | ) |
| 74 | }) |
| 75 | ManualCreateCard.displayName = 'ManualCreateCard' |
| 76 | |
| 77 | // 草稿卡片 props |
| 78 | interface DraftCardProps { |
| 79 | material: PromotionMaterial |
| 80 | onClick: () => void |
| 81 | batchMode: boolean |
| 82 | selected: boolean |
| 83 | onToggleSelect: () => void |
| 84 | useCountLabels?: string[] |
| 85 | enablePublishDrag?: boolean |
| 86 | } |
| 87 | |
| 88 | // 草稿卡片组件(小红书风格) |
| 89 | const DraftCard = memo(({ material, onClick, batchMode, selected, onToggleSelect, useCountLabels = [], enablePublishDrag }: DraftCardProps) => { |
| 90 | const { t } = useTransClient('brandPromotion') |
| 91 | const coverUrl = material.coverUrl || '/images/placeholder.png' |
| 92 | const canPublishDrag = !!enablePublishDrag && !batchMode |
| 93 | |
| 94 | const handleClick = useCallback(() => { |
| 95 | if (batchMode) { |
| 96 | onToggleSelect() |
| 97 | } |
| 98 | else { |
| 99 | onClick() |
| 100 | } |
| 101 | }, [batchMode, onClick, onToggleSelect]) |
| 102 | |
| 103 | const cardNode = ( |
| 104 | <div |
| 105 | data-testid="draftbox-draft-card" |
| 106 | className={cn( |
| 107 | 'mb-4 cursor-pointer group relative', |
| 108 | batchMode |
| 109 | ? cn( |
| 110 | 'rounded-xl transition-all duration-200', |
| 111 | selected ? 'shadow-lg' : '', |
| 112 | ) |
| 113 | : '', |
| 114 | )} |
| 115 | onClick={handleClick} |
| 116 | > |
| 117 | {/* 批量模式圆形勾选指示器 */} |
| 118 | {batchMode && ( |
| 119 | <div |
| 120 | data-testid="draftbox-draft-checkbox" |
| 121 | className={cn( |
| 122 | 'absolute top-2 right-2 z-10 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-all duration-200 shadow-sm', |
| 123 | selected |
| 124 | ? 'border-transparent bg-gradient-back scale-110' |
| 125 | : 'bg-background/90 border-muted-foreground/30 group-hover:border-primary group-hover:scale-105', |
| 126 | )} |
| 127 | onClick={(e) => { e.stopPropagation(); onToggleSelect() }} |
| 128 | > |
| 129 | {selected && <Check className="w-3.5 h-3.5 text-gradient-foreground" />} |
| 130 | </div> |
| 131 | )} |
| 132 | |
| 133 | {/* 封面图 - 保持原始比例,圆角独立 */} |
| 134 | <div className="relative w-full overflow-hidden rounded-xl"> |
| 135 | <LazyImage |
| 136 | src={coverUrl} |
| 137 | alt={material.title || t('material.draft')} |
| 138 | width={400} |
| 139 | height={300} |
| 140 | className="w-full h-auto transition-transform duration-300 group-hover:scale-105" |
| 141 | skeletonClassName="rounded-xl" |
| 142 | placeholderHeight={150} |
| 143 | style={{ aspectRatio: 'auto' }} |
| 144 | /> |
| 145 | |
| 146 | {/* hover 时显示描述遮罩 - 批量模式下隐藏 */} |
| 147 | {!batchMode && material.desc && ( |
| 148 | <div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-end p-3 rounded-xl"> |
| 149 | <p className="text-white text-xs line-clamp-4"> |
| 150 | {material.desc} |
| 151 | </p> |
| 152 | </div> |
| 153 | )} |
| 154 | |
| 155 | {/* 选中遮罩 */} |
| 156 | {batchMode && selected && ( |
| 157 | <div className="absolute inset-0 bg-primary/15 pointer-events-none rounded-xl" /> |
| 158 | )} |
| 159 | </div> |
| 160 | {/* 标题和模型标签 */} |
| 161 | <div className="pt-2 px-1"> |
| 162 | <p className="text-sm font-medium text-foreground line-clamp-2"> |
| 163 | {material.title || t('material.untitled')} |
| 164 | </p> |
| 165 | {material.model && ( |
| 166 | <span className="inline-block mt-1 px-1.5 py-0.5 text-xs rounded bg-muted text-muted-foreground"> |
| 167 | {material.model} |
| 168 | </span> |
| 169 | )} |
| 170 | {useCountLabels.map(label => ( |
| 171 | <span |
| 172 | key={label} |
| 173 | className="inline-block mt-1 ml-1 px-1.5 py-0.5 text-xs rounded bg-primary/10 text-primary" |
| 174 | > |
| 175 | {label} |
| 176 | </span> |
| 177 | ))} |
| 178 | {material.accountTypes && material.accountTypes.length > 0 && ( |
| 179 | <div className="flex flex-wrap gap-1 mt-1.5"> |
| 180 | {material.accountTypes.map((type) => { |
| 181 | const platInfo = getPlatformInfoSync(type as PlatType) |
| 182 | if (!platInfo) |
| 183 | return null |
| 184 | return ( |
| 185 | <OssImage |
| 186 | key={type} |
| 187 | src={platInfo.icon} |
| 188 | alt={platInfo.name} |
| 189 | width={16} |
| 190 | height={16} |
| 191 | className="w-4 h-4" |
| 192 | unoptimized |
| 193 | /> |
| 194 | ) |
| 195 | })} |
| 196 | </div> |
| 197 | )} |
| 198 | </div> |
| 199 | </div> |
| 200 | ) |
| 201 | |
| 202 | if (!canPublishDrag) |
| 203 | return cardNode |
| 204 | |
| 205 | return ( |
| 206 | <PublishDialogDragSource dragItem={{ kind: 'draft', material }}> |
| 207 | {cardNode} |
| 208 | </PublishDialogDragSource> |
| 209 | ) |
| 210 | }) |
| 211 | |
| 212 | DraftCard.displayName = 'DraftCard' |
| 213 | |
| 214 | // 骨架屏 - 随机高度模拟瀑布流效果(小红书风格) |
| 215 | function DraftCardSkeleton({ index }: { index: number }) { |
| 216 | // 根据 index 生成不同高度,模拟真实图片的随机比例 |
| 217 | const heights = [120, 160, 200, 140, 180, 150, 170, 190] |
| 218 | const height = heights[index % heights.length] |
| 219 | |
| 220 | return ( |
| 221 | <div className="mb-4"> |
| 222 | <Skeleton className="w-full rounded-xl" style={{ height: `${height}px` }} /> |
| 223 | <div className="pt-2 px-1"> |
| 224 | <Skeleton className="h-4 w-full" /> |
| 225 | </div> |
| 226 | </div> |
| 227 | ) |
| 228 | } |
| 229 | |
| 230 | // 加载更多指示器 |
| 231 | const LoadingIndicator = memo(({ label }: { label: string }) => ( |
| 232 | <div className="flex justify-center py-4"> |
| 233 | <div className="flex items-center gap-2 text-sm text-muted-foreground"> |
| 234 | <div className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" /> |
| 235 | <span>{label}</span> |
| 236 | </div> |
| 237 | </div> |
| 238 | )) |
| 239 | |
| 240 | LoadingIndicator.displayName = 'LoadingIndicator' |
| 241 | |
| 242 | interface DraftListSectionProps { |
| 243 | materialGroupId: string |
| 244 | tabs?: DraftListSectionTab[] |
| 245 | defaultTab?: DraftListSectionTab |
| 246 | allowTransfer?: boolean |
| 247 | batchActionPosition?: 'fixed' | 'sticky' |
| 248 | useContainerResponsive?: boolean |
| 249 | enablePublishDrag?: boolean |
| 250 | } |
| 251 | |
| 252 | export type DraftListSectionTab = 'all' | 'drafts' | 'video' | 'img' |
| 253 | |
| 254 | const DEFAULT_DRAFT_LIST_TABS: DraftListSectionTab[] = ['all', 'drafts', 'video', 'img'] |
| 255 | |
| 256 | export const DraftListSection = memo(({ |
| 257 | materialGroupId, |
| 258 | tabs = DEFAULT_DRAFT_LIST_TABS, |
| 259 | defaultTab = 'all', |
| 260 | allowTransfer = true, |
| 261 | batchActionPosition = 'fixed', |
| 262 | useContainerResponsive = false, |
| 263 | enablePublishDrag = false, |
| 264 | }: DraftListSectionProps) => { |
| 265 | const { t } = useTransClient('brandPromotion') |
| 266 | const { t: tMaterial } = useTransClient('material') |
| 267 | const visibleTabs = useMemo(() => tabs.length > 0 ? tabs : DEFAULT_DRAFT_LIST_TABS, [tabs]) |
| 268 | const resolvedDefaultTab = visibleTabs.includes(defaultTab) ? defaultTab : visibleTabs[0] |
| 269 | const [activeTab, setActiveTab] = useState<DraftListSectionTab>(resolvedDefaultTab) |
| 270 | const hasAllTab = visibleTabs.includes('all') |
| 271 | const hasDraftsTab = visibleTabs.includes('drafts') |
| 272 | const hasVideoTab = visibleTabs.includes('video') |
| 273 | const hasImgTab = visibleTabs.includes('img') |
| 274 | const showTabsList = visibleTabs.length > 1 |
| 275 | const { containerRef, masonryColumns } = useContainerMasonryColumns(useContainerResponsive) |
| 276 | const masonryBreakpointCols = useContainerResponsive ? masonryColumns : MASONRY_BREAKPOINTS |
| 277 | |
| 278 | // materialGroupId 变化时,默认选中配置的 Tab |
| 279 | useEffect(() => { |
| 280 | setActiveTab(resolvedDefaultTab) |
| 281 | }, [materialGroupId, resolvedDefaultTab]) |
| 282 | |
| 283 | // 无限滚动加载触发器(使用 callback ref + state,确保 Tab 切换后 observer 能正确绑定) |
| 284 | const [loadMoreElement, setLoadMoreElement] = useState<HTMLDivElement | null>(null) |
| 285 | const loadMoreCallbackRef = useCallback((node: HTMLDivElement | null) => { |
| 286 | setLoadMoreElement(node) |
| 287 | }, []) |
| 288 | |
| 289 | const { |
| 290 | materials, |
| 291 | materialsLoading, |
| 292 | materialsPagination, |
| 293 | generationTasks, |
| 294 | batchMode, |
| 295 | selectedMaterialIds, |
| 296 | openCreateMaterialModal, |
| 297 | loadMoreMaterials, |
| 298 | openDraftDetailDialog, |
| 299 | openGenerationDetailDialog, |
| 300 | toggleMaterialSelection, |
| 301 | fetchMaterials, |
| 302 | } = usePlanDetailStore( |
| 303 | useShallow(state => ({ |
| 304 | materials: state.materials, |
| 305 | materialsLoading: state.materialsLoading, |
| 306 | materialsPagination: state.materialsPagination, |
| 307 | generationTasks: state.generationTasks, |
| 308 | batchMode: state.batchMode, |
| 309 | selectedMaterialIds: state.selectedMaterialIds, |
| 310 | openCreateMaterialModal: state.openCreateMaterialModal, |
| 311 | loadMoreMaterials: state.loadMoreMaterials, |
| 312 | openDraftDetailDialog: state.openDraftDetailDialog, |
| 313 | openGenerationDetailDialog: state.openGenerationDetailDialog, |
| 314 | toggleMaterialSelection: state.toggleMaterialSelection, |
| 315 | fetchMaterials: state.fetchMaterials, |
| 316 | })), |
| 317 | ) |
| 318 | |
| 319 | const { draftTotal, videoTotal, imgTotal } = useMediaTabStore( |
| 320 | useShallow(state => ({ |
| 321 | draftTotal: state.all.draftTotal, |
| 322 | videoTotal: state.video.initialized ? state.video.total : state.all.videoTotal, |
| 323 | imgTotal: state.img.initialized ? state.img.total : state.all.imgTotal, |
| 324 | })), |
| 325 | ) |
| 326 | |
| 327 | const selectedSet = new Set(selectedMaterialIds) |
| 328 | const visibleGenerationTasks = useMemo( |
| 329 | () => generationTasks.filter(shouldShowDraftGenerationTaskCard), |
| 330 | [generationTasks], |
| 331 | ) |
| 332 | const visibleDraftGenerationTasks = useMemo( |
| 333 | () => visibleGenerationTasks.filter(task => getDraftGenerationTaskTarget(task) === 'draft'), |
| 334 | [visibleGenerationTasks], |
| 335 | ) |
| 336 | const visibleVideoGenerationTasks = useMemo( |
| 337 | () => visibleGenerationTasks.filter(task => getDraftGenerationTaskTarget(task) === 'video'), |
| 338 | [visibleGenerationTasks], |
| 339 | ) |
| 340 | const visibleImageGenerationTasks = useMemo( |
| 341 | () => visibleGenerationTasks.filter(task => getDraftGenerationTaskTarget(task) === 'img'), |
| 342 | [visibleGenerationTasks], |
| 343 | ) |
| 344 | const allTotal = draftTotal + videoTotal + imgTotal + visibleGenerationTasks.length |
| 345 | |
| 346 | const exitMediaBatchMode = useMediaTabStore(state => state.exitBatchMode) |
| 347 | const enterMediaBatchMode = useMediaTabStore(state => state.enterBatchMode) |
| 348 | const mediaBatchMode = useMediaTabStore(state => state.batchMode) |
| 349 | const exitDraftBatchMode = usePlanDetailStore(state => state.exitBatchMode) |
| 350 | |
| 351 | // Tab 切换处理 |
| 352 | const handleTabChange = useCallback((value: string) => { |
| 353 | if (!visibleTabs.includes(value as DraftListSectionTab)) |
| 354 | return |
| 355 | |
| 356 | // 切换 Tab 时退出所有批量模式 |
| 357 | exitDraftBatchMode() |
| 358 | exitMediaBatchMode() |
| 359 | |
| 360 | setActiveTab(value as DraftListSectionTab) |
| 361 | |
| 362 | // 草稿 Tab 无独立列表组件,仍在这里兜底首次加载;媒体/全部 Tab 交由各自内容组件加载。 |
| 363 | if (value === 'drafts' && !usePlanDetailStore.getState().materialsInitialized && materialGroupId) { |
| 364 | fetchMaterials(materialGroupId, 1) |
| 365 | } |
| 366 | }, [materialGroupId, exitDraftBatchMode, exitMediaBatchMode, fetchMaterials, visibleTabs]) |
| 367 | |
| 368 | // IntersectionObserver 实现无限滚动 |
| 369 | useEffect(() => { |
| 370 | if (!loadMoreElement) |
| 371 | return |
| 372 | |
| 373 | const observer = new IntersectionObserver( |
| 374 | (entries) => { |
| 375 | const [entry] = entries |
| 376 | if (entry.isIntersecting && materialsPagination.hasMore && !materialsLoading && materialGroupId) { |
| 377 | loadMoreMaterials(materialGroupId) |
| 378 | } |
| 379 | }, |
| 380 | LOAD_MORE_OBSERVER_OPTIONS, |
| 381 | ) |
| 382 | |
| 383 | observer.observe(loadMoreElement) |
| 384 | |
| 385 | return () => { |
| 386 | observer.disconnect() |
| 387 | } |
| 388 | }, [loadMoreElement, materialsPagination.hasMore, materialsLoading, materialGroupId, loadMoreMaterials]) |
| 389 | |
| 390 | // 根据 activeTab 获取标题 |
| 391 | const getHeaderTitle = () => { |
| 392 | switch (activeTab) { |
| 393 | case 'all': |
| 394 | return tMaterial('mediaManagement.all') |
| 395 | case 'video': |
| 396 | return tMaterial('mediaManagement.video') |
| 397 | case 'img': |
| 398 | return tMaterial('mediaManagement.image') |
| 399 | default: |
| 400 | return tMaterial('mediaManagement.drafts', '草稿') |
| 401 | } |
| 402 | } |
| 403 | |
| 404 | const getActiveTabCount = () => { |
| 405 | switch (activeTab) { |
| 406 | case 'all': |
| 407 | return allTotal |
| 408 | case 'drafts': |
| 409 | return materialsPagination.total + visibleDraftGenerationTasks.length |
| 410 | case 'video': |
| 411 | return videoTotal + visibleVideoGenerationTasks.length |
| 412 | case 'img': |
| 413 | return imgTotal + visibleImageGenerationTasks.length |
| 414 | default: |
| 415 | return materialsPagination.total + visibleDraftGenerationTasks.length |
| 416 | } |
| 417 | } |
| 418 | |
| 419 | const isDraftsTab = activeTab === 'drafts' |
| 420 | const showMediaHeaderBatchActions = !isDraftsTab && !mediaBatchMode && getActiveTabCount() > 0 |
| 421 | const cardContentClassName = useContainerResponsive |
| 422 | ? 'px-4 pb-4 @min-[640px]:px-6 @min-[640px]:pb-6' |
| 423 | : 'px-4 pb-4 sm:px-6 sm:pb-6' |
| 424 | const tabCountBadgeClassName = 'ml-1 h-5 min-w-[20px] px-1.5 text-xs' |
| 425 | const headerCountBadgeClassName = showTabsList ? 'hidden' : 'h-5 min-w-[20px] px-1.5 text-xs' |
| 426 | const batchCardContentClassName = cn( |
| 427 | cardContentClassName, |
| 428 | (batchMode || mediaBatchMode) && 'pb-16', |
| 429 | ) |
| 430 | |
| 431 | // 草稿内容区域 |
| 432 | const draftsContent = ( |
| 433 | <> |
| 434 | <DraftListToolbar allowTransfer={allowTransfer} useContainerResponsive={useContainerResponsive} /> |
| 435 | <div> |
| 436 | <Masonry |
| 437 | breakpointCols={masonryBreakpointCols} |
| 438 | className="flex -ml-4 w-auto" |
| 439 | columnClassName="pl-4 bg-clip-padding" |
| 440 | data-testid="draftbox-masonry-list" |
| 441 | > |
| 442 | {!batchMode && ( |
| 443 | <ManualCreateCard onClick={openCreateMaterialModal} /> |
| 444 | )} |
| 445 | |
| 446 | {/* 生成中卡片 - 批量模式隐藏 */} |
| 447 | {!batchMode && visibleDraftGenerationTasks.map(task => ( |
| 448 | <GeneratingTaskCard key={task.id} task={task} onClick={openGenerationDetailDialog} /> |
| 449 | ))} |
| 450 | |
| 451 | {/* 草稿数据 */} |
| 452 | {materials.map(material => ( |
| 453 | <DraftCard |
| 454 | key={material.id} |
| 455 | material={material} |
| 456 | onClick={() => openDraftDetailDialog(material)} |
| 457 | batchMode={batchMode} |
| 458 | selected={selectedSet.has(material.id)} |
| 459 | onToggleSelect={() => toggleMaterialSelection(material.id)} |
| 460 | useCountLabels={getMaterialUseCountLabels(material, t)} |
| 461 | enablePublishDrag={enablePublishDrag} |
| 462 | /> |
| 463 | ))} |
| 464 | </Masonry> |
| 465 | |
| 466 | {/* 加载触发器 */} |
| 467 | <div ref={loadMoreCallbackRef} /> |
| 468 | |
| 469 | {/* 加载更多指示器 */} |
| 470 | {materialsLoading && <LoadingIndicator label={t('common.loading')} />} |
| 471 | |
| 472 | {/* 没有更多数据 */} |
| 473 | {!materialsPagination.hasMore && materials.length > 0 && ( |
| 474 | <div className="flex items-center justify-center py-4"> |
| 475 | <span className="text-sm text-muted-foreground"> |
| 476 | {t('common.noMore', '没有更多了')} |
| 477 | </span> |
| 478 | </div> |
| 479 | )} |
| 480 | </div> |
| 481 | </> |
| 482 | ) |
| 483 | |
| 484 | // 骨架屏内容 |
| 485 | // TabsList 组件 |
| 486 | const tabsList = showTabsList ? ( |
| 487 | <TabsList className={useContainerResponsive ? 'grid h-auto w-full grid-cols-4 gap-1 @min-[640px]:inline-flex @min-[640px]:w-auto' : 'grid h-auto w-full grid-cols-4 gap-1 sm:inline-flex sm:w-auto'}> |
| 488 | {hasAllTab && ( |
| 489 | <TabsTrigger value="all" className={useContainerResponsive ? 'min-w-0 cursor-pointer px-2 text-xs @min-[640px]:px-3 @min-[640px]:text-sm' : 'cursor-pointer min-w-0 px-2 text-xs sm:px-3 sm:text-sm'}> |
| 490 | {tMaterial('mediaManagement.all')} |
| 491 | {allTotal > 0 && ( |
| 492 | <Badge variant="secondary" className={tabCountBadgeClassName}> |
| 493 | {allTotal} |
| 494 | </Badge> |
| 495 | )} |
| 496 | </TabsTrigger> |
| 497 | )} |
| 498 | {hasDraftsTab && ( |
| 499 | <TabsTrigger value="drafts" className={useContainerResponsive ? 'min-w-0 cursor-pointer px-2 text-xs @min-[640px]:px-3 @min-[640px]:text-sm' : 'cursor-pointer min-w-0 px-2 text-xs sm:px-3 sm:text-sm'}> |
| 500 | {tMaterial('mediaManagement.drafts', '草稿')} |
| 501 | {materialsPagination.total > 0 && ( |
| 502 | <Badge variant="secondary" className={tabCountBadgeClassName}> |
| 503 | {materialsPagination.total} |
| 504 | </Badge> |
| 505 | )} |
| 506 | </TabsTrigger> |
| 507 | )} |
| 508 | {hasVideoTab && ( |
| 509 | <TabsTrigger value="video" className={useContainerResponsive ? 'min-w-0 cursor-pointer px-2 text-xs @min-[640px]:px-3 @min-[640px]:text-sm' : 'cursor-pointer min-w-0 px-2 text-xs sm:px-3 sm:text-sm'}> |
| 510 | {tMaterial('mediaManagement.video')} |
| 511 | {videoTotal > 0 && ( |
| 512 | <Badge variant="secondary" className={tabCountBadgeClassName}> |
| 513 | {videoTotal} |
| 514 | </Badge> |
| 515 | )} |
| 516 | </TabsTrigger> |
| 517 | )} |
| 518 | {hasImgTab && ( |
| 519 | <TabsTrigger value="img" className={useContainerResponsive ? 'min-w-0 cursor-pointer px-2 text-xs @min-[640px]:px-3 @min-[640px]:text-sm' : 'cursor-pointer min-w-0 px-2 text-xs sm:px-3 sm:text-sm'}> |
| 520 | {tMaterial('mediaManagement.image')} |
| 521 | {imgTotal > 0 && ( |
| 522 | <Badge variant="secondary" className={tabCountBadgeClassName}> |
| 523 | {imgTotal} |
| 524 | </Badge> |
| 525 | )} |
| 526 | </TabsTrigger> |
| 527 | )} |
| 528 | </TabsList> |
| 529 | ) : null |
| 530 | |
| 531 | // 初始加载骨架屏 |
| 532 | if (materialsLoading && materials.length === 0) { |
| 533 | return ( |
| 534 | <Tabs ref={useContainerResponsive ? containerRef : undefined} value={activeTab} onValueChange={handleTabChange} className="w-full"> |
| 535 | <Card> |
| 536 | {tabsList && ( |
| 537 | <div className={useContainerResponsive ? 'px-4 pt-4 @min-[640px]:px-6 @min-[640px]:pt-6' : 'px-4 pt-4 sm:px-6 sm:pt-6'}> |
| 538 | {tabsList} |
| 539 | </div> |
| 540 | )} |
| 541 | <CardHeader className={useContainerResponsive ? 'px-4 pb-4 pt-4 @min-[640px]:px-6' : 'px-4 pb-4 pt-4 sm:px-6'}> |
| 542 | <div className="flex items-center justify-between gap-3"> |
| 543 | <div className="flex min-w-0 flex-wrap items-center gap-2"> |
| 544 | <CardTitle className="text-base">{getHeaderTitle()}</CardTitle> |
| 545 | {getActiveTabCount() > 0 && ( |
| 546 | <Badge variant="secondary" className={headerCountBadgeClassName}> |
| 547 | {getActiveTabCount()} |
| 548 | </Badge> |
| 549 | )} |
| 550 | </div> |
| 551 | <Button |
| 552 | variant="ghost" |
| 553 | size="sm" |
| 554 | onClick={openGenerationDetailDialog} |
| 555 | className="shrink-0 cursor-pointer justify-center gap-1.5 text-muted-foreground" |
| 556 | > |
| 557 | <ListChecks className="h-3.5 w-3.5" /> |
| 558 | {t('draftManage.generationDetail')} |
| 559 | </Button> |
| 560 | </div> |
| 561 | </CardHeader> |
| 562 | <CardContent className={cardContentClassName}> |
| 563 | <Masonry |
| 564 | breakpointCols={masonryBreakpointCols} |
| 565 | className="flex -ml-4 w-auto" |
| 566 | columnClassName="pl-4 bg-clip-padding" |
| 567 | > |
| 568 | <ManualCreateCard onClick={openCreateMaterialModal} /> |
| 569 | {Array.from({ length: 8 }).map((_, i) => ( |
| 570 | <DraftCardSkeleton key={i} index={i} /> |
| 571 | ))} |
| 572 | </Masonry> |
| 573 | </CardContent> |
| 574 | </Card> |
| 575 | </Tabs> |
| 576 | ) |
| 577 | } |
| 578 | return ( |
| 579 | <Tabs ref={useContainerResponsive ? containerRef : undefined} value={activeTab} onValueChange={handleTabChange} className="w-full"> |
| 580 | <Card> |
| 581 | {tabsList && ( |
| 582 | <div className={useContainerResponsive ? 'px-4 pt-4 @min-[640px]:px-6 @min-[640px]:pt-6' : 'px-4 pt-4 sm:px-6 sm:pt-6'}> |
| 583 | {tabsList} |
| 584 | </div> |
| 585 | )} |
| 586 | <CardHeader className={useContainerResponsive ? 'px-4 pb-4 pt-4 @min-[640px]:px-6' : 'px-4 pb-4 pt-4 sm:px-6'}> |
| 587 | <div className={useContainerResponsive ? 'flex flex-col gap-3 @min-[640px]:flex-row @min-[640px]:items-center @min-[640px]:justify-between' : 'flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'}> |
| 588 | <div className={useContainerResponsive ? 'flex items-center justify-between gap-3 @min-[640px]:min-w-0 @min-[640px]:flex-1 @min-[640px]:justify-start' : 'flex items-center justify-between gap-3 sm:min-w-0 sm:flex-1 sm:justify-start'}> |
| 589 | <div className={useContainerResponsive ? 'flex min-w-0 flex-wrap items-center gap-2' : 'flex min-w-0 flex-wrap items-center gap-2'}> |
| 590 | <CardTitle className="text-base">{getHeaderTitle()}</CardTitle> |
| 591 | {getActiveTabCount() > 0 && ( |
| 592 | <Badge variant="secondary" className={headerCountBadgeClassName}> |
| 593 | {getActiveTabCount()} |
| 594 | </Badge> |
| 595 | )} |
| 596 | </div> |
| 597 | <Button |
| 598 | variant="ghost" |
| 599 | size="sm" |
| 600 | onClick={openGenerationDetailDialog} |
| 601 | className={useContainerResponsive ? 'shrink-0 cursor-pointer justify-center gap-1.5 text-muted-foreground @min-[640px]:hidden' : 'shrink-0 cursor-pointer justify-center gap-1.5 text-muted-foreground sm:hidden'} |
| 602 | > |
| 603 | <ListChecks className="h-3.5 w-3.5" /> |
| 604 | {t('draftManage.generationDetail')} |
| 605 | </Button> |
| 606 | </div> |
| 607 | <div className={useContainerResponsive ? 'flex w-full flex-col gap-2 @min-[640px]:w-auto @min-[640px]:flex-row @min-[640px]:items-center @min-[640px]:justify-end' : 'flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center sm:justify-end'}> |
| 608 | {showMediaHeaderBatchActions && ( |
| 609 | <div className={useContainerResponsive ? 'grid w-full grid-cols-2 gap-2 @min-[640px]:flex @min-[640px]:w-auto' : 'grid w-full grid-cols-2 gap-2 sm:flex sm:w-auto'}> |
| 610 | <Button |
| 611 | variant="outline" |
| 612 | size="sm" |
| 613 | onClick={enterMediaBatchMode} |
| 614 | className={useContainerResponsive ? 'w-full cursor-pointer justify-center gap-1.5 @min-[640px]:w-auto' : 'w-full cursor-pointer justify-center gap-1.5 sm:w-auto'} |
| 615 | > |
| 616 | <ArrowRightLeft className="h-3.5 w-3.5" /> |
| 617 | {t('draftManage.batchTransfer')} |
| 618 | </Button> |
| 619 | <Button |
| 620 | variant="outline" |
| 621 | size="sm" |
| 622 | onClick={enterMediaBatchMode} |
| 623 | className={useContainerResponsive ? 'w-full cursor-pointer justify-center gap-1.5 @min-[640px]:w-auto' : 'w-full cursor-pointer justify-center gap-1.5 sm:w-auto'} |
| 624 | > |
| 625 | <Trash2 className="h-3.5 w-3.5" /> |
| 626 | {tMaterial('mediaManagement.batchDelete')} |
| 627 | </Button> |
| 628 | </div> |
| 629 | )} |
| 630 | <Button |
| 631 | variant="ghost" |
| 632 | size="sm" |
| 633 | onClick={openGenerationDetailDialog} |
| 634 | className={useContainerResponsive ? 'hidden cursor-pointer justify-center gap-1.5 text-muted-foreground @min-[640px]:inline-flex' : 'hidden cursor-pointer justify-center gap-1.5 text-muted-foreground sm:inline-flex'} |
| 635 | > |
| 636 | <ListChecks className="h-3.5 w-3.5" /> |
| 637 | {t('draftManage.generationDetail')} |
| 638 | </Button> |
| 639 | </div> |
| 640 | </div> |
| 641 | </CardHeader> |
| 642 | |
| 643 | {hasAllTab && ( |
| 644 | <TabsContent value="all" className="mt-0"> |
| 645 | <CardContent className={batchCardContentClassName}> |
| 646 | <AllListSection materialGroupId={materialGroupId} showBatchDeleteTrigger={false} batchActionPosition={batchActionPosition} useContainerResponsive={useContainerResponsive} enablePublishDrag={enablePublishDrag} /> |
| 647 | </CardContent> |
| 648 | </TabsContent> |
| 649 | )} |
| 650 | |
| 651 | {hasDraftsTab && ( |
| 652 | <TabsContent value="drafts" className="mt-0"> |
| 653 | <CardContent className={batchCardContentClassName}> |
| 654 | {draftsContent} |
| 655 | </CardContent> |
| 656 | </TabsContent> |
| 657 | )} |
| 658 | |
| 659 | {hasVideoTab && ( |
| 660 | <TabsContent value="video" className="mt-0"> |
| 661 | <CardContent className={batchCardContentClassName}> |
| 662 | <MediaListSection |
| 663 | type="video" |
| 664 | materialGroupId={materialGroupId} |
| 665 | showBatchDeleteTrigger={false} |
| 666 | batchActionPosition={batchActionPosition} |
| 667 | useContainerResponsive={useContainerResponsive} |
| 668 | enablePublishDrag={enablePublishDrag} |
| 669 | /> |
| 670 | </CardContent> |
| 671 | </TabsContent> |
| 672 | )} |
| 673 | |
| 674 | {hasImgTab && ( |
| 675 | <TabsContent value="img" className="mt-0"> |
| 676 | <CardContent className={batchCardContentClassName}> |
| 677 | <MediaListSection |
| 678 | type="img" |
| 679 | materialGroupId={materialGroupId} |
| 680 | showBatchDeleteTrigger={false} |
| 681 | batchActionPosition={batchActionPosition} |
| 682 | useContainerResponsive={useContainerResponsive} |
| 683 | enablePublishDrag={enablePublishDrag} |
| 684 | /> |
| 685 | </CardContent> |
| 686 | </TabsContent> |
| 687 | )} |
| 688 | |
| 689 | {activeTab === 'drafts' && batchMode && ( |
| 690 | <BatchActionBar allowTransfer={allowTransfer} position={batchActionPosition} useContainerResponsive={useContainerResponsive} /> |
| 691 | )} |
| 692 | <ConditionalDeleteDialog /> |
| 693 | </Card> |
| 694 | </Tabs> |
| 695 | ) |
| 696 | }) |
| 697 | |
| 698 | DraftListSection.displayName = 'DraftListSection' |
| 699 |