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')}
- +