300 lines
12 KiB
TypeScript
300 lines
12 KiB
TypeScript
/**
|
||
* 任务-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>
|
||
);
|
||
}
|