gongxiangp2/components/detail/ThreeDOriginalDetail.tsx

300 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 任务-3D原图详情组件
*/
'use client';
import { useState, useEffect, useRef } from 'react';
import { parseDownloadError } from '@/lib/download-errors';
import {
detailInkButtonClass,
detailMetaLabelClass,
detailMetaValueClass,
detailOutlineButtonClass,
detailPanelClass,
detailSectionTitleClass,
detailThumbActiveClass,
detailThumbIdleClass,
} from '@/components/detail/detail-styles';
interface Asset {
id: string;
assetType: string;
fileUrl: string;
fileName: string;
fileSize: number;
sortOrder: number;
}
interface ThreeDOriginalDetailProps {
material: {
id: string;
taskNo?: string;
productModel: string;
categoryName: string;
sceneName: string;
materialType?: string;
taskStatus?: string;
taskFetchedAt?: string;
assets: Asset[];
};
canDownloadSource: boolean;
onDownload: (type: string, assetId?: string) => Promise<void>;
onBack?: () => void;
showToast?: (message: string, options?: { variant?: 'success' | 'error' }) => void;
}
export default function ThreeDOriginalDetail({
material,
canDownloadSource,
onDownload,
onBack,
showToast,
}: ThreeDOriginalDetailProps) {
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [downloading, setDownloading] = useState(false);
const [downloadType, setDownloadType] = useState<string>('');
const isTaskOther = material.materialType === '任务-其他';
// 轮播相关状态
const [autoPlayEnabled, setAutoPlayEnabled] = useState(true);
// 按类型分组资产
// ⚠️ 注意:preview是S3存储的压缩预览图(小文件),用于网页展示
// zip是本地存储的原图压缩包(大文件),用于下载
const mainImages = material.assets.filter((a) => a.assetType === 'preview').sort((a, b) => a.sortOrder - b.sortOrder);
const zipFile = material.assets.find((a) => a.assetType === 'zip');
// 轮播逻辑
const autoPlayTimerRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
// 清理之前的定时器
if (autoPlayTimerRef.current) {
clearInterval(autoPlayTimerRef.current);
}
// 只在有多张原图时启用轮播
if (autoPlayEnabled && mainImages.length > 1) {
autoPlayTimerRef.current = setInterval(() => {
setCurrentImageIndex((prev) => (prev + 1) % mainImages.length);
}, 5000); // 5秒轮播
}
return () => {
if (autoPlayTimerRef.current) {
clearInterval(autoPlayTimerRef.current);
}
};
}, [currentImageIndex, autoPlayEnabled, mainImages.length]);
// 格式化文件大小
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
return (
<div className="space-y-6">
{/* 警告:已被外部系统获取 */}
{material.taskStatus === 'fetched' && material.taskFetchedAt && (
<div className={`${detailPanelClass} p-4`}>
<div>
<h4 className="text-[13px] font-medium text-[#1a1c1d]">此素材已被外部系统获取</h4>
<p className="mt-1 text-[13px] text-[#6b7075]">
获取时间:{new Date(material.taskFetchedAt).toLocaleString('zh-CN')}
</p>
<p className="text-[13px] text-[#6b7075]">编辑或删除可能影响外部系统使用</p>
</div>
</div>
)}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(18rem,1fr)] lg:gap-6">
{/* 左侧:原图展示 */}
<div className="min-w-0 space-y-6">
{/* 原图预览区 */}
{mainImages.length > 0 && (
<div className={`${detailPanelClass} p-4`}>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium">
{isTaskOther ? '预览图' : '原图'} ({currentImageIndex + 1}/{mainImages.length})
</h4>
{mainImages.length > 1 && (
<button
onClick={() => setAutoPlayEnabled(!autoPlayEnabled)}
className="text-[12px] text-[#86898e] hover:text-[#1a1c1d]"
type="button"
>
{autoPlayEnabled ? '暂停轮播' : '开始轮播'}
</button>
)}
</div>
<div className="relative">
<img
src={mainImages[currentImageIndex]?.fileUrl}
alt={`原图 ${currentImageIndex + 1}`}
className="w-full rounded-lg"
/>
</div>
</div>
)}
{/* 原图小卡片展示区 */}
{mainImages.length > 0 && (
<div className={`${detailPanelClass} p-4`}>
<h4 className={detailSectionTitleClass}>所有原图 ({mainImages.length})</h4>
{isTaskOther && (
<p className="mb-3 text-[12px] text-[#86898e]">任务-其他只展示上传时提交的预览图</p>
)}
<div className="flex flex-wrap gap-2">
{mainImages.map((img, index) => (
<button
key={img.id}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// 阻止滚动行为
const currentScrollY = window.scrollY;
setCurrentImageIndex(index);
setAutoPlayEnabled(false); // 手动切换时暂停轮播
// 保持滚动位置
requestAnimationFrame(() => {
window.scrollTo(0, currentScrollY);
});
}}
onMouseDown={(e) => {
// 阻止默认的鼠标按下行为
e.preventDefault();
}}
className={`relative flex-shrink-0 border-2 rounded overflow-hidden ${
index === currentImageIndex
? detailThumbActiveClass
: detailThumbIdleClass
}`}
style={{ width: '84px', height: '84px' }}
type="button"
>
<img
src={img.fileUrl}
alt={`原图 ${index + 1}`}
className="w-full h-full object-cover"
/>
{index === currentImageIndex && (
<div className="absolute inset-0 flex items-center justify-center bg-[#1a1c1d]/20">
<svg className="h-6 w-6 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</div>
)}
</button>
))}
</div>
</div>
)}
</div>
{/* 右侧:基本信息 + 下载按钮 */}
<div className="space-y-6">
{/* 基本信息卡片 */}
<div className={`${detailPanelClass} p-6`}>
<h3 className="mb-4 text-[15px] font-semibold tracking-[-0.03em] text-[#1a1c1d]">素材详情</h3>
<div className="space-y-4">
<div>
<p className={detailMetaLabelClass}>任务号</p>
<p className={detailMetaValueClass}>{material.taskNo}</p>
</div>
{!isTaskOther && (
<div>
<p className={detailMetaLabelClass}>SPU型号</p>
<p className={detailMetaValueClass}>{material.productModel}</p>
</div>
)}
{!isTaskOther && (
<div>
<p className={detailMetaLabelClass}>店铺</p>
<p className={detailMetaValueClass}>{material.categoryName}</p>
</div>
)}
{!isTaskOther && material.sceneName && (
<div>
<p className={detailMetaLabelClass}>场景</p>
<p className={detailMetaValueClass}>{material.sceneName}</p>
</div>
)}
</div>
</div>
{/* ZIP 文件信息和下载按钮 - 使用容器实现sticky,保持相对位置 */}
<div className="space-y-6 lg:sticky lg:top-24 lg:z-10">
{/* ZIP 源文件 */}
{zipFile && (
<div className={`${detailPanelClass} p-4`}>
<h4 className={detailSectionTitleClass}>源文件</h4>
<div className="flex items-center justify-between rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] p-3">
<div className="flex-1">
<p className="text-[13px] font-medium text-[#1a1c1d]">{zipFile.fileName}</p>
<p className="text-[12px] text-[#86898e]">{formatBytes(zipFile.fileSize)}</p>
</div>
{canDownloadSource ? (
<button
onClick={async () => {
setDownloading(true);
setDownloadType('zip');
try {
await onDownload('zip', zipFile.id);
setTimeout(() => {
setDownloading(false);
setDownloadType('');
}, 500);
} catch (error) {
console.error('下载失败:', error);
const { message } = parseDownloadError(error);
if (showToast) {
showToast(message, { variant: 'error' });
}
setDownloading(false);
setDownloadType('');
}
}}
disabled={downloading}
className={detailInkButtonClass}
type="button"
>
{downloading && downloadType === 'zip' ? (
<div className="flex items-center space-x-2">
<svg className="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>下载中...</span>
</div>
) : (
'下载'
)}
</button>
) : (
<span className="text-[12px] text-[#86898e]">无权限</span>
)}
</div>
</div>
)}
{/* 返回上一页按钮 */}
{onBack && (
<button
onClick={onBack}
className={`w-full ${detailOutlineButtonClass}`}
>
返回上一页
</button>
)}
</div>
</div>
</div>
</div>
);
}