feat: 实现虚拟列表、拖拽排序、实时预览防抖,更新文档
- DataPreview: 大数据量(>100条)自动启用虚拟滚动,仅渲染可见区域 - FieldList: 基于 @dnd-kit/sortable 实现拖拽排序 - index.tsx: 配置变更时300ms防抖生成预览数据 - ui-design.md: 去除平板/移动端响应式、快捷键,更新交互说明 - technical-implementation.md: 去除downloadAsZip,更新虚拟列表说明
This commit is contained in:
@@ -1481,28 +1481,15 @@ export class DataExporter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载为 ZIP(需要引入 jszip 库)
|
// 复制到剪贴板
|
||||||
static async downloadAsZip(
|
static async copyToClipboard(content: string): Promise<boolean> {
|
||||||
files: ExportFile[],
|
try {
|
||||||
zipFilename: string = 'export.zip',
|
await navigator.clipboard.writeText(content);
|
||||||
): Promise<void> {
|
return true;
|
||||||
// 动态导入 jszip
|
} catch (error) {
|
||||||
const JSZip = (await import('jszip')).default;
|
console.error('[DataExporter] 复制失败:', error);
|
||||||
const zip = new JSZip();
|
return false;
|
||||||
|
}
|
||||||
files.forEach((file) => {
|
|
||||||
zip.file(file.filename, file.content);
|
|
||||||
});
|
|
||||||
|
|
||||||
const content = await zip.generateAsync({ type: 'blob' });
|
|
||||||
const url = URL.createObjectURL(content);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = zipFilename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -1860,9 +1847,9 @@ toast.error('生成失败:生成器不存在');
|
|||||||
|
|
||||||
### 4. 虚拟列表
|
### 4. 虚拟列表
|
||||||
|
|
||||||
- 大数据预览使用虚拟列表
|
- 大数据预览(>100 条)自动启用虚拟滚动
|
||||||
- 只渲染可见区域
|
- 行高固定 20px,仅渲染可见区域 ±5 行缓冲
|
||||||
- 减少 DOM 渲染
|
- 大幅减少 DOM 节点数量,优化滚动性能
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -446,39 +446,29 @@
|
|||||||
- 字段配置区占 60%
|
- 字段配置区占 60%
|
||||||
- 数据预览区占 40%
|
- 数据预览区占 40%
|
||||||
|
|
||||||
### 平板端 (768px-1024px)
|
|
||||||
|
|
||||||
- 左右分栏布局
|
|
||||||
- 字段配置区占 50%
|
|
||||||
- 数据预览区占 50%
|
|
||||||
|
|
||||||
### 移动端 (<768px)
|
|
||||||
|
|
||||||
- 单栏布局
|
|
||||||
- 字段配置和数据预览上下排列
|
|
||||||
- 预览区默认折叠
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 交互细节
|
## 交互细节
|
||||||
|
|
||||||
### 拖拽排序
|
### 拖拽排序
|
||||||
|
|
||||||
- 字段支持拖拽排序
|
- 字段支持拖拽排序(基于 @dnd-kit/sortable)
|
||||||
- 拖拽时显示占位符
|
- 拖拽手柄(GripVertical 图标)位于字段左侧
|
||||||
- 释放后立即生效
|
- 拖拽时显示半透明效果和阴影
|
||||||
|
- 释放后立即生效,自动更新字段顺序
|
||||||
|
|
||||||
### 实时预览
|
### 实时预览
|
||||||
|
|
||||||
- 配置参数时实时更新预览
|
- 配置参数时实时更新预览
|
||||||
- 延迟 300ms 防抖
|
- 延迟 300ms 防抖,避免频繁重渲染
|
||||||
- 加载状态显示
|
- 预览最多显示 10 条示例数据
|
||||||
|
- 生成完成后切换为完整结果预览
|
||||||
|
|
||||||
### 快捷操作
|
### 虚拟列表
|
||||||
|
|
||||||
- Ctrl+C 复制选中数据
|
- 数据量超过 100 条时自动启用虚拟滚动
|
||||||
- Ctrl+V 粘贴规则配置
|
- 仅渲染可见区域的行,大幅减少 DOM 节点
|
||||||
- Ctrl+S 保存当前配置
|
- 行高固定 20px,支持快速滚动
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
"message": "清理",
|
"message": "清理",
|
||||||
"description": "Translation key: buttons_clear"
|
"description": "Translation key: buttons_clear"
|
||||||
},
|
},
|
||||||
|
|
||||||
"messages_copySuccess": {
|
"messages_copySuccess": {
|
||||||
"message": "已复制到剪贴板",
|
"message": "已复制到剪贴板",
|
||||||
"description": "Translation key: messages_copySuccess"
|
"description": "Translation key: messages_copySuccess"
|
||||||
@@ -896,7 +895,6 @@
|
|||||||
"message": "在标签页打开",
|
"message": "在标签页打开",
|
||||||
"description": "Translation key: buttons_openInTab"
|
"description": "Translation key: buttons_openInTab"
|
||||||
},
|
},
|
||||||
|
|
||||||
"common_errorBoundary_title": {
|
"common_errorBoundary_title": {
|
||||||
"message": "糟糕,出了点问题",
|
"message": "糟糕,出了点问题",
|
||||||
"description": "Translation key: errorBoundary_title"
|
"description": "Translation key: errorBoundary_title"
|
||||||
@@ -1188,5 +1186,11 @@
|
|||||||
"testDataGenerator_delete": {
|
"testDataGenerator_delete": {
|
||||||
"message": "删除",
|
"message": "删除",
|
||||||
"description": "Translation key: testDataGenerator_delete"
|
"description": "Translation key: testDataGenerator_delete"
|
||||||
|
},
|
||||||
|
"testDataGenerator_virtualMode": {
|
||||||
|
"message": "虚拟列表模式(共 $ 行)"
|
||||||
|
},
|
||||||
|
"testDataGenerator_totalRows": {
|
||||||
|
"message": "共 $ 条数据"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* 数据预览组件
|
* 数据预览组件
|
||||||
* 展示生成的数据,支持 JSON 和 CSV 格式切换
|
* 展示生成的数据,支持 JSON 和 CSV 格式切换
|
||||||
|
* 大数据量使用虚拟列表优化性能
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useRef, useCallback, useMemo } from 'react';
|
||||||
import { Copy, Check, FileJson, FileText } from 'lucide-react';
|
import { Copy, Check, FileJson, FileText } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
@@ -12,18 +13,76 @@ import type { GenerateResult } from '@/types/testDataGenerator';
|
|||||||
|
|
||||||
interface DataPreviewProps {
|
interface DataPreviewProps {
|
||||||
result: GenerateResult | null;
|
result: GenerateResult | null;
|
||||||
pageSize?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PreviewFormat = 'json' | 'csv';
|
type PreviewFormat = 'json' | 'csv';
|
||||||
|
|
||||||
export default function DataPreview({ result, pageSize = 20 }: DataPreviewProps) {
|
/** 虚拟列表配置 */
|
||||||
|
const VIRTUAL_ROW_HEIGHT = 20;
|
||||||
|
const VIRTUAL_OVERSCAN = 5;
|
||||||
|
|
||||||
|
export default function DataPreview({ result }: DataPreviewProps) {
|
||||||
const { t } = useI18n('testDataGenerator');
|
const { t } = useI18n('testDataGenerator');
|
||||||
const [format, setFormat] = useState<PreviewFormat>('json');
|
const [format, setFormat] = useState<PreviewFormat>('json');
|
||||||
const [currentPage, setCurrentPage] = useState(0);
|
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [scrollTop, setScrollTop] = useState(0);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
if (!result?.data || result.data.length === 0) {
|
const data = result?.data;
|
||||||
|
const hasData = data && data.length > 0;
|
||||||
|
|
||||||
|
// 将数据转换为预渲染的行文本(避免每次滚动重复计算)
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
if (!hasData) return [];
|
||||||
|
if (format === 'json') {
|
||||||
|
return data.map((item) => JSON.stringify(item));
|
||||||
|
}
|
||||||
|
// CSV 格式:先生成表头,再逐行
|
||||||
|
const headers = Array.from(new Set(data.flatMap((row) => Object.keys(row))));
|
||||||
|
const headerLine = headers.join(',');
|
||||||
|
const dataLines = data.map((row) =>
|
||||||
|
headers
|
||||||
|
.map((h) => {
|
||||||
|
const val = String(row[h] ?? '');
|
||||||
|
return val.includes(',') || val.includes('"') || val.includes('\n')
|
||||||
|
? `"${val.replace(/"/g, '""')}"`
|
||||||
|
: val;
|
||||||
|
})
|
||||||
|
.join(','),
|
||||||
|
);
|
||||||
|
return [headerLine, ...dataLines];
|
||||||
|
}, [data, hasData, format]);
|
||||||
|
|
||||||
|
const totalRows = rows.length;
|
||||||
|
const isLargeDataset = totalRows > 100;
|
||||||
|
const containerHeight = 400;
|
||||||
|
|
||||||
|
// 虚拟列表:计算可见范围
|
||||||
|
const visibleStart = Math.max(0, Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN);
|
||||||
|
const visibleEnd = Math.min(
|
||||||
|
totalRows,
|
||||||
|
Math.ceil((scrollTop + containerHeight) / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN,
|
||||||
|
);
|
||||||
|
const visibleRows = rows.slice(visibleStart, visibleEnd);
|
||||||
|
const totalHeight = totalRows * VIRTUAL_ROW_HEIGHT;
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
setScrollTop(containerRef.current.scrollTop);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!hasData) return;
|
||||||
|
const fullContent = format === 'json' ? DataExporter.toJSON(data) : DataExporter.toCSV(data);
|
||||||
|
const success = await DataExporter.copyToClipboard(fullContent);
|
||||||
|
if (success) {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!hasData) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<FileJson className="h-12 w-12 text-muted-foreground/40 mb-3" />
|
<FileJson className="h-12 w-12 text-muted-foreground/40 mb-3" />
|
||||||
@@ -33,24 +92,6 @@ export default function DataPreview({ result, pageSize = 20 }: DataPreviewProps)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = result.data;
|
|
||||||
const totalPages = Math.ceil(data.length / pageSize);
|
|
||||||
const startIndex = currentPage * pageSize;
|
|
||||||
const endIndex = Math.min(startIndex + pageSize, data.length);
|
|
||||||
const currentPageData = data.slice(startIndex, endIndex);
|
|
||||||
|
|
||||||
const content =
|
|
||||||
format === 'json' ? DataExporter.toJSON(currentPageData) : DataExporter.toCSV(currentPageData);
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
const fullContent = format === 'json' ? DataExporter.toJSON(data) : DataExporter.toCSV(data);
|
|
||||||
const success = await DataExporter.copyToClipboard(fullContent);
|
|
||||||
if (success) {
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* 工具栏 */}
|
{/* 工具栏 */}
|
||||||
@@ -74,6 +115,11 @@ export default function DataPreview({ result, pageSize = 20 }: DataPreviewProps)
|
|||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
CSV
|
CSV
|
||||||
</Button>
|
</Button>
|
||||||
|
{isLargeDataset && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
|
{t('testDataGenerator_virtualMode', { count: totalRows })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button variant="ghost" size="sm" onClick={handleCopy} className="h-8 gap-1.5">
|
<Button variant="ghost" size="sm" onClick={handleCopy} className="h-8 gap-1.5">
|
||||||
@@ -82,44 +128,47 @@ export default function DataPreview({ result, pageSize = 20 }: DataPreviewProps)
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 代码预览 */}
|
{/* 虚拟列表预览 */}
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div
|
||||||
<pre className="h-full overflow-auto p-4 rounded-lg bg-muted/50 text-sm font-mono text-foreground whitespace-pre-wrap break-all">
|
ref={containerRef}
|
||||||
{content}
|
className="flex-1 min-h-0 overflow-auto rounded-lg bg-muted/50"
|
||||||
</pre>
|
onScroll={handleScroll}
|
||||||
|
style={{ height: containerHeight }}
|
||||||
|
>
|
||||||
|
{isLargeDataset ? (
|
||||||
|
/* 虚拟滚动模式 */
|
||||||
|
<div style={{ height: totalHeight, position: 'relative' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: visibleStart * VIRTUAL_ROW_HEIGHT,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{visibleRows.map((row, i) => (
|
||||||
|
<div
|
||||||
|
key={visibleStart + i}
|
||||||
|
className="px-4 py-0.5 text-sm font-mono text-foreground whitespace-pre-wrap break-all hover:bg-muted/30"
|
||||||
|
style={{ height: VIRTUAL_ROW_HEIGHT, lineHeight: `${VIRTUAL_ROW_HEIGHT}px` }}
|
||||||
|
>
|
||||||
|
{row}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* 普通模式(小数据量) */
|
||||||
|
<pre className="p-4 text-sm font-mono text-foreground whitespace-pre-wrap break-all">
|
||||||
|
{rows.join('\n')}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 分页 */}
|
{/* 数据统计 */}
|
||||||
{totalPages > 1 && (
|
<div className="mt-2 text-xs text-muted-foreground text-right">
|
||||||
<div className="flex items-center justify-between mt-3 pt-3 border-t">
|
{t('testDataGenerator_totalRows', { count: data.length })}
|
||||||
<span className="text-xs text-muted-foreground">
|
</div>
|
||||||
{t('testDataGenerator_pageInfo', {
|
|
||||||
current: currentPage + 1,
|
|
||||||
total: totalPages,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
|
|
||||||
disabled={currentPage === 0}
|
|
||||||
className="h-7"
|
|
||||||
>
|
|
||||||
{t('testDataGenerator_prevPage')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
|
|
||||||
disabled={currentPage === totalPages - 1}
|
|
||||||
className="h-7"
|
|
||||||
>
|
|
||||||
{t('testDataGenerator_nextPage')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* 字段列表组件
|
* 字段列表组件
|
||||||
* 展示所有字段配置,支持添加、删除、排序
|
* 展示所有字段配置,支持添加、删除、拖拽排序
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Plus, GripVertical, Trash2, ChevronUp, ChevronDown } from 'lucide-react';
|
import { Plus, GripVertical, Trash2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import type { FieldConfig } from '@/types/testDataGenerator';
|
import type { FieldConfig } from '@/types/testDataGenerator';
|
||||||
import FieldItem from './FieldItem';
|
import FieldItem from './FieldItem';
|
||||||
|
|
||||||
@@ -16,32 +32,98 @@ interface FieldListProps {
|
|||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onSelect: (index: number) => void;
|
onSelect: (index: number) => void;
|
||||||
selectedIndex: number | null;
|
selectedIndex: number | null;
|
||||||
|
onReorder: (oldIndex: number, newIndex: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 可排序的字段项 */
|
||||||
|
function SortableFieldItem({
|
||||||
|
field,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
field: FieldConfig;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: field.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
zIndex: isDragging ? 10 : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`group relative rounded-lg border transition-colors ${
|
||||||
|
isDragging ? 'border-primary shadow-lg' : ''
|
||||||
|
} ${
|
||||||
|
isSelected
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-muted-foreground/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 p-3">
|
||||||
|
{/* 拖拽手柄 */}
|
||||||
|
<button
|
||||||
|
className="cursor-grab active:cursor-grabbing p-1 rounded hover:bg-muted/50 touch-none"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<FieldItem field={field} onClick={onSelect} isSelected={isSelected} />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive"
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FieldList({
|
export default function FieldList({
|
||||||
fields,
|
fields,
|
||||||
onUpdate,
|
onUpdate: _onUpdate,
|
||||||
onRemove,
|
onRemove,
|
||||||
onAdd,
|
onAdd,
|
||||||
onSelect,
|
onSelect,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
|
onReorder,
|
||||||
}: FieldListProps) {
|
}: FieldListProps) {
|
||||||
const { t } = useI18n('testDataGenerator');
|
const { t } = useI18n('testDataGenerator');
|
||||||
|
|
||||||
const handleMoveUp = (index: number) => {
|
const sensors = useSensors(
|
||||||
if (index === 0) return;
|
useSensor(PointerSensor, {
|
||||||
const newFields = [...fields];
|
activationConstraint: { distance: 5 },
|
||||||
[newFields[index - 1], newFields[index]] = [newFields[index], newFields[index - 1]];
|
}),
|
||||||
onUpdate(index - 1, newFields[index - 1]);
|
useSensor(KeyboardSensor, {
|
||||||
onUpdate(index, newFields[index]);
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
};
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const handleMoveDown = (index: number) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
if (index === fields.length - 1) return;
|
const { active, over } = event;
|
||||||
const newFields = [...fields];
|
if (!over || active.id === over.id) return;
|
||||||
[newFields[index], newFields[index + 1]] = [newFields[index + 1], newFields[index]];
|
|
||||||
onUpdate(index, newFields[index]);
|
const oldIndex = fields.findIndex((f) => f.id === active.id);
|
||||||
onUpdate(index + 1, newFields[index + 1]);
|
const newIndex = fields.findIndex((f) => f.id === over.id);
|
||||||
|
if (oldIndex !== -1 && newIndex !== -1) {
|
||||||
|
onReorder(oldIndex, newIndex);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -66,54 +148,23 @@ export default function FieldList({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
fields.map((field, index) => (
|
<DndContext
|
||||||
<div
|
sensors={sensors}
|
||||||
key={field.id}
|
collisionDetection={closestCenter}
|
||||||
className={`group relative rounded-lg border transition-colors ${
|
onDragEnd={handleDragEnd}
|
||||||
selectedIndex === index
|
>
|
||||||
? 'border-primary bg-primary/5'
|
<SortableContext items={fields.map((f) => f.id)} strategy={verticalListSortingStrategy}>
|
||||||
: 'border-border hover:border-muted-foreground/30'
|
{fields.map((field, index) => (
|
||||||
}`}
|
<SortableFieldItem
|
||||||
>
|
key={field.id}
|
||||||
<div className="flex items-center gap-2 p-3">
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
onClick={() => handleMoveUp(index)}
|
|
||||||
disabled={index === 0}
|
|
||||||
>
|
|
||||||
<ChevronUp className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
onClick={() => handleMoveDown(index)}
|
|
||||||
disabled={index === fields.length - 1}
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FieldItem
|
|
||||||
field={field}
|
field={field}
|
||||||
onClick={() => onSelect(index)}
|
|
||||||
isSelected={selectedIndex === index}
|
isSelected={selectedIndex === index}
|
||||||
|
onSelect={() => onSelect(index)}
|
||||||
|
onRemove={() => onRemove(index)}
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
<Button
|
</SortableContext>
|
||||||
variant="ghost"
|
</DndContext>
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive"
|
|
||||||
onClick={() => onRemove(index)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
* 测试数据生成器主页面
|
* 测试数据生成器主页面
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import { Settings, Database, Tag } from 'lucide-react';
|
import { Settings, Database, Tag } from 'lucide-react';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useGenerator } from './hooks/useGenerator';
|
import { useGenerator } from './hooks/useGenerator';
|
||||||
import type { FieldConfig } from '@/types/testDataGenerator';
|
import { getGeneratorById } from '@/lib/generators';
|
||||||
|
import type { FieldConfig, GenerateResult } from '@/types/testDataGenerator';
|
||||||
|
|
||||||
// 生成唯一 ID 的辅助函数
|
// 生成唯一 ID 的辅助函数
|
||||||
function generateId(): string {
|
function generateId(): string {
|
||||||
@@ -41,6 +42,60 @@ export default function TestDataGeneratorPage() {
|
|||||||
// 当前标签页
|
// 当前标签页
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('fields');
|
const [activeTab, setActiveTab] = useState<TabType>('fields');
|
||||||
|
|
||||||
|
// 实时预览(防抖)
|
||||||
|
const [previewResult, setPreviewResult] = useState<GenerateResult | null>(null);
|
||||||
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// 配置变更时生成预览数据(防抖 300ms)
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无字段时不生成预览
|
||||||
|
if (fields.length === 0) {
|
||||||
|
debounceTimerRef.current = setTimeout(() => setPreviewResult(null), 0);
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const previewData: Record<string, unknown>[] = [];
|
||||||
|
const sampleCount = Math.min(10, count);
|
||||||
|
for (let i = 0; i < sampleCount; i++) {
|
||||||
|
const record: Record<string, unknown> = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
const generator = getGeneratorById(field.generatorId);
|
||||||
|
if (!generator) continue;
|
||||||
|
if (!field.required && Math.random() * 100 < field.nullRate) {
|
||||||
|
record[field.name] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (field.unique && generator.generateAtIndex) {
|
||||||
|
record[field.name] = generator.generateAtIndex(field.params, i);
|
||||||
|
} else {
|
||||||
|
record[field.name] = generator.generate(field.params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previewData.push(record);
|
||||||
|
}
|
||||||
|
setPreviewResult({
|
||||||
|
success: true,
|
||||||
|
data: previewData,
|
||||||
|
stats: { total: sampleCount, success: sampleCount, failed: 0, duration: 0 },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setPreviewResult(null);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [fields, count]);
|
||||||
|
|
||||||
// 添加新字段
|
// 添加新字段
|
||||||
const handleAddField = useCallback(() => {
|
const handleAddField = useCallback(() => {
|
||||||
const newField: FieldConfig = {
|
const newField: FieldConfig = {
|
||||||
@@ -80,6 +135,27 @@ export default function TestDataGeneratorPage() {
|
|||||||
[fields, selectedIndex],
|
[fields, selectedIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 拖拽排序
|
||||||
|
const handleReorder = useCallback(
|
||||||
|
(oldIndex: number, newIndex: number) => {
|
||||||
|
const newFields = [...fields];
|
||||||
|
const [moved] = newFields.splice(oldIndex, 1);
|
||||||
|
newFields.splice(newIndex, 0, moved);
|
||||||
|
setFields(newFields);
|
||||||
|
// 同步更新选中索引
|
||||||
|
if (selectedIndex === oldIndex) {
|
||||||
|
setSelectedIndex(newIndex);
|
||||||
|
} else if (selectedIndex !== null) {
|
||||||
|
if (oldIndex < selectedIndex && newIndex >= selectedIndex) {
|
||||||
|
setSelectedIndex(selectedIndex - 1);
|
||||||
|
} else if (oldIndex > selectedIndex && newIndex <= selectedIndex) {
|
||||||
|
setSelectedIndex(selectedIndex + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fields, selectedIndex],
|
||||||
|
);
|
||||||
|
|
||||||
// 加载规则
|
// 加载规则
|
||||||
const handleLoadRule = useCallback(
|
const handleLoadRule = useCallback(
|
||||||
(loadedFields: FieldConfig[]) => {
|
(loadedFields: FieldConfig[]) => {
|
||||||
@@ -152,6 +228,7 @@ export default function TestDataGeneratorPage() {
|
|||||||
onAdd={handleAddField}
|
onAdd={handleAddField}
|
||||||
onSelect={setSelectedIndex}
|
onSelect={setSelectedIndex}
|
||||||
selectedIndex={selectedIndex}
|
selectedIndex={selectedIndex}
|
||||||
|
onReorder={handleReorder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -227,7 +304,7 @@ export default function TestDataGeneratorPage() {
|
|||||||
{t('testDataGenerator_dataPreview')}
|
{t('testDataGenerator_dataPreview')}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="h-[400px]">
|
<div className="h-[400px]">
|
||||||
<DataPreview result={result} />
|
<DataPreview result={result || previewResult} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user