Compare commits

...

1 Commits

Author SHA1 Message Date
xiaoxia 8437d4060b feat(products): 成片库视频内联播放,去掉弹窗 + 自适应比例
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m35s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m7s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m5s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m40s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 5m18s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m13s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 10m1s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 2m9s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m16s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 10m52s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m7s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 43s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 4m1s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Waiting to run
- ProductCard: 缩略图区域改为 <video> 内联播放,点击播放按钮原生 controls
- 视频比例通过 onLoadedMetadata 动态获取,获取不到时默认 16:9
- ProductLibrary: 移除 playingProduct 状态和 VideoPlayer 弹窗组件
- products.css: .xx-product-thumb 去掉固定 9:16 aspect-ratio,
  新增 .xx-product-thumb-video 样式(object-fit: contain)
- 卡片网格保持 4 列,卡片高度自适应视频内容
2026-08-31 16:40:15 +08:00
3 changed files with 118 additions and 46 deletions
+4 -23
View File
@@ -1,6 +1,6 @@
/**
* 成片库页面 — V21 设计系统
* 卡片网格布局,支持视频播放/下载/分享、批量操作、筛选
* 卡片网格布局,支持视频内联播放/下载/分享、批量操作、筛选
*
* 主组件仅保留 Hook 组装与整体布局
* 列表查询 → hooks/useProductList
@@ -8,15 +8,12 @@
* 筛选栏 → components/ProductFilterBar
* 批量操作栏 → components/ProductBatchBar
* 空状态 → components/ProductEmptyState
* 产品卡片 → components/ProductCard
* 视频播放 → components/VideoPlayer
* 产品卡片 → components/ProductCard(内联视频播放)
*/
import React, { useState } from "react"
import React from "react"
import { VideoCameraOutlined, DownloadOutlined } from "@ant-design/icons"
import { Button } from "@/components/ui"
import type { ProductItem } from "./types"
import { ProductCard } from "./components/ProductCard"
import { VideoPlayer } from "./components/VideoPlayer"
import { ProductFilterBar } from "./components/ProductFilterBar"
import { ProductBatchBar } from "./components/ProductBatchBar"
import { ProductEmptyState } from "./components/ProductEmptyState"
@@ -53,13 +50,9 @@ const ProductLibrary: React.FC = () => {
clearSelection,
} = useProductList()
/* 播放器 */
const [playingProduct, setPlayingProduct] = useState<ProductItem | null>(null)
const {
handleDownload,
handleShare,
handleViewDetail,
handleDelete,
handlePublish,
handleReviewStatusChange,
@@ -71,7 +64,7 @@ const ProductLibrary: React.FC = () => {
selectedIds,
clearSelection,
products,
setPlayingProduct,
setPlayingProduct: () => {}, // 不再使用弹窗播放
})
// ── Loading 状态 ──
@@ -146,7 +139,6 @@ const ProductLibrary: React.FC = () => {
isSelected={selectedIds.has(product.id)}
batchMode={batchMode}
onToggleSelect={handleToggleSelect}
onPlay={setPlayingProduct}
onDownload={handleDownload}
onShare={handleShare}
onDelete={handleDelete}
@@ -158,17 +150,6 @@ const ProductLibrary: React.FC = () => {
) : (
<ProductEmptyState type="empty" />
)}
{/* 视频播放弹窗 */}
{playingProduct && (
<VideoPlayer
product={playingProduct}
onClose={() => setPlayingProduct(null)}
onDownload={handleDownload}
onShare={handleShare}
onViewDetail={handleViewDetail}
/>
)}
</div>
)
}
@@ -1,4 +1,4 @@
import React from "react"
import React, { useState, useRef, useCallback, useEffect } from "react"
import { Popconfirm } from "antd"
import {
CheckOutlined,
@@ -7,6 +7,7 @@ import {
ShareAltOutlined,
DeleteOutlined,
CloudUploadOutlined,
VideoCameraOutlined,
} from "@ant-design/icons"
import type { ProductItem } from "../types"
import { statusConfig, reviewStatusConfig } from "../constants"
@@ -17,7 +18,6 @@ interface ProductCardProps {
isSelected: boolean
batchMode: boolean
onToggleSelect: (id: string) => void
onPlay: (product: ProductItem) => void
onDownload: (product: ProductItem) => void
onShare: (product: ProductItem) => void
onDelete: (id: string) => void
@@ -30,7 +30,6 @@ export const ProductCard: React.FC<ProductCardProps> = ({
isSelected,
batchMode,
onToggleSelect,
onPlay,
onDownload,
onShare,
onDelete,
@@ -38,17 +37,39 @@ export const ProductCard: React.FC<ProductCardProps> = ({
onReviewStatusChange,
}) => {
const st = statusConfig[product.status]
const videoRef = useRef<HTMLVideoElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [aspectRatio, setAspectRatio] = useState<string | null>(null)
const [hasVideo, setHasVideo] = useState(!!product.videoUrl)
/** 查重率样式 */
const dupClass =
product.duplicateRate <= 5 ? "good" : product.duplicateRate <= 15 ? "warn" : "bad"
/** 点击卡片 */
const handleCardClick = () => {
if (batchMode) {
onToggleSelect(product.id)
/** 视频元数据加载后获取实际比例 */
const handleVideoLoaded = useCallback(() => {
const v = videoRef.current
if (v && v.videoWidth > 0 && v.videoHeight > 0) {
setAspectRatio(`${v.videoWidth} / ${v.videoHeight}`)
}
}, [])
/** 视频播放/暂停结束事件 */
const handlePlay = useCallback(() => setIsPlaying(true), [])
const handlePause = useCallback(() => setIsPlaying(false), [])
/** 视频出错时降级为缩略图 */
const handleVideoError = useCallback(() => setHasVideo(false), [])
/** 点击缩略图区域:有视频则内联播放,否则不响应 */
const handleThumbClick = () => {
if (batchMode) return
if (!hasVideo || !videoRef.current) return
const v = videoRef.current
if (v.paused) {
v.play()
} else {
onPlay(product)
v.pause()
}
}
@@ -58,6 +79,24 @@ export const ProductCard: React.FC<ProductCardProps> = ({
onToggleSelect(product.id)
}
/** 卡片容器点击(非批量模式下不再触发弹窗播放) */
const handleCardClick = () => {
if (batchMode) {
onToggleSelect(product.id)
}
}
/** 组件卸载时暂停视频 */
useEffect(() => {
const v = videoRef.current
return () => {
v?.pause()
}
}, [])
/** 缩略图区域 style:有实际比例则用实际比例,否则默认 16:9 */
const thumbStyle: React.CSSProperties = aspectRatio ? { aspectRatio } : { aspectRatio: "16 / 9" }
return (
<div
className={`xx-product-card${isSelected ? " selected" : ""}${product.isPublished ? " published" : ""}`}
@@ -102,21 +141,61 @@ export const ProductCard: React.FC<ProductCardProps> = ({
</div>
)}
{/* 缩略图 */}
<div className="xx-product-thumb">
{product.thumbnailUrl ? (
<img
className="xx-product-thumb-bg"
src={product.thumbnailUrl}
alt={product.name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
{/* 视频/缩略图区域 */}
<div className="xx-product-thumb" style={thumbStyle}>
{hasVideo ? (
<>
<video
ref={videoRef}
className="xx-product-thumb-video"
src={product.videoUrl}
preload="metadata"
onLoadedMetadata={handleVideoLoaded}
onPlay={handlePlay}
onPause={handlePause}
onEnded={handlePause}
onError={handleVideoError}
controls={isPlaying}
onClick={(e) => {
e.stopPropagation()
handleThumbClick()
}}
/>
{/* 未播放时显示播放按钮覆盖层 */}
{!isPlaying && (
<div className="xx-product-play" onClick={handleThumbClick}>
<PlayCircleOutlined />
</div>
)}
</>
) : (
<div className="xx-product-thumb-bg" style={{ background: product.gradient }} />
<>
{product.thumbnailUrl ? (
<img
className="xx-product-thumb-bg"
src={product.thumbnailUrl}
alt={product.name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : (
<div
className="xx-product-thumb-bg"
style={{
background: product.gradient,
display: "grid",
placeItems: "center",
color: "rgba(255,255,255,0.3)",
fontSize: "48px",
}}
>
<VideoCameraOutlined />
</div>
)}
<div className="xx-product-play">
<PlayCircleOutlined />
</div>
</>
)}
<div className="xx-product-play">
<PlayCircleOutlined />
</div>
{product.duration > 0 && (
<span className="xx-product-duration">{formatTime(product.duration)}</span>
)}
+14 -2
View File
@@ -260,13 +260,13 @@
/* 缩略图区域 */
.xx-product-thumb {
aspect-ratio: 9 / 16;
max-height: 220px;
position: relative;
overflow: hidden;
display: grid;
place-items: center;
color: var(--text-inverse);
background: var(--color-gray-950);
max-height: 320px;
}
.xx-product-thumb-bg {
@@ -276,6 +276,18 @@
background-position: center;
}
/* 内联视频播放器 */
.xx-product-thumb-video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.xx-product-thumb-video[controls] {
cursor: pointer;
}
.xx-product-play {
position: relative;
z-index: 1;