- 创建统一的下载错误处理模块 (lib/download-errors.ts) - 实现13种错误类型分类和中文错误消息 - 后端统一使用 createDownloadError 创建标准化错误响应 - 前端统一使用 parseDownloadError 解析错误并显示 - 修复正式系统数据库Schema,添加产品全案相关字段和表 - 执行数据库迁移:添加 task_no, task_status, task_fetched_at 字段 - 创建 material_assets, api_keys, api_call_logs 表
185 lines
5.9 KiB
TypeScript
185 lines
5.9 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;
|
||
}
|
||
|
||
export default function DetailImageSorter({ images, onChange, onRemove }: 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) => {
|
||
setDraggedIndex(index);
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
e.stopPropagation(); // 阻止事件冒泡,避免触发外层文件拖拽
|
||
};
|
||
|
||
// 拖拽经过
|
||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||
e.preventDefault();
|
||
e.stopPropagation(); // 阻止事件冒泡
|
||
e.dataTransfer.dropEffect = 'move';
|
||
setHoveredIndex(index);
|
||
};
|
||
|
||
// 拖拽离开
|
||
const handleDragLeave = (e: React.DragEvent) => {
|
||
e.stopPropagation(); // 阻止事件冒泡
|
||
setHoveredIndex(null);
|
||
};
|
||
|
||
// 放置
|
||
const handleDrop = (e: React.DragEvent, dropIndex: number) => {
|
||
e.preventDefault();
|
||
e.stopPropagation(); // 阻止事件冒泡,避免触发外层文件拖拽
|
||
|
||
if (draggedIndex === null || 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-3 gap-4"
|
||
onDragOver={(e) => e.stopPropagation()}
|
||
onDrop={(e) => e.stopPropagation()}
|
||
>
|
||
{/* 左侧:实时预览(占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} 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="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
|
||
onDragStart={(e) => handleDragStart(e, index)}
|
||
onDragOver={(e) => handleDragOver(e, index)}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={(e) => handleDrop(e, index)}
|
||
onDragEnd={handleDragEnd}
|
||
onClick={() => scrollToImage(index)}
|
||
className={`
|
||
relative cursor-move border-2 rounded-lg overflow-hidden
|
||
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="w-full h-32 object-cover"
|
||
/>
|
||
|
||
{/* 序号 */}
|
||
<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
|
||
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>
|
||
);
|
||
}
|