feat: 实现虚拟列表、拖拽排序、实时预览防抖,更新文档

- DataPreview: 大数据量(>100条)自动启用虚拟滚动,仅渲染可见区域
- FieldList: 基于 @dnd-kit/sortable 实现拖拽排序
- index.tsx: 配置变更时300ms防抖生成预览数据
- ui-design.md: 去除平板/移动端响应式、快捷键,更新交互说明
- technical-implementation.md: 去除downloadAsZip,更新虚拟列表说明
This commit is contained in:
雨霖铃
2026-06-06 09:42:58 +08:00
parent 1a92e88934
commit 49e7f1ea55
6 changed files with 329 additions and 171 deletions
@@ -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 节点数量,优化滚动性能
---
+11 -21
View File
@@ -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,支持快速滚动
---
+6 -2
View File
@@ -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>
+80 -3
View File
@@ -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>