242 lines
7.3 KiB
TypeScript
242 lines
7.3 KiB
TypeScript
/**
|
||
* 详情图排序组件
|
||
* 左侧:实时预览(按顺序拼接显示)
|
||
* 右侧:可拖拽缩略图
|
||
*/
|
||
|
||
'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>
|
||
);
|
||
}
|