feat: 实现虚拟列表、拖拽排序、实时预览防抖,更新文档
- DataPreview: 大数据量(>100条)自动启用虚拟滚动,仅渲染可见区域 - FieldList: 基于 @dnd-kit/sortable 实现拖拽排序 - index.tsx: 配置变更时300ms防抖生成预览数据 - ui-design.md: 去除平板/移动端响应式、快捷键,更新交互说明 - technical-implementation.md: 去除downloadAsZip,更新虚拟列表说明
This commit is contained in:
@@ -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}
|
||||
</pre>
|
||||
{/* 虚拟列表预览 */}
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
{/* 数据统计 */}
|
||||
<div className="mt-2 text-xs text-muted-foreground text-right">
|
||||
{t('testDataGenerator_totalRows', { count: data.length })}
|
||||
</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
|
||||
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
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={fields.map((f) => f.id)} strategy={verticalListSortingStrategy}>
|
||||
{fields.map((field, index) => (
|
||||
<SortableFieldItem
|
||||
key={field.id}
|
||||
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