781 lines
33 KiB
TypeScript
781 lines
33 KiB
TypeScript
/**
|
||
* 任务-详情详情组件
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import { useState, useEffect, useRef } from 'react';
|
||
import { parseDownloadError } from '@/lib/download-errors';
|
||
import { getTaskDetailSourceTypeLabel } from '@/lib/task-detail-source-type';
|
||
import {
|
||
detailInkButtonClass,
|
||
detailOutlineButtonClass,
|
||
detailMetaLabelClass,
|
||
detailMetaValueClass,
|
||
detailPanelClass,
|
||
detailSectionTitleClass,
|
||
detailThumbActiveClass,
|
||
detailThumbIdleClass,
|
||
} from '@/components/detail/detail-styles';
|
||
|
||
interface Asset {
|
||
id: string;
|
||
assetType: string;
|
||
fileUrl: string;
|
||
fileName: string;
|
||
fileSize: number;
|
||
sortOrder: number;
|
||
}
|
||
|
||
type AppendableGroup = 'main' | 'sku';
|
||
|
||
type DetailInsertPosition = {
|
||
group: 'detail';
|
||
afterAssetId: string;
|
||
beforeAssetId: string;
|
||
};
|
||
|
||
interface ProductPackageDetailProps {
|
||
material: {
|
||
id: string;
|
||
taskNo: string;
|
||
productModel: string;
|
||
categoryName: string;
|
||
sceneName?: string;
|
||
taskDetailSourceType?: string | null;
|
||
isFullSet?: boolean;
|
||
taskStatus: string;
|
||
taskFetchedAt?: string;
|
||
assets: Asset[];
|
||
};
|
||
canDownloadSource: boolean;
|
||
onDownload: (type: 'all' | 'main' | 'sku' | 'detail' | 'psd', assetId?: string) => Promise<void>;
|
||
onBack?: () => void;
|
||
showToast?: (message: string, options?: { variant?: 'success' | 'error' }) => void;
|
||
/** 单图编辑权限(上传者 / 托管部门成员 / 系统管理员),由服务端判定 */
|
||
canManageAssets?: boolean;
|
||
/** 正在编辑处理的资产 id,用于禁用与遮罩 */
|
||
editingAssetId?: string | null;
|
||
onRequestReplace?: (asset: Asset) => void;
|
||
onRequestRename?: (asset: Asset) => void;
|
||
onRequestDelete?: (asset: Asset) => void;
|
||
onRequestAppend?: (group: AppendableGroup) => void;
|
||
onRequestInsert?: (position: DetailInsertPosition) => void;
|
||
/** 已加入待提交清单的资产 id,缩略图上显示待提交标记 */
|
||
pendingAssetIds?: string[];
|
||
}
|
||
|
||
function AddImageButton({
|
||
label,
|
||
hint,
|
||
onClick,
|
||
disabled,
|
||
className,
|
||
}: {
|
||
label: string;
|
||
hint: string;
|
||
onClick: () => void;
|
||
disabled: boolean;
|
||
className: string;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={onClick}
|
||
aria-label={`${label},${hint}`}
|
||
className={`${className} flex items-center justify-center rounded-[8px] border border-dashed border-[#cfd6dd] bg-[#fafafa] text-center text-[#4f6077] transition-colors hover:border-[#1a1c1d] hover:bg-white hover:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50`}
|
||
>
|
||
<span className="flex flex-col items-center gap-1 px-2">
|
||
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" aria-hidden="true">
|
||
<path d="M10 4v12M4 10h12" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.6" />
|
||
</svg>
|
||
<span className="text-[12px] font-medium tracking-[-0.02em]">{label}</span>
|
||
<span className="text-[10px] leading-4 text-[#86898e]">{hint}</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function DetailInsertButton({
|
||
disabled,
|
||
onClick,
|
||
afterIndex,
|
||
beforeIndex,
|
||
}: {
|
||
disabled: boolean;
|
||
onClick: () => void;
|
||
afterIndex: number;
|
||
beforeIndex: number;
|
||
}) {
|
||
return (
|
||
<div className="pointer-events-none relative z-20 h-0">
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={onClick}
|
||
title="插入图片"
|
||
aria-label={`在详情图 ${afterIndex} 和详情图 ${beforeIndex} 之间插入图片`}
|
||
className="pointer-events-auto absolute left-1/2 top-0 flex h-10 w-10 -translate-x-1/2 -translate-y-1/2 items-center justify-center text-[#4f6077] transition-colors hover:text-[#1a1c1d] focus-visible:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-30"
|
||
>
|
||
<span className="flex h-7 w-7 items-center justify-center rounded-[6px] border border-[#aeb4ba] bg-white/95 shadow-[0_1px_3px_rgba(15,23,42,0.18)] transition-colors hover:border-[#1a1c1d]">
|
||
<svg viewBox="0 0 20 20" className="h-3.5 w-3.5" fill="none" stroke="currentColor" aria-hidden="true">
|
||
<path d="M10 5v10M5 10h10" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.6" />
|
||
</svg>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AssetEditMenu({
|
||
asset,
|
||
busy,
|
||
disabled,
|
||
onRequestReplace,
|
||
onRequestRename,
|
||
onRequestDelete,
|
||
}: {
|
||
asset: Asset;
|
||
busy: boolean;
|
||
disabled: boolean;
|
||
onRequestReplace: (asset: Asset) => void;
|
||
onRequestRename: (asset: Asset) => void;
|
||
onRequestDelete: (asset: Asset) => void;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
|
||
if (disabled) {
|
||
return busy ? (
|
||
<span className="absolute right-1 top-1 z-10 rounded-[6px] bg-white/90 px-1.5 py-0.5 text-[11px] text-[#6b7075]">
|
||
处理中…
|
||
</span>
|
||
) : null;
|
||
}
|
||
|
||
return (
|
||
<span className="absolute right-1 top-1 z-10">
|
||
<button
|
||
type="button"
|
||
aria-label={`编辑图片 ${asset.fileName}`}
|
||
className="flex h-7 w-7 items-center justify-center rounded-[6px] border border-[#e7e8ea] bg-white/95 text-[#1a1c1d] opacity-100 transition-opacity hover:border-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-1 md:opacity-0 md:group-hover:opacity-100 md:focus-visible:opacity-100"
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setOpen((value) => !value);
|
||
}}
|
||
>
|
||
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="currentColor" aria-hidden="true">
|
||
<circle cx="8" cy="3.5" r="1.4" />
|
||
<circle cx="8" cy="8" r="1.4" />
|
||
<circle cx="8" cy="12.5" r="1.4" />
|
||
</svg>
|
||
</button>
|
||
{open && (
|
||
<>
|
||
<span
|
||
className="fixed inset-0 z-20 cursor-default"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setOpen(false);
|
||
}}
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
}}
|
||
/>
|
||
<span className="absolute right-0 top-7 z-30 block w-28 overflow-hidden rounded-[8px] border border-[#e7e8ea] bg-white py-1">
|
||
{[
|
||
{ label: '替换图片', action: () => { setOpen(false); onRequestReplace(asset); } },
|
||
{ label: '重命名', action: () => { setOpen(false); onRequestRename(asset); } },
|
||
{ label: '删除图片', action: () => { setOpen(false); onRequestDelete(asset); }, danger: true },
|
||
].map((item) => (
|
||
<button
|
||
key={item.label}
|
||
type="button"
|
||
className={`block w-full px-3 py-1.5 text-left text-[12px] tracking-[-0.01em] hover:bg-[#fafafa] ${
|
||
item.danger ? 'text-[#b42318]' : 'text-[#1a1c1d]'
|
||
}`}
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
item.action();
|
||
}}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</span>
|
||
</>
|
||
)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export default function ProductPackageDetail({
|
||
material,
|
||
canDownloadSource,
|
||
onDownload,
|
||
onBack,
|
||
showToast,
|
||
canManageAssets = false,
|
||
editingAssetId = null,
|
||
onRequestReplace,
|
||
onRequestRename,
|
||
onRequestDelete,
|
||
onRequestAppend,
|
||
onRequestInsert,
|
||
pendingAssetIds = [],
|
||
}: ProductPackageDetailProps) {
|
||
// 当前预览的图片类型和索引
|
||
const [previewType, setPreviewType] = useState<'main' | 'sku'>('main');
|
||
const [currentMainIndex, setCurrentMainIndex] = useState(0);
|
||
const [currentSkuIndex, setCurrentSkuIndex] = useState(0);
|
||
const [downloading, setDownloading] = useState(false);
|
||
const [downloadType, setDownloadType] = useState<string>('');
|
||
|
||
// 轮播相关状态
|
||
const [autoPlayEnabled, setAutoPlayEnabled] = useState(true);
|
||
|
||
// 按类型分组资产(使用新的资产类型格式)
|
||
const mainImages = material.assets.filter((a) => a.assetType === 'main-preview').sort((a, b) => a.sortOrder - b.sortOrder);
|
||
const skuImages = material.assets.filter((a) => a.assetType === 'sku-preview').sort((a, b) => a.sortOrder - b.sortOrder);
|
||
const detailImages = material.assets.filter((a) => a.assetType === 'detail-preview').sort((a, b) => a.sortOrder - b.sortOrder);
|
||
const psdFiles = material.assets.filter((a) => a.assetType === 'psd');
|
||
|
||
// 轮播逻辑
|
||
const autoPlayTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||
|
||
useEffect(() => {
|
||
// 清理之前的定时器
|
||
if (autoPlayTimerRef.current) {
|
||
clearInterval(autoPlayTimerRef.current);
|
||
}
|
||
|
||
// 只在预览主图或SKU图时启用轮播
|
||
if (autoPlayEnabled) {
|
||
const currentImages = previewType === 'main' ? mainImages : skuImages;
|
||
if (currentImages.length > 1) {
|
||
autoPlayTimerRef.current = setInterval(() => {
|
||
if (previewType === 'main') {
|
||
setCurrentMainIndex((prev) => (prev + 1) % mainImages.length);
|
||
} else {
|
||
setCurrentSkuIndex((prev) => (prev + 1) % skuImages.length);
|
||
}
|
||
}, 5000); // 5秒轮播
|
||
}
|
||
}
|
||
|
||
return () => {
|
||
if (autoPlayTimerRef.current) {
|
||
clearInterval(autoPlayTimerRef.current);
|
||
}
|
||
};
|
||
}, [previewType, currentMainIndex, currentSkuIndex, autoPlayEnabled, mainImages.length, skuImages.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 className="flex items-start">
|
||
<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>
|
||
)}
|
||
|
||
<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">
|
||
{/* 图片预览区(可显示主图或SKU图) */}
|
||
{((previewType === 'main' && mainImages.length > 0) || (previewType === 'sku' && skuImages.length > 0)) && (
|
||
<div className={`${detailPanelClass} p-4`}>
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h4 className={detailSectionTitleClass}>
|
||
{previewType === 'main' ? `主图 (${currentMainIndex + 1}/${mainImages.length})` : `SKU图 (${currentSkuIndex + 1}/${skuImages.length})`}
|
||
</h4>
|
||
{((previewType === 'main' && mainImages.length > 1) || (previewType === 'sku' && skuImages.length > 1)) && (
|
||
<button
|
||
onClick={() => setAutoPlayEnabled(!autoPlayEnabled)}
|
||
className="text-[12px] text-[#86898e] hover:text-[#1a1c1d]"
|
||
type="button"
|
||
>
|
||
{autoPlayEnabled ? '暂停轮播' : '开始轮播'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="relative">
|
||
<img
|
||
src={
|
||
previewType === 'main'
|
||
? mainImages[currentMainIndex]?.fileUrl
|
||
: skuImages[currentSkuIndex]?.fileUrl
|
||
}
|
||
alt={previewType === 'main' ? `主图 ${currentMainIndex + 1}` : `SKU图 ${currentSkuIndex + 1}`}
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 主图小卡片展示区 */}
|
||
{(mainImages.length > 0 || (canManageAssets && onRequestAppend)) && (
|
||
<div className={`${detailPanelClass} p-4`}>
|
||
<h4 className={detailSectionTitleClass}>主图 ({mainImages.length})</h4>
|
||
<div className="flex flex-wrap gap-2">
|
||
{mainImages.map((img, index) => (
|
||
<div
|
||
key={img.id}
|
||
className="group relative flex-shrink-0"
|
||
style={{ width: '84px', height: '84px' }}
|
||
>
|
||
<button
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
// 阻止滚动行为
|
||
const currentScrollY = window.scrollY;
|
||
setPreviewType('main');
|
||
setCurrentMainIndex(index);
|
||
setAutoPlayEnabled(false); // 手动切换时暂停轮播
|
||
// 保持滚动位置
|
||
requestAnimationFrame(() => {
|
||
window.scrollTo(0, currentScrollY);
|
||
});
|
||
}}
|
||
onMouseDown={(e) => {
|
||
// 阻止默认的鼠标按下行为
|
||
e.preventDefault();
|
||
}}
|
||
className={`absolute inset-0 overflow-hidden rounded-[8px] ${
|
||
previewType === 'main' && index === currentMainIndex
|
||
? detailThumbActiveClass
|
||
: detailThumbIdleClass
|
||
}`}
|
||
type="button"
|
||
>
|
||
<img
|
||
src={img.fileUrl}
|
||
alt={`主图 ${index + 1}`}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
{previewType === 'main' && index === currentMainIndex && (
|
||
<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>
|
||
{canManageAssets && onRequestReplace && onRequestRename && onRequestDelete && (
|
||
<>
|
||
<AssetEditMenu
|
||
asset={img}
|
||
busy={editingAssetId === img.id}
|
||
disabled={Boolean(editingAssetId)}
|
||
onRequestReplace={onRequestReplace}
|
||
onRequestRename={onRequestRename}
|
||
onRequestDelete={onRequestDelete}
|
||
/>
|
||
{editingAssetId === img.id && (
|
||
<span className="pointer-events-none absolute inset-0 z-0 animate-pulse rounded-[8px] border border-[#1a1c1d]/30 bg-white/60" />
|
||
)}
|
||
{pendingAssetIds.includes(img.id) && (
|
||
<span className="pointer-events-none absolute left-1 top-1 z-10 rounded bg-[#1a1c1d]/80 px-1.5 py-0.5 text-[10px] font-medium text-white">待提交</span>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
{canManageAssets && onRequestAppend && (
|
||
<AddImageButton
|
||
label="追加图片"
|
||
hint="加入主图末尾"
|
||
disabled={Boolean(editingAssetId)}
|
||
onClick={() => onRequestAppend('main')}
|
||
className="h-[84px] w-[84px] flex-shrink-0"
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 详情图区域(使用页面整体滚动,图片无缝拼接) */}
|
||
{detailImages.length > 0 && (
|
||
<div className={`${detailPanelClass} p-4`}>
|
||
<h4 className={detailSectionTitleClass}>详情图 ({detailImages.length})</h4>
|
||
<div className="space-y-0">
|
||
{detailImages.map((img, index) => (
|
||
<div key={img.id}>
|
||
<div className="group relative">
|
||
<img
|
||
src={img.fileUrl}
|
||
alt={`详情图 ${index + 1}`}
|
||
className="w-full block"
|
||
style={{ display: 'block' }}
|
||
/>
|
||
<div className="absolute left-2 top-2 bg-[#1a1c1d] px-2 py-1 text-[11px] text-white">
|
||
{index + 1}
|
||
</div>
|
||
{canManageAssets && onRequestReplace && onRequestRename && onRequestDelete && (
|
||
<>
|
||
<AssetEditMenu
|
||
asset={img}
|
||
busy={editingAssetId === img.id}
|
||
disabled={Boolean(editingAssetId)}
|
||
onRequestReplace={onRequestReplace}
|
||
onRequestRename={onRequestRename}
|
||
onRequestDelete={onRequestDelete}
|
||
/>
|
||
{editingAssetId === img.id && (
|
||
<span className="pointer-events-none absolute inset-x-0 top-0 z-0 flex items-center justify-center bg-white/60 py-2 text-[12px] text-[#6b7075]">
|
||
正在上传临时图片,请稍候…
|
||
</span>
|
||
)}
|
||
{pendingAssetIds.includes(img.id) && (
|
||
<span className="pointer-events-none absolute left-1 top-1 z-10 rounded bg-[#1a1c1d]/80 px-1.5 py-0.5 text-[10px] font-medium text-white">待提交</span>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
{canManageAssets && onRequestInsert && detailImages[index + 1] && (
|
||
<DetailInsertButton
|
||
disabled={Boolean(editingAssetId)}
|
||
afterIndex={index + 1}
|
||
beforeIndex={index + 2}
|
||
onClick={() => onRequestInsert({
|
||
group: 'detail',
|
||
afterAssetId: img.id,
|
||
beforeAssetId: detailImages[index + 1].id,
|
||
})}
|
||
/>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 右侧:基本信息 + SKU图 + 下载按钮 */}
|
||
<div className="space-y-6">
|
||
{/* 基本信息卡片 */}
|
||
<div data-detail-meta 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>
|
||
<div>
|
||
<p className={detailMetaLabelClass}>SPU型号</p>
|
||
<p className={detailMetaValueClass}>{material.productModel}</p>
|
||
</div>
|
||
<div>
|
||
<p className={detailMetaLabelClass}>店铺</p>
|
||
<p className={detailMetaValueClass}>{material.categoryName}</p>
|
||
</div>
|
||
{material.sceneName && (
|
||
<div>
|
||
<p className={detailMetaLabelClass}>场景</p>
|
||
<p className={detailMetaValueClass}>{material.sceneName}</p>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<p className={detailMetaLabelClass}>出图方式</p>
|
||
<p className={detailMetaValueClass}>
|
||
{getTaskDetailSourceTypeLabel(material.taskDetailSourceType)}
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<p className={detailMetaLabelClass}>是否整套</p>
|
||
<p className={detailMetaValueClass}>
|
||
{material.isFullSet ? '是' : '否'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* SKU图小卡片展示区 */}
|
||
{(skuImages.length > 0 || (canManageAssets && onRequestAppend)) && (
|
||
<div className={`${detailPanelClass} p-4`}>
|
||
<h4 className={detailSectionTitleClass}>SKU图 ({skuImages.length})</h4>
|
||
<div className="grid grid-cols-4 gap-2">
|
||
{skuImages.map((img, index) => (
|
||
<div key={img.id} className="group relative aspect-square w-full">
|
||
<button
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setPreviewType('sku');
|
||
setCurrentSkuIndex(index);
|
||
setAutoPlayEnabled(false); // 手动切换时暂停轮播
|
||
}}
|
||
className={`absolute inset-0 overflow-hidden rounded-[8px] ${
|
||
previewType === 'sku' && index === currentSkuIndex
|
||
? detailThumbActiveClass
|
||
: detailThumbIdleClass
|
||
}`}
|
||
type="button"
|
||
>
|
||
<img
|
||
src={img.fileUrl}
|
||
alt={`SKU图 ${index + 1}`}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
{previewType === 'sku' && index === currentSkuIndex && (
|
||
<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>
|
||
{canManageAssets && onRequestReplace && onRequestRename && onRequestDelete && (
|
||
<>
|
||
<AssetEditMenu
|
||
asset={img}
|
||
busy={editingAssetId === img.id}
|
||
disabled={Boolean(editingAssetId)}
|
||
onRequestReplace={onRequestReplace}
|
||
onRequestRename={onRequestRename}
|
||
onRequestDelete={onRequestDelete}
|
||
/>
|
||
{editingAssetId === img.id && (
|
||
<span className="pointer-events-none absolute inset-0 z-0 animate-pulse rounded-[8px] border border-[#1a1c1d]/30 bg-white/60" />
|
||
)}
|
||
{pendingAssetIds.includes(img.id) && (
|
||
<span className="pointer-events-none absolute left-1 top-1 z-10 rounded bg-[#1a1c1d]/80 px-1.5 py-0.5 text-[10px] font-medium text-white">待提交</span>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
{canManageAssets && onRequestAppend && (
|
||
<AddImageButton
|
||
label="追加图片"
|
||
hint="加入 SKU 末尾"
|
||
disabled={Boolean(editingAssetId)}
|
||
onClick={() => onRequestAppend('sku')}
|
||
className="aspect-square w-full"
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* PSD 文件信息、下载按钮组和返回按钮 - 使用容器实现sticky,保持相对位置 */}
|
||
<div className="space-y-6 lg:sticky lg:top-24 lg:z-10">
|
||
{/* PSD 文件信息 */}
|
||
{psdFiles.length > 0 && (
|
||
<div className={`${detailPanelClass} p-4`}>
|
||
<h4 className={detailSectionTitleClass}>PSD 源文件 ({psdFiles.length})</h4>
|
||
<div className="space-y-2">
|
||
{psdFiles.map((file) => (
|
||
<div key={file.id} 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]">{file.fileName}</p>
|
||
<p className="text-[12px] text-[#86898e]">{formatBytes(file.fileSize)}</p>
|
||
</div>
|
||
{canDownloadSource ? (
|
||
<button
|
||
onClick={async () => {
|
||
setDownloading(true);
|
||
setDownloadType('psd');
|
||
try {
|
||
await onDownload('psd', file.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"
|
||
>
|
||
下载
|
||
</button>
|
||
) : (
|
||
<span className="text-[12px] text-[#86898e]">无权限</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 下载按钮组 */}
|
||
<div className={`${detailPanelClass} p-4`}>
|
||
<h4 className={detailSectionTitleClass}>批量下载</h4>
|
||
{downloading && (
|
||
<div className="mb-3 rounded-[8px] border border-[#e7e8ea] bg-[#fafafa] p-3">
|
||
<div className="flex items-center space-x-2">
|
||
<svg className="h-4 w-4 animate-spin text-[#1a1c1d]" 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 className="text-[13px] text-[#6b7075]">
|
||
{downloadType === 'all' ? '正在准备全部文件,请稍候...' :
|
||
downloadType === 'main' ? '正在下载主图,请稍候...' :
|
||
downloadType === 'sku' ? '正在下载SKU图,请稍候...' :
|
||
downloadType === 'detail' ? '正在下载详情图,请稍候...' :
|
||
downloadType === 'psd' ? '正在下载PSD文件,请稍候...' :
|
||
'正在准备文件,请稍候...'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{mainImages.length > 0 && (
|
||
<button
|
||
onClick={async () => {
|
||
setDownloading(true);
|
||
setDownloadType('main');
|
||
try {
|
||
await onDownload('main');
|
||
// 下载完成后重置状态
|
||
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"
|
||
>
|
||
下载主图
|
||
</button>
|
||
)}
|
||
{skuImages.length > 0 && (
|
||
<button
|
||
onClick={async () => {
|
||
setDownloading(true);
|
||
setDownloadType('sku');
|
||
try {
|
||
await onDownload('sku');
|
||
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"
|
||
>
|
||
下载SKU图
|
||
</button>
|
||
)}
|
||
{detailImages.length > 0 && (
|
||
<button
|
||
onClick={async () => {
|
||
setDownloading(true);
|
||
setDownloadType('detail');
|
||
try {
|
||
await onDownload('detail');
|
||
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"
|
||
>
|
||
下载详情图
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={async () => {
|
||
setDownloading(true);
|
||
setDownloadType('all');
|
||
try {
|
||
await onDownload('all');
|
||
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"
|
||
>
|
||
下载全部
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 返回上一页按钮 */}
|
||
{onBack && (
|
||
<button
|
||
onClick={onBack}
|
||
className={`w-full ${detailOutlineButtonClass}`}
|
||
>
|
||
返回上一页
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|