diff --git a/docs/test-data-generator/technical-implementation.md b/docs/test-data-generator/technical-implementation.md
index 7ecae24..2531468 100644
--- a/docs/test-data-generator/technical-implementation.md
+++ b/docs/test-data-generator/technical-implementation.md
@@ -1481,28 +1481,15 @@ export class DataExporter {
});
}
- // 下载为 ZIP(需要引入 jszip 库)
- static async downloadAsZip(
- files: ExportFile[],
- zipFilename: string = 'export.zip',
- ): Promise {
- // 动态导入 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 {
+ 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 节点数量,优化滚动性能
---
diff --git a/docs/test-data-generator/ui-design.md b/docs/test-data-generator/ui-design.md
index d56601c..7ba4762 100644
--- a/docs/test-data-generator/ui-design.md
+++ b/docs/test-data-generator/ui-design.md
@@ -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,支持快速滚动
---
diff --git a/public/_locales/zh_CN/messages.json b/public/_locales/zh_CN/messages.json
index 5bb3d51..2a92b0d 100644
--- a/public/_locales/zh_CN/messages.json
+++ b/public/_locales/zh_CN/messages.json
@@ -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": "共 $ 条数据"
}
}
diff --git a/src/pages/TestDataGenerator/components/DataPreview.tsx b/src/pages/TestDataGenerator/components/DataPreview.tsx
index 4a29c00..ce162f9 100644
--- a/src/pages/TestDataGenerator/components/DataPreview.tsx
+++ b/src/pages/TestDataGenerator/components/DataPreview.tsx
@@ -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('json');
- const [currentPage, setCurrentPage] = useState(0);
const [copied, setCopied] = useState(false);
+ const [scrollTop, setScrollTop] = useState(0);
+ const containerRef = useRef(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 (
@@ -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 (
{/* 工具栏 */}
@@ -74,6 +115,11 @@ export default function DataPreview({ result, pageSize = 20 }: DataPreviewProps)
CSV
+ {isLargeDataset && (
+
+ {t('testDataGenerator_virtualMode', { count: totalRows })}
+
+ )}
- {/* 代码预览 */}
-
-
- {content}
-
+ {/* 虚拟列表预览 */}
+
+ {isLargeDataset ? (
+ /* 虚拟滚动模式 */
+
+
+ {visibleRows.map((row, i) => (
+
+ {row}
+
+ ))}
+
+
+ ) : (
+ /* 普通模式(小数据量) */
+
+ {rows.join('\n')}
+
+ )}
- {/* 分页 */}
- {totalPages > 1 && (
-
-
- {t('testDataGenerator_pageInfo', {
- current: currentPage + 1,
- total: totalPages,
- })}
-
-
-
-
-
-
- )}
+ {/* 数据统计 */}
+
+ {t('testDataGenerator_totalRows', { count: data.length })}
+
);
}
diff --git a/src/pages/TestDataGenerator/components/FieldList.tsx b/src/pages/TestDataGenerator/components/FieldList.tsx
index dcd8c90..5b695c1 100644
--- a/src/pages/TestDataGenerator/components/FieldList.tsx
+++ b/src/pages/TestDataGenerator/components/FieldList.tsx
@@ -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 (
+
+
+ {/* 拖拽手柄 */}
+
+
+
+
+
+
+
+ );
}
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({
) : (
- fields.map((field, index) => (
-
-
-
-
-
-
-
-
+ f.id)} strategy={verticalListSortingStrategy}>
+ {fields.map((field, index) => (
+ onSelect(index)}
isSelected={selectedIndex === index}
+ onSelect={() => onSelect(index)}
+ onRemove={() => onRemove(index)}
/>
-
-
-
-
- ))
+ ))}
+
+
)}
diff --git a/src/pages/TestDataGenerator/index.tsx b/src/pages/TestDataGenerator/index.tsx
index eb1527b..a1ef778 100644
--- a/src/pages/TestDataGenerator/index.tsx
+++ b/src/pages/TestDataGenerator/index.tsx
@@ -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('fields');
+ // 实时预览(防抖)
+ const [previewResult, setPreviewResult] = useState(null);
+ const debounceTimerRef = useRef | 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[] = [];
+ const sampleCount = Math.min(10, count);
+ for (let i = 0; i < sampleCount; i++) {
+ const record: Record = {};
+ 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}
/>
@@ -227,7 +304,7 @@ export default function TestDataGeneratorPage() {
{t('testDataGenerator_dataPreview')}
-
+