gongxiangp2/components/upload/DetailImageSorter.tsx

242 lines
7.3 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.

/**
* 详情图排序组件
* 左侧:实时预览(按顺序拼接显示)
* 右侧:可拖拽缩略图
*/
'use client';
import { useState, useRef, useEffect } from 'react';
interface DetailImage {
id: string;
file: File;
preview: string;
sortOrder: number;
}
interface DetailImageSorterProps {
images: DetailImage[];
onChange: (images: DetailImage[]) => void;
onRemove: (id: string) => void;
disabled?: boolean;
}
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 previewImages = previewRef.current.querySelectorAll('img');
if (previewImages[index]) {
previewImages[index].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
};
if (sortedImages.length === 0) {
return null;
}
return (
<div
className="grid grid-cols-1 gap-4 lg:grid-cols-3"
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="border rounded-lg bg-gray-50 p-4 dark:bg-gray-800 lg:col-span-2">
<h4 className="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">预览效果</h4>
<div
ref={previewRef}
className="max-h-[min(600px,60vh)] overflow-y-auto"
>
{sortedImages.map((img, index) => (
<div key={img.id} className="relative">
<img
src={img.preview}
alt={`详情图 ${index + 1}`}
className="w-full block"
style={{ display: 'block' }}
/>
<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="border rounded-lg p-4 dark:border-gray-700 lg:col-span-1">
<h4 className="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">拖拽排序</h4>
<div className="max-h-[min(600px,60vh)] space-y-2 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)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
scrollToImage(index);
}
}}
role="button"
tabIndex={disabled ? -1 : 0}
aria-label={`查看详情图 ${index + 1}`}
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
`}
>
<img
src={img.preview}
alt={`缩略图 ${index + 1}`}
className="h-32 w-full bg-gray-50 object-contain dark:bg-gray-800"
/>
{/* 序号 */}
<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>
);
}