优化素材库视频筛选加载

This commit is contained in:
chenpei 2026-08-21 12:36:02 +08:00
parent 524024f7d6
commit 0f319f7b68
3 changed files with 79 additions and 68 deletions

View File

@ -140,6 +140,39 @@ describe('MaterialCard', () => {
expect(screen.getByText('15天后可查看详情')).toBeInTheDocument()
})
test('视频卡片初始只加载封面,悬停时才挂载视频预览', () => {
const { container } = render(
<MaterialCard
{...defaultProps}
material={{
...baseMaterial,
materialType: '淘系-逛逛视频',
videoCoverUrl: 'https://example.com/video-cover.jpg',
videoPreviewClipUrl: 'https://example.com/video-preview.mp4',
videoProcessingStatus: 'done',
}}
/>,
)
const previewArea = container.querySelector('[data-material-link="true"]')
expect(previewArea).not.toBeNull()
expect(screen.getByAltText('SPU-001 视频封面')).toHaveAttribute(
'src',
'https://example.com/video-cover.jpg',
)
expect(screen.queryByLabelText('SPU-001 视频预览')).not.toBeInTheDocument()
fireEvent.mouseEnter(previewArea!)
expect(screen.getByLabelText('SPU-001 视频预览')).toHaveAttribute(
'src',
'https://example.com/video-preview.mp4',
)
fireEvent.mouseLeave(previewArea!)
expect(screen.queryByLabelText('SPU-001 视频预览')).not.toBeInTheDocument()
expect(screen.getByAltText('SPU-001 视频封面')).toBeInTheDocument()
})
test('15天保护期内仍可复制分享链接', () => {
render(
<MaterialCard

View File

@ -1,6 +1,6 @@
'use client'
import React, { useRef, useEffect, useState } from 'react'
import React, { useEffect, useState } from 'react'
import Image from 'next/image'
import { format } from 'date-fns'
import AudioVisualizer from '../AudioVisualizer'
@ -148,8 +148,8 @@ export default function MaterialCard({
onRetryTranscode,
showToast,
}: MaterialCardProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const [posterLoadError, setPosterLoadError] = useState(false)
const [shouldLoadVideoPreview, setShouldLoadVideoPreview] = useState(false)
// 判断素材类型
const isVideoType = isVideoMaterialType(material.materialType)
@ -173,30 +173,11 @@ export default function MaterialCard({
reportTitle: material.reportTitle,
})
// 检测封面图是否可加载
// 素材切换时回到封面态;视频只在用户悬停时加载,避免列表首屏并发预载全部视频。
useEffect(() => {
if (isVideoType && posterUrl && typeof window !== 'undefined') {
setPosterLoadError(false)
const img = document.createElement('img')
img.onload = () => {
setPosterLoadError(false)
}
img.onerror = () => {
console.error('[MaterialCard] 视频封面图预加载失败:', {
materialId: material.id,
productModel: materialDisplayTitle,
videoCoverUrl: material.videoCoverUrl,
previewUrl: material.previewUrl,
posterUrl: posterUrl,
timestamp: new Date().toISOString()
})
setPosterLoadError(true)
}
img.src = posterUrl
} else {
setPosterLoadError(false)
}
}, [isVideoType, posterUrl, material.id, materialDisplayTitle, material.videoCoverUrl, material.previewUrl])
setPosterLoadError(false)
setShouldLoadVideoPreview(false)
}, [material.id, posterUrl])
// 音频URL(API已经处理过,直接使用)
let audioUrl = ''
@ -218,18 +199,13 @@ export default function MaterialCard({
onClick(material)
}
// 视频鼠标悬停播放控制
// 视频鼠标悬停播放控制:离开后卸载 video,立即释放媒体连接。
const handleVideoMouseEnter = () => {
if (videoRef.current) {
videoRef.current.play().catch(() => {})
}
setShouldLoadVideoPreview(true)
}
const handleVideoMouseLeave = () => {
if (videoRef.current) {
videoRef.current.pause()
videoRef.current.currentTime = 0
}
setShouldLoadVideoPreview(false)
}
// 复制ID到剪贴板
@ -376,28 +352,43 @@ export default function MaterialCard({
{/* 视频类型 */}
{isVideoType && videoPreviewUrl ? (
<>
<video
ref={videoRef}
src={videoPreviewUrl}
poster={!posterLoadError && posterUrl ? posterUrl : undefined}
className="h-full w-full object-cover"
muted
loop
playsInline
preload="metadata"
onError={(e) => {
// 视频加载失败时的错误处理
console.error('[MaterialCard] 视频加载失败:', {
materialId: material.id,
productModel: materialDisplayTitle,
videoPreviewUrl: videoPreviewUrl,
posterUrl: posterUrl,
timestamp: new Date().toISOString()
})
}}
/>
{/* 视频封面加载失败时的占位符 */}
{posterLoadError && (
{shouldLoadVideoPreview ? (
<video
src={videoPreviewUrl}
poster={!posterLoadError && posterUrl ? posterUrl : undefined}
aria-label={`${materialDisplayTitle} 视频预览`}
className="h-full w-full object-cover"
muted
loop
playsInline
autoPlay
preload="metadata"
onError={() => {
console.error('[MaterialCard] 视频加载失败:', {
materialId: material.id,
productModel: materialDisplayTitle,
timestamp: new Date().toISOString()
})
}}
/>
) : !posterLoadError && posterUrl ? (
<Image
src={posterUrl}
alt={`${materialDisplayTitle} 视频封面`}
fill
unoptimized
className="object-cover"
sizes="(max-width: 768px) 100vw, 300px"
onError={() => {
console.error('[MaterialCard] 视频封面图加载失败:', {
materialId: material.id,
productModel: materialDisplayTitle,
timestamp: new Date().toISOString()
})
setPosterLoadError(true)
}}
/>
) : (
<div className="absolute inset-0 w-full h-full flex items-center justify-center bg-[#eceef0] z-10">
<span className="text-[#86898e] text-sm">封面加载失败</span>
</div>

View File

@ -233,12 +233,6 @@ export function rewriteStorageUrlForClient(url?: string | null, request?: NextRe
const decodedPath = decodeURIComponent(parsed.pathname)
// 手动构建URL字符串,避免toString()自动编码
const decodedUrl = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}${decodedPath}${parsed.search || ''}${parsed.hash || ''}`
console.log('[URL Rewrite] 解码内网MinIO路径:', {
original: trimmed,
originalPath: parsed.pathname,
decodedPath,
decodedUrl
})
return decodedUrl
} catch {
// 解码失败,保持原路径(可能部分编码)
@ -278,17 +272,10 @@ export function rewriteStorageUrlForClient(url?: string | null, request?: NextRe
if (isAlreadyEncoded) {
// 路径已经编码过,先解码(MinIO期望未编码的路径)
try {
const originalPath = parsed.pathname
const decodedPath = decodeURIComponent(parsed.pathname)
// 手动构建URL字符串,避免toString()自动编码
// 格式:protocol://hostname:port/pathname?search#hash
const decodedUrl = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}${decodedPath}${parsed.search || ''}${parsed.hash || ''}`
console.log('[URL Rewrite] 解码公网S3路径:', {
original: trimmed,
originalPath,
decodedPath,
decodedUrl
})
return decodedUrl
} catch {
// 解码失败,保持原路径