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(
|
||||
files: ExportFile[],
|
||||
zipFilename: string = 'export.zip',
|
||||
): Promise<void> {
|
||||
// 动态导入 jszip
|
||||
const JSZip = (await import('jszip')).default;
|
||||
const zip = new JSZip();
|
||||
|
||||
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);
|
||||
// 复制到剪贴板
|
||||
static async copyToClipboard(content: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[DataExporter] 复制失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1860,9 +1847,9 @@ toast.error('生成失败:生成器不存在');
|
||||
|
||||
### 4. 虚拟列表
|
||||
|
||||
- 大数据预览使用虚拟列表
|
||||
- 只渲染可见区域
|
||||
- 减少 DOM 渲染
|
||||
- 大数据预览(>100 条)自动启用虚拟滚动
|
||||
- 行高固定 20px,仅渲染可见区域 ±5 行缓冲
|
||||
- 大幅减少 DOM 节点数量,优化滚动性能
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -446,39 +446,29 @@
|
||||
- 字段配置区占 60%
|
||||
- 数据预览区占 40%
|
||||
|
||||
### 平板端 (768px-1024px)
|
||||
|
||||
- 左右分栏布局
|
||||
- 字段配置区占 50%
|
||||
- 数据预览区占 50%
|
||||
|
||||
### 移动端 (<768px)
|
||||
|
||||
- 单栏布局
|
||||
- 字段配置和数据预览上下排列
|
||||
- 预览区默认折叠
|
||||
|
||||
---
|
||||
|
||||
## 交互细节
|
||||
|
||||
### 拖拽排序
|
||||
|
||||
- 字段支持拖拽排序
|
||||
- 拖拽时显示占位符
|
||||
- 释放后立即生效
|
||||
- 字段支持拖拽排序(基于 @dnd-kit/sortable)
|
||||
- 拖拽手柄(GripVertical 图标)位于字段左侧
|
||||
- 拖拽时显示半透明效果和阴影
|
||||
- 释放后立即生效,自动更新字段顺序
|
||||
|
||||
### 实时预览
|
||||
|
||||
- 配置参数时实时更新预览
|
||||
- 延迟 300ms 防抖
|
||||
- 加载状态显示
|
||||
- 延迟 300ms 防抖,避免频繁重渲染
|
||||
- 预览最多显示 10 条示例数据
|
||||
- 生成完成后切换为完整结果预览
|
||||
|
||||
### 快捷操作
|
||||
### 虚拟列表
|
||||
|
||||
- Ctrl+C 复制选中数据
|
||||
- Ctrl+V 粘贴规则配置
|
||||
- Ctrl+S 保存当前配置
|
||||
- 数据量超过 100 条时自动启用虚拟滚动
|
||||
- 仅渲染可见区域的行,大幅减少 DOM 节点
|
||||
- 行高固定 20px,支持快速滚动
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"message": "清理",
|
||||
"description": "Translation key: buttons_clear"
|
||||
},
|
||||
|
||||
"messages_copySuccess": {
|
||||
"message": "已复制到剪贴板",
|
||||
"description": "Translation key: messages_copySuccess"
|
||||
@@ -896,7 +895,6 @@
|
||||
"message": "在标签页打开",
|
||||
"description": "Translation key: buttons_openInTab"
|
||||
},
|
||||
|
||||
"common_errorBoundary_title": {
|
||||
"message": "糟糕,出了点问题",
|
||||
"description": "Translation key: errorBoundary_title"
|
||||
@@ -1188,5 +1186,11 @@
|
||||
"testDataGenerator_delete": {
|
||||
"message": "删除",
|
||||
"description": "Translation key: testDataGenerator_delete"
|
||||
},
|
||||
"testDataGenerator_virtualMode": {
|
||||
"message": "虚拟列表模式(共 $ 行)"
|
||||
},
|
||||
"testDataGenerator_totalRows": {
|
||||
"message": "共 $ 条数据"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* 数据预览组件
|
||||
* 展示生成的数据,支持 JSON 和 CSV 格式切换
|
||||
* 大数据量使用虚拟列表优化性能
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { Copy, Check, FileJson, FileText } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
@@ -12,18 +13,76 @@ import type { GenerateResult } from '@/types/testDataGenerator';
|
||||
|
||||
interface DataPreviewProps {
|
||||
result: GenerateResult | null;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
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 [format, setFormat] = useState<PreviewFormat>('json');
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
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 (
|
||||
<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" />
|
||||
@@ -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 (
|
||||
<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" />
|
||||
CSV
|
||||
</Button>
|
||||
{isLargeDataset && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{t('testDataGenerator_virtualMode', { count: totalRows })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* 代码预览 */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<pre className="h-full overflow-auto p-4 rounded-lg bg-muted/50 text-sm font-mono text-foreground whitespace-pre-wrap break-all">
|
||||
{content}
|
||||
{/* 虚拟列表预览 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 min-h-0 overflow-auto rounded-lg bg-muted/50"
|
||||
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>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{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 className="mt-2 text-xs text-muted-foreground text-right">
|
||||
{t('testDataGenerator_totalRows', { count: data.length })}
|
||||
</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 { 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 FieldItem from './FieldItem';
|
||||
|
||||
@@ -16,32 +32,98 @@ interface FieldListProps {
|
||||
onAdd: () => void;
|
||||
onSelect: (index: number) => void;
|
||||
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({
|
||||
fields,
|
||||
onUpdate,
|
||||
onUpdate: _onUpdate,
|
||||
onRemove,
|
||||
onAdd,
|
||||
onSelect,
|
||||
selectedIndex,
|
||||
onReorder,
|
||||
}: FieldListProps) {
|
||||
const { t } = useI18n('testDataGenerator');
|
||||
|
||||
const handleMoveUp = (index: number) => {
|
||||
if (index === 0) return;
|
||||
const newFields = [...fields];
|
||||
[newFields[index - 1], newFields[index]] = [newFields[index], newFields[index - 1]];
|
||||
onUpdate(index - 1, newFields[index - 1]);
|
||||
onUpdate(index, newFields[index]);
|
||||
};
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleMoveDown = (index: number) => {
|
||||
if (index === fields.length - 1) return;
|
||||
const newFields = [...fields];
|
||||
[newFields[index], newFields[index + 1]] = [newFields[index + 1], newFields[index]];
|
||||
onUpdate(index, newFields[index]);
|
||||
onUpdate(index + 1, newFields[index + 1]);
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const oldIndex = fields.findIndex((f) => f.id === active.id);
|
||||
const newIndex = fields.findIndex((f) => f.id === over.id);
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
onReorder(oldIndex, newIndex);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -66,54 +148,23 @@ export default function FieldList({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
fields.map((field, index) => (
|
||||
<div
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={fields.map((f) => f.id)} strategy={verticalListSortingStrategy}>
|
||||
{fields.map((field, index) => (
|
||||
<SortableFieldItem
|
||||
key={field.id}
|
||||
className={`group relative rounded-lg border transition-colors ${
|
||||
selectedIndex === index
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-muted-foreground/30'
|
||||
}`}
|
||||
>
|
||||
<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}
|
||||
onClick={() => onSelect(index)}
|
||||
isSelected={selectedIndex === index}
|
||||
onSelect={() => onSelect(index)}
|
||||
onRemove={() => onRemove(index)}
|
||||
/>
|
||||
|
||||
<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(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</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 { useI18n } from '@/utils/chromeI18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useGenerator } from './hooks/useGenerator';
|
||||
import type { FieldConfig } from '@/types/testDataGenerator';
|
||||
import { getGeneratorById } from '@/lib/generators';
|
||||
import type { FieldConfig, GenerateResult } from '@/types/testDataGenerator';
|
||||
|
||||
// 生成唯一 ID 的辅助函数
|
||||
function generateId(): string {
|
||||
@@ -41,6 +42,60 @@ export default function TestDataGeneratorPage() {
|
||||
// 当前标签页
|
||||
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 newField: FieldConfig = {
|
||||
@@ -80,6 +135,27 @@ export default function TestDataGeneratorPage() {
|
||||
[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(
|
||||
(loadedFields: FieldConfig[]) => {
|
||||
@@ -152,6 +228,7 @@ export default function TestDataGeneratorPage() {
|
||||
onAdd={handleAddField}
|
||||
onSelect={setSelectedIndex}
|
||||
selectedIndex={selectedIndex}
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -227,7 +304,7 @@ export default function TestDataGeneratorPage() {
|
||||
{t('testDataGenerator_dataPreview')}
|
||||
</h3>
|
||||
<div className="h-[400px]">
|
||||
<DataPreview result={result} />
|
||||
<DataPreview result={result || previewResult} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user