gongxiangp2/components/upload/DetailImageSorter.tsx
2026-08-03 09:53:20 +08:00

267 lines
8.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;
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>
);
}