267 lines
8.3 KiB
TypeScript
267 lines
8.3 KiB
TypeScript
/**
|
||
* 详情图排序组件
|
||
* 左侧:实时预览(按顺序拼接显示)
|
||
* 右侧:可拖拽缩略图
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import { useState, useRef, useEffect } from 'react';
|
||
|
||
interface DetailImage {
|
||
id: string;
|
||
file: File;
|
||
preview: string;
|
||
previewStatus: 'loading' | 'ready' | 'unavailable';
|
||
previewMessage?: string;
|
||
sortOrder: number;
|
||
}
|
||
|
||
interface DetailImageSorterProps {
|
||
images: DetailImage[];
|
||
onChange: (images: DetailImage[]) => void;
|
||
onRemove: (id: string) => void;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
function DetailImagePreview({
|
||
image,
|
||
alt,
|
||
compact = false,
|
||
}: {
|
||
image: DetailImage;
|
||
alt: string;
|
||
compact?: boolean;
|
||
}) {
|
||
const status = image.previewStatus;
|
||
if (status === 'ready' && image.preview) {
|
||
return (
|
||
<img
|
||
src={image.preview}
|
||
alt={alt}
|
||
className={compact ? 'h-32 w-full object-contain' : 'block w-full'}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const statusLabel = status === 'loading' ? '正在生成缩略图' : '预览不可用';
|
||
return (
|
||
<div
|
||
role="img"
|
||
aria-label={`${image.file.name}:${statusLabel}`}
|
||
className={`flex w-full min-w-0 flex-col items-center justify-center gap-1 bg-gray-100 px-2 text-center dark:bg-gray-700/70 ${compact ? 'h-32' : 'h-36'}`}
|
||
>
|
||
<svg aria-hidden="true" className="h-6 w-6 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 5a2 2 0 012-2h8l6 6v10a2 2 0 01-2 2H6a2 2 0 01-2-2V5z" />
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M14 3v6h6M8 15l2-2 2 2 2-3 2 3" />
|
||
</svg>
|
||
<span className="w-full truncate text-xs font-medium text-gray-600 dark:text-gray-300" title={image.file.name}>
|
||
{image.file.name}
|
||
</span>
|
||
<span className="text-[11px] text-gray-400 dark:text-gray-500">
|
||
{image.previewMessage || statusLabel}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function DetailImageSorter({
|
||
images,
|
||
onChange,
|
||
onRemove,
|
||
disabled = false,
|
||
}: DetailImageSorterProps) {
|
||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||
const previewRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 按 sortOrder 排序
|
||
const sortedImages = [...images].sort((a, b) => a.sortOrder - b.sortOrder);
|
||
|
||
// 拖拽开始
|
||
const handleDragStart = (e: React.DragEvent, index: number) => {
|
||
if (disabled) return;
|
||
setDraggedIndex(index);
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
e.stopPropagation(); // 阻止事件冒泡,避免触发外层文件拖拽
|
||
};
|
||
|
||
// 拖拽经过
|
||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||
if (disabled) {
|
||
return;
|
||
}
|
||
// 没有内部排序项时,说明是从资源管理器拖入的新文件。
|
||
// 此时不能拦截事件,交给外层详情图上传区域处理。
|
||
if (draggedIndex === null) {
|
||
return;
|
||
}
|
||
|
||
e.preventDefault();
|
||
e.stopPropagation(); // 阻止事件冒泡
|
||
e.dataTransfer.dropEffect = 'move';
|
||
setHoveredIndex(index);
|
||
};
|
||
|
||
// 拖拽离开
|
||
const handleDragLeave = (e: React.DragEvent) => {
|
||
if (disabled) {
|
||
return;
|
||
}
|
||
if (draggedIndex === null) {
|
||
return;
|
||
}
|
||
|
||
e.stopPropagation(); // 阻止事件冒泡
|
||
setHoveredIndex(null);
|
||
};
|
||
|
||
// 放置
|
||
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
|
||
if (disabled) {
|
||
return;
|
||
}
|
||
// 外部文件放置必须继续冒泡到 ProductPackageUploader,
|
||
// 否则已有详情图区域会变成无法追加图片的“死区”。
|
||
if (draggedIndex === null) {
|
||
return;
|
||
}
|
||
|
||
e.preventDefault();
|
||
e.stopPropagation(); // 阻止事件冒泡,避免触发外层文件拖拽
|
||
|
||
if (draggedIndex === dropIndex) {
|
||
setDraggedIndex(null);
|
||
setHoveredIndex(null);
|
||
return;
|
||
}
|
||
|
||
// 重新排序
|
||
const newImages = [...sortedImages];
|
||
const [draggedItem] = newImages.splice(draggedIndex, 1);
|
||
newImages.splice(dropIndex, 0, draggedItem);
|
||
|
||
// 更新 sortOrder
|
||
const updatedImages = newImages.map((img, idx) => ({
|
||
...img,
|
||
sortOrder: idx,
|
||
}));
|
||
|
||
onChange(updatedImages);
|
||
setDraggedIndex(null);
|
||
setHoveredIndex(null);
|
||
};
|
||
|
||
// 拖拽结束
|
||
const handleDragEnd = (e: React.DragEvent) => {
|
||
e.stopPropagation(); // 阻止事件冒泡
|
||
setDraggedIndex(null);
|
||
setHoveredIndex(null);
|
||
};
|
||
|
||
// 点击缩略图滚动到对应位置
|
||
const scrollToImage = (index: number) => {
|
||
if (!previewRef.current) return;
|
||
|
||
const previewItem = previewRef.current.querySelector<HTMLElement>(`[data-detail-preview-index="${index}"]`);
|
||
if (previewItem) {
|
||
previewItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}
|
||
};
|
||
|
||
if (sortedImages.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className="grid grid-cols-3 gap-4"
|
||
onDragOver={(e) => {
|
||
if (draggedIndex !== null) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}
|
||
}}
|
||
onDrop={(e) => {
|
||
if (draggedIndex !== null) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setDraggedIndex(null);
|
||
setHoveredIndex(null);
|
||
}
|
||
}}
|
||
>
|
||
{/* 左侧:实时预览(占2/3宽度) */}
|
||
<div className="col-span-2 border rounded-lg p-4 bg-gray-50 dark:bg-gray-800">
|
||
<h4 className="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">预览效果</h4>
|
||
<div
|
||
ref={previewRef}
|
||
className="max-h-[600px] overflow-y-auto"
|
||
>
|
||
{sortedImages.map((img, index) => (
|
||
<div key={img.id} data-detail-preview-index={index} className="relative overflow-hidden rounded bg-white dark:bg-gray-800">
|
||
<DetailImagePreview image={img} alt={`详情图 ${index + 1}`} />
|
||
<div className="absolute top-2 left-2 bg-black bg-opacity-60 text-white text-xs px-2 py-1 rounded">
|
||
{index + 1}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧:可拖拽缩略图(单列,占1/3宽度) */}
|
||
<div className="col-span-1 border rounded-lg p-4 dark:border-gray-700">
|
||
<h4 className="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">拖拽排序</h4>
|
||
<div className="space-y-2 max-h-[600px] overflow-y-auto">
|
||
{sortedImages.map((img, index) => (
|
||
<div
|
||
key={img.id}
|
||
draggable={!disabled}
|
||
onDragStart={(e) => handleDragStart(e, index)}
|
||
onDragOver={(e) => handleDragOver(e, index)}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={(e) => handleDrop(e, index)}
|
||
onDragEnd={handleDragEnd}
|
||
onClick={() => scrollToImage(index)}
|
||
className={`
|
||
relative border-2 rounded-lg overflow-hidden
|
||
${disabled ? 'cursor-not-allowed opacity-60' : 'cursor-move'}
|
||
transition-all duration-200
|
||
${draggedIndex === index ? 'opacity-50 scale-95' : ''}
|
||
${hoveredIndex === index && draggedIndex !== null ? 'border-blue-500 scale-105' : 'border-gray-200 dark:border-gray-600'}
|
||
hover:border-blue-300 dark:hover:border-blue-500
|
||
`}
|
||
>
|
||
<DetailImagePreview image={img} alt={`缩略图 ${index + 1}`} compact />
|
||
|
||
{/* 序号 */}
|
||
<div className="absolute top-1 left-1 bg-black bg-opacity-60 text-white text-xs px-2 py-1 rounded">
|
||
{index + 1}
|
||
</div>
|
||
|
||
{/* 删除按钮 */}
|
||
<button
|
||
type="button"
|
||
aria-label={`删除详情图 ${index + 1}`}
|
||
disabled={disabled}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onRemove(img.id);
|
||
}}
|
||
className="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 text-sm hover:bg-red-600 flex items-center justify-center"
|
||
>
|
||
×
|
||
</button>
|
||
|
||
{/* 拖拽提示 */}
|
||
<div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white text-xs text-center py-1">
|
||
拖拽排序
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|