refactor: 数据预览改为显示示例数据,简化组件逻辑
- DataPreview 改为接收字段配置,显示一条示例数据 - 移除虚拟列表、格式切换等复杂功能 - 添加 JSON 语法高亮(键名蓝色、字符串绿色、数字琥珀色、布尔紫色) - 优化空状态样式,垂直居中显示 - 缩短预览区域高度 (400px → 280px) - 添加 testDataGenerator_sampleData 翻译 key
This commit is contained in:
@@ -1043,6 +1043,10 @@
|
|||||||
"message": "配置字段后点击「生成数据」按钮",
|
"message": "配置字段后点击「生成数据」按钮",
|
||||||
"description": "Translation key: testDataGenerator_noDataHint"
|
"description": "Translation key: testDataGenerator_noDataHint"
|
||||||
},
|
},
|
||||||
|
"testDataGenerator_sampleData": {
|
||||||
|
"message": "示例数据",
|
||||||
|
"description": "Translation key: testDataGenerator_sampleData"
|
||||||
|
},
|
||||||
"testDataGenerator_copied": {
|
"testDataGenerator_copied": {
|
||||||
"message": "已复制",
|
"message": "已复制",
|
||||||
"description": "Translation key: testDataGenerator_copied"
|
"description": "Translation key: testDataGenerator_copied"
|
||||||
|
|||||||
@@ -1,173 +1,108 @@
|
|||||||
/**
|
/**
|
||||||
* 数据预览组件
|
* 数据预览组件
|
||||||
* 展示生成的数据,支持 JSON 和 CSV 格式切换
|
* 展示一条示例数据,展示数据结构
|
||||||
* 大数据量使用虚拟列表优化性能
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useRef, useCallback, useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Copy, Check, FileJson, FileText } from 'lucide-react';
|
import { FileJson, FileText } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { DataExporter } from '@/utils/dataExporter';
|
import { getGeneratorById } from '@/lib/generators';
|
||||||
import type { GenerateResult } from '@/types/testDataGenerator';
|
import type { FieldConfig } from '@/types/testDataGenerator';
|
||||||
|
|
||||||
interface DataPreviewProps {
|
interface DataPreviewProps {
|
||||||
result: GenerateResult | null;
|
fields: FieldConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type PreviewFormat = 'json' | 'csv';
|
/** JSON 语法高亮渲染 */
|
||||||
|
function JsonHighlight({ data }: { data: Record<string, unknown> }) {
|
||||||
|
const formatted = useMemo(() => {
|
||||||
|
const lines: { indent: string; key?: string; value: string; isLast: boolean }[] = [];
|
||||||
|
const entries = Object.entries(data);
|
||||||
|
|
||||||
/** 虚拟列表配置 */
|
entries.forEach(([key, value], index) => {
|
||||||
const VIRTUAL_ROW_HEIGHT = 20;
|
const isLast = index === entries.length - 1;
|
||||||
const VIRTUAL_OVERSCAN = 5;
|
const formattedValue =
|
||||||
|
value === null ? 'null' : typeof value === 'string' ? `"${value}"` : String(value);
|
||||||
|
lines.push({ indent: ' ', key, value: formattedValue, isLast });
|
||||||
|
});
|
||||||
|
|
||||||
export default function DataPreview({ result }: DataPreviewProps) {
|
return lines;
|
||||||
const { t } = useI18n('testDataGenerator');
|
}, [data]);
|
||||||
const [format, setFormat] = useState<PreviewFormat>('json');
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const [scrollTop, setScrollTop] = useState(0);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const data = result?.data;
|
return (
|
||||||
const hasData = data && data.length > 0;
|
<pre className="text-sm font-mono leading-relaxed">
|
||||||
|
<span className="text-muted-foreground">{'{'}</span>
|
||||||
// 将数据转换为预渲染的行文本(避免每次滚动重复计算)
|
{formatted.map((line, i) => (
|
||||||
const rows = useMemo(() => {
|
<div key={i} className="flex">
|
||||||
if (!hasData) return [];
|
<span className="text-muted-foreground">{line.indent}</span>
|
||||||
if (format === 'json') {
|
<span className="text-blue-500 dark:text-blue-400">"{line.key}"</span>
|
||||||
return data.map((item) => JSON.stringify(item));
|
<span className="text-muted-foreground">{': '}</span>
|
||||||
}
|
<span className={getValueColor(line.value)}>{line.value}</span>
|
||||||
// CSV 格式:先生成表头,再逐行
|
{!line.isLast && <span className="text-muted-foreground">,</span>}
|
||||||
const headers = Array.from(new Set(data.flatMap((row) => Object.keys(row))));
|
</div>
|
||||||
const headerLine = headers.join(',');
|
))}
|
||||||
const dataLines = data.map((row) =>
|
<span className="text-muted-foreground">{'}'}</span>
|
||||||
headers
|
</pre>
|
||||||
.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) {
|
function getValueColor(value: string): string {
|
||||||
setScrollTop(containerRef.current.scrollTop);
|
if (value === 'null') return 'text-muted-foreground';
|
||||||
|
if (value.startsWith('"')) return 'text-emerald-600 dark:text-emerald-400';
|
||||||
|
if (/^\d+$/.test(value)) return 'text-amber-600 dark:text-amber-400';
|
||||||
|
if (value === 'true' || value === 'false') return 'text-purple-600 dark:text-purple-400';
|
||||||
|
return 'text-foreground';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DataPreview({ fields }: DataPreviewProps) {
|
||||||
|
const { t } = useI18n('testDataGenerator');
|
||||||
|
|
||||||
|
// 生成一条示例数据
|
||||||
|
const sampleData = useMemo(() => {
|
||||||
|
if (fields.length === 0) return null;
|
||||||
|
|
||||||
|
const record: Record<string, unknown> = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
const generator = getGeneratorById(field.generatorId);
|
||||||
|
if (generator) {
|
||||||
|
record[field.name] = generator.generate(field.params);
|
||||||
|
} else {
|
||||||
|
record[field.name] = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
return record;
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
const handleCopy = async () => {
|
if (!sampleData) {
|
||||||
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 (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||||
<FileJson className="h-12 w-12 text-muted-foreground/40 mb-3" />
|
<FileJson className="h-8 w-8 text-muted-foreground/30 mb-2" />
|
||||||
<p className="text-sm text-muted-foreground">{t('testDataGenerator_noData')}</p>
|
<p className="text-xs text-muted-foreground/50">{t('testDataGenerator_noDataHint')}</p>
|
||||||
<p className="text-xs text-muted-foreground/70 mt-1">{t('testDataGenerator_noDataHint')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* 工具栏 */}
|
{/* 示例标签 */}
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<div className="h-5 w-5 rounded bg-primary/10 flex items-center justify-center">
|
||||||
variant={format === 'json' ? 'default' : 'ghost'}
|
<FileText className="h-3 w-3 text-primary" />
|
||||||
size="sm"
|
|
||||||
onClick={() => setFormat('json')}
|
|
||||||
className="h-8 gap-1.5"
|
|
||||||
>
|
|
||||||
<FileJson className="h-4 w-4" />
|
|
||||||
JSON
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={format === 'csv' ? 'default' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setFormat('csv')}
|
|
||||||
className="h-8 gap-1.5"
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
|
||||||
{copied ? t('testDataGenerator_copied') : t('testDataGenerator_copy')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 虚拟列表预览 */}
|
|
||||||
<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>
|
</div>
|
||||||
) : (
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
/* 普通模式(小数据量) */
|
{t('testDataGenerator_sampleData')}
|
||||||
<pre className="p-4 text-sm font-mono text-foreground whitespace-pre-wrap break-all">
|
</span>
|
||||||
{rows.join('\n')}
|
</div>
|
||||||
</pre>
|
<span className="text-[10px] text-muted-foreground/50 bg-muted px-1.5 py-0.5 rounded">
|
||||||
)}
|
{Object.keys(sampleData).length} {t('testDataGenerator_fields')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 数据统计 */}
|
{/* 示例数据展示 */}
|
||||||
<div className="mt-2 text-xs text-muted-foreground text-right">
|
<div className="flex-1 min-h-0 overflow-auto rounded-lg bg-zinc-950 dark:bg-zinc-900 p-4">
|
||||||
{t('testDataGenerator_totalRows', { count: data.length })}
|
<JsonHighlight data={sampleData} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { Settings, Database, Tag } from 'lucide-react';
|
|||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useGenerator } from './hooks/useGenerator';
|
import { useGenerator } from './hooks/useGenerator';
|
||||||
import { getGeneratorById } from '@/lib/generators';
|
|
||||||
import type { FieldConfig, GenerateResult } from '@/types/testDataGenerator';
|
import type { FieldConfig, GenerateResult } from '@/types/testDataGenerator';
|
||||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -45,61 +44,9 @@ export default function TestDataGeneratorPage() {
|
|||||||
// 当前标签页
|
// 当前标签页
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('fields');
|
const [activeTab, setActiveTab] = useState<TabType>('fields');
|
||||||
|
|
||||||
// 实时预览(防抖)
|
// 生成完成时显示 toast 提示(避免重复触发)
|
||||||
const [previewResult, setPreviewResult] = useState<GenerateResult | null>(null);
|
|
||||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const lastToastResultRef = useRef<GenerateResult | null>(null);
|
const lastToastResultRef = useRef<GenerateResult | 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]);
|
|
||||||
|
|
||||||
// 生成完成时显示 toast 提示(避免重复触发)
|
// 生成完成时显示 toast 提示(避免重复触发)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (result?.success && result.stats && result !== lastToastResultRef.current) {
|
if (result?.success && result.stats && result !== lastToastResultRef.current) {
|
||||||
@@ -300,8 +247,8 @@ export default function TestDataGeneratorPage() {
|
|||||||
<Database className="h-4 w-4" />
|
<Database className="h-4 w-4" />
|
||||||
{t('testDataGenerator_dataPreview')}
|
{t('testDataGenerator_dataPreview')}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="h-[400px]">
|
<div className="h-[280px]">
|
||||||
<DataPreview result={result || previewResult} />
|
<DataPreview fields={fields} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user