feat: 优化规则管理功能

- 移动保存按钮到字段配置区域
- 添加规则编辑功能,支持更新和另存为
- 编辑模式下显示编辑状态标识
- 保存时检查规则名称重复,支持覆盖保存
- 优化弹窗样式,统一风格
- 更换加载图标为 FolderOpen
- 添加搜索防抖和错误处理
- 添加国际化文本
This commit is contained in:
雨霖铃
2026-06-06 23:08:05 +08:00
parent a2e07a9115
commit 6c7b7294b4
6 changed files with 654 additions and 148 deletions
@@ -5,9 +5,13 @@
*/
import { useState, useRef, useCallback, useEffect } from 'react';
import { Plus, GripVertical, Trash2 } from 'lucide-react';
import { Plus, GripVertical, Trash2, Save } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { useI18n } from '@/utils/chromeI18n';
import * as ruleStorage from '@/utils/ruleStorage';
import {
DndContext,
closestCenter,
@@ -26,7 +30,7 @@ import {
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { FieldConfig } from '@/types/testDataGenerator';
import type { FieldConfig, DataRule } from '@/types/testDataGenerator';
import FieldItem from './FieldItem';
/** 最大字段数量 */
@@ -46,6 +50,8 @@ interface FieldListProps {
onAdd: () => void;
onEdit: (index: number) => void;
onReorder: (oldIndex: number, newIndex: number) => void;
editingRule?: DataRule | null;
onRuleSaved?: () => void;
}
/** 可排序的字段项 */
@@ -114,11 +120,17 @@ export default function FieldList({
onAdd,
onEdit,
onReorder,
editingRule,
onRuleSaved,
}: FieldListProps) {
const { t } = useI18n('testDataGenerator');
const [scrollTop, setScrollTop] = useState(0);
const [activeId, setActiveId] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [showConfirmOverwrite, setShowConfirmOverwrite] = useState(false);
const [ruleName, setRuleName] = useState('');
const [ruleDescription, setRuleDescription] = useState('');
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -205,22 +217,107 @@ export default function FieldList({
const isMaxReached = fields.length >= MAX_FIELDS;
// 编辑模式下,保存时更新原规则
const handleUpdateRule = useCallback(() => {
if (!editingRule) return;
const updated = ruleStorage.update(editingRule.id, {
fields: fields,
});
if (updated) {
toast.success(t('testDataGenerator_ruleUpdated'));
onRuleSaved?.();
}
}, [editingRule, fields, t, onRuleSaved]);
// 新建规则或另存为
const handleSave = useCallback(
(overwrite = false) => {
if (!ruleName.trim()) return;
const trimmedName = ruleName.trim();
// 检查名称是否重复
if (!overwrite) {
const existingRule = ruleStorage.getByName(trimmedName);
if (existingRule) {
setShowConfirmOverwrite(true);
return;
}
}
const newRule = ruleStorage.save({
name: trimmedName,
description: ruleDescription.trim(),
fields: fields,
});
if (newRule) {
setShowSaveDialog(false);
setShowConfirmOverwrite(false);
setRuleName('');
setRuleDescription('');
toast.success(t('testDataGenerator_ruleSaved'));
onRuleSaved?.();
}
},
[ruleName, ruleDescription, fields, t, onRuleSaved],
);
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-foreground">
{t('testDataGenerator_fields')} ({fields.length}/{MAX_FIELDS})
</h3>
<Button
variant="outline"
size="sm"
onClick={onAdd}
disabled={isMaxReached}
className="h-9 gap-1.5 px-3"
>
<Plus className="h-4 w-4" />
{t('testDataGenerator_addField')}
</Button>
<div className="flex items-center gap-2">
{editingRule ? (
<>
<Button
variant="outline"
size="sm"
onClick={handleUpdateRule}
disabled={fields.length === 0}
className="h-8 gap-1.5 px-2.5"
title={`${t('testDataGenerator_editing')}: ${editingRule.name}`}
>
<Save className="h-3.5 w-3.5" />
{t('testDataGenerator_updateRule')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={fields.length === 0}
className="h-8 px-2"
>
{t('testDataGenerator_saveAs')}
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={fields.length === 0}
className="h-8 gap-1.5 px-2.5"
>
<Save className="h-3.5 w-3.5" />
{t('testDataGenerator_saveRule')}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={onAdd}
disabled={isMaxReached}
className="h-8 gap-1.5 px-2.5"
>
<Plus className="h-4 w-4" />
{t('testDataGenerator_addField')}
</Button>
</div>
</div>
<div className="flex-1 min-h-0">
@@ -311,6 +408,59 @@ export default function FieldList({
</DndContext>
)}
</div>
{/* 保存规则对话框 */}
<Dialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
<DialogContent
showCloseButton={false}
className="w-[calc(100vw-4rem)] max-w-[420px] p-0 pt-6 flex flex-col"
>
<div className="flex-1 overflow-y-auto px-6 pt-2 pb-4 space-y-4">
<Input
value={ruleName}
onChange={(e) => setRuleName(e.target.value)}
placeholder={t('testDataGenerator_ruleNamePlaceholder')}
className="h-9"
/>
<Input
value={ruleDescription}
onChange={(e) => setRuleDescription(e.target.value)}
placeholder={t('testDataGenerator_ruleDescPlaceholder')}
className="h-9"
/>
</div>
<div className="flex justify-end gap-2 px-6 py-2 border-t shrink-0">
<Button variant="ghost" size="sm" onClick={() => setShowSaveDialog(false)}>
{t('testDataGenerator_cancel')}
</Button>
<Button size="sm" onClick={() => handleSave()} disabled={!ruleName.trim()}>
{t('testDataGenerator_confirm')}
</Button>
</div>
</DialogContent>
</Dialog>
{/* 覆盖确认对话框 */}
<Dialog open={showConfirmOverwrite} onOpenChange={setShowConfirmOverwrite}>
<DialogContent
showCloseButton={false}
className="w-[calc(100vw-4rem)] max-w-[420px] p-0 pt-6 flex flex-col"
>
<div className="flex-1 overflow-y-auto px-6 pb-4">
<p className="text-sm text-muted-foreground">
{t('testDataGenerator_ruleNameDuplicate')}
</p>
</div>
<div className="flex justify-end gap-2 px-6 py-2 border-t shrink-0">
<Button variant="ghost" size="sm" onClick={() => setShowConfirmOverwrite(false)}>
{t('testDataGenerator_cancel')}
</Button>
<Button size="sm" onClick={() => handleSave(true)}>
{t('testDataGenerator_overwrite')}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -1,143 +1,227 @@
/**
* 规则管理器组件
* 管理测试数据生成规则的保存、加载、删除等操作
* 管理测试数据生成规则的加载、删除等操作
*/
import { useState } from 'react';
import { Search, Save, Upload, Download, Trash2, Copy, Edit, Clock, Tag } from 'lucide-react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
Search,
Upload,
Download,
Trash2,
Copy,
Edit,
Clock,
Tag,
Loader2,
FolderOpen,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { useI18n } from '@/utils/chromeI18n';
import * as ruleStorage from '@/utils/ruleStorage';
import type { DataRule, FieldConfig } from '@/types/testDataGenerator';
interface RuleManagerProps {
currentFields: FieldConfig[];
onLoad: (fields: FieldConfig[]) => void;
onEdit?: (rule: DataRule) => void;
onRulesChanged?: () => void;
}
export default function RuleManager({ currentFields, onLoad }: RuleManagerProps) {
const { t } = useI18n('testDataGenerator');
export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleManagerProps) {
const { t, i18n } = useI18n('testDataGenerator');
const [rules, setRules] = useState<DataRule[]>(() => ruleStorage.getAll());
const [searchQuery, setSearchQuery] = useState('');
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [ruleName, setRuleName] = useState('');
const [ruleDescription, setRuleDescription] = useState('');
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
const [ruleToDelete, setRuleToDelete] = useState<DataRule | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const loadRules = () => {
const loadRules = useCallback(() => {
setRules(ruleStorage.getAll());
};
}, []);
const filteredRules = searchQuery ? ruleStorage.search(searchQuery) : rules;
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchQuery(searchQuery);
}, 250);
return () => clearTimeout(timer);
}, [searchQuery]);
const handleSave = () => {
if (!ruleName.trim()) return;
const filteredRules = useMemo(() => {
if (!debouncedSearchQuery) return rules;
const query = debouncedSearchQuery.toLowerCase();
return rules.filter(
(rule) =>
rule.name.toLowerCase().includes(query) || rule.description?.toLowerCase().includes(query),
);
}, [rules, debouncedSearchQuery]);
const newRule = ruleStorage.save({
name: ruleName.trim(),
description: ruleDescription.trim(),
fields: currentFields,
});
if (newRule) {
const handleLoad = useCallback(
(rule: DataRule) => {
onLoad(rule.fields);
ruleStorage.recordUse(rule.id);
loadRules();
setShowSaveDialog(false);
setRuleName('');
setRuleDescription('');
toast.success(t('testDataGenerator_ruleLoaded', { name: rule.name }));
},
[onLoad, loadRules, t],
);
const handleDelete = useCallback(() => {
if (!ruleToDelete) return;
ruleStorage.deleteRule(ruleToDelete.id);
loadRules();
setRuleToDelete(null);
toast.success(t('testDataGenerator_ruleDeleted'));
onRulesChanged?.();
}, [ruleToDelete, loadRules, t, onRulesChanged]);
const handleDuplicate = useCallback(
(id: string) => {
const result = ruleStorage.duplicate(id);
if (result) {
loadRules();
toast.success(t('testDataGenerator_ruleDuplicated'));
onRulesChanged?.();
}
},
[loadRules, t, onRulesChanged],
);
const handleEdit = useCallback(
(rule: DataRule) => {
onEdit?.(rule);
},
[onEdit],
);
const handleExport = useCallback(() => {
try {
setIsExporting(true);
const json = ruleStorage.exportRules();
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
try {
const link = document.createElement('a');
link.href = url;
link.download = 'test-data-rules.json';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} finally {
URL.revokeObjectURL(url);
}
toast.success(t('testDataGenerator_exportSuccess'));
} catch (error) {
console.error('[RuleManager] 导出失败:', error);
toast.error(t('testDataGenerator_exportFailed'));
} finally {
setIsExporting(false);
}
};
}, [t]);
const handleLoad = (rule: DataRule) => {
onLoad(rule.fields);
ruleStorage.recordUse(rule.id);
loadRules();
};
const handleDelete = (id: string) => {
ruleStorage.deleteRule(id);
loadRules();
};
const handleDuplicate = (id: string) => {
ruleStorage.duplicate(id);
loadRules();
};
const handleExport = () => {
const json = ruleStorage.exportRules();
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'test-data-rules.json';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const handleImport = async () => {
const handleImport = useCallback(() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
setIsImporting(true);
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const text = await file.text();
const result = ruleStorage.importRules(text);
const text = await file.text();
const result = ruleStorage.importRules(text);
if (result.errors.length > 0) {
console.warn('[RuleManager] 导入警告:', result.errors);
if (result.success > 0) {
toast.success(t('testDataGenerator_importSuccess', { count: result.success }));
}
if (result.failed > 0) {
toast.error(t('testDataGenerator_importFailed', { count: result.failed }));
console.warn('[RuleManager] 导入警告:', result.errors);
}
loadRules();
onRulesChanged?.();
} catch (error) {
console.error('[RuleManager] 导入失败:', error);
toast.error(t('testDataGenerator_importFailed'));
} finally {
setIsImporting(false);
}
loadRules();
};
input.click();
};
}, [loadRules, t, onRulesChanged]);
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleDateString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const formatDate = useCallback(
(timestamp: number) => {
return new Date(timestamp).toLocaleDateString(i18n.language || 'zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
},
[i18n.language],
);
return (
<div className="space-y-4">
{/* 删除确认对话框 */}
<Dialog open={!!ruleToDelete} onOpenChange={() => setRuleToDelete(null)}>
<DialogContent
showCloseButton={false}
className="w-[calc(100vw-4rem)] max-w-[420px] p-0 pt-6 flex flex-col"
>
<div className="flex-1 overflow-y-auto px-6 pb-4">
<p className="text-sm text-muted-foreground">
{t('testDataGenerator_confirmDeleteDescription', { name: ruleToDelete?.name ?? '' })}
</p>
</div>
<div className="flex justify-end gap-2 px-6 py-2 border-t shrink-0">
<Button variant="ghost" size="sm" onClick={() => setRuleToDelete(null)}>
{t('testDataGenerator_cancel')}
</Button>
<Button variant="destructive" size="sm" onClick={handleDelete}>
{t('testDataGenerator_confirm')}
</Button>
</div>
</DialogContent>
</Dialog>
{/* 工具栏 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowSaveDialog(true)}
disabled={currentFields.length === 0}
className="h-8 gap-1.5"
>
<Save className="h-4 w-4" />
{t('testDataGenerator_saveRule')}
</Button>
<Button variant="outline" size="sm" onClick={handleImport} className="h-8 gap-1.5">
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={handleImport}
disabled={isImporting}
className="h-8 gap-1.5"
>
{isImporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
{t('testDataGenerator_import')}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExport}
disabled={rules.length === 0}
className="h-8 gap-1.5"
>
)}
{t('testDataGenerator_import')}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExport}
disabled={isExporting || rules.length === 0}
className="h-8 gap-1.5"
>
{isExporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
{t('testDataGenerator_export')}
</Button>
</div>
<span className="text-xs text-muted-foreground">
{t('testDataGenerator_ruleCount', { count: rules.length, max: 20 })}
</span>
)}
{t('testDataGenerator_export')}
</Button>
</div>
{/* 搜索框 */}
@@ -145,48 +229,23 @@ export default function RuleManager({ currentFields, onLoad }: RuleManagerProps)
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onChange={(e) => setSearchQuery(e.target.value.slice(0, 20))}
placeholder={t('testDataGenerator_searchRules')}
className="pl-9 h-9"
className="pl-9 pr-24 h-9"
maxLength={20}
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground pointer-events-none">
{t('testDataGenerator_ruleCount', { count: rules.length, max: 20 })}
</span>
</div>
{/* 保存对话框 */}
{showSaveDialog && (
<div className="p-3 rounded-lg border bg-card space-y-3">
<h4 className="text-sm font-medium text-foreground">
{t('testDataGenerator_saveNewRule')}
</h4>
<Input
value={ruleName}
onChange={(e) => setRuleName(e.target.value)}
placeholder={t('testDataGenerator_ruleNamePlaceholder')}
className="h-9"
/>
<Input
value={ruleDescription}
onChange={(e) => setRuleDescription(e.target.value)}
placeholder={t('testDataGenerator_ruleDescPlaceholder')}
className="h-9"
/>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setShowSaveDialog(false)}>
{t('testDataGenerator_cancel')}
</Button>
<Button size="sm" onClick={handleSave} disabled={!ruleName.trim()}>
{t('testDataGenerator_confirm')}
</Button>
</div>
</div>
)}
{/* 规则列表 */}
<div className="space-y-2 max-h-64 overflow-y-auto">
{filteredRules.length === 0 ? (
<div className="text-center py-6">
<Tag className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
{searchQuery
{debouncedSearchQuery
? t('testDataGenerator_noSearchResults')
: t('testDataGenerator_noRules')}
</p>
@@ -225,6 +284,15 @@ export default function RuleManager({ currentFields, onLoad }: RuleManagerProps)
className="h-7 w-7"
onClick={() => handleLoad(rule)}
title={t('testDataGenerator_load')}
>
<FolderOpen className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(rule)}
title={t('testDataGenerator_edit')}
>
<Edit className="h-3.5 w-3.5" />
</Button>
@@ -241,7 +309,7 @@ export default function RuleManager({ currentFields, onLoad }: RuleManagerProps)
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(rule.id)}
onClick={() => setRuleToDelete(rule)}
title={t('testDataGenerator_delete')}
>
<Trash2 className="h-3.5 w-3.5" />
@@ -0,0 +1,190 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// Mock dependencies
vi.mock('@/utils/ruleStorage', () => ({
getAll: vi.fn(() => []),
save: vi.fn(),
deleteRule: vi.fn(),
duplicate: vi.fn(),
recordUse: vi.fn(),
search: vi.fn(() => []),
exportRules: vi.fn(() => '[]'),
importRules: vi.fn(() => ({ success: 0, failed: 0, errors: [] })),
}));
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
import RuleManager from '../RuleManager';
import * as ruleStorage from '@/utils/ruleStorage';
import { toast } from 'sonner';
import type { DataRule, FieldConfig } from '@/types/testDataGenerator';
const mockedRuleStorage = vi.mocked(ruleStorage);
const mockedToast = vi.mocked(toast);
const mockFields: FieldConfig[] = [
{
id: 'field-1',
name: 'name',
generatorId: 'string',
params: {},
required: true,
nullRate: 0,
unique: false,
},
];
const mockRule: DataRule = {
id: 'rule-1',
name: 'Test Rule',
description: 'Test description',
fields: mockFields,
createdAt: Date.now(),
updatedAt: Date.now(),
useCount: 0,
};
describe('RuleManager', () => {
const defaultProps = {
onLoad: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mockedRuleStorage.getAll.mockReturnValue([]);
});
it('should render empty state when no rules', () => {
render(<RuleManager {...defaultProps} />);
expect(screen.getByText('暂无保存的规则')).toBeInTheDocument();
});
it('should render rule list when rules exist', () => {
mockedRuleStorage.getAll.mockReturnValue([mockRule]);
render(<RuleManager {...defaultProps} />);
expect(screen.getByText('Test Rule')).toBeInTheDocument();
expect(screen.getByText('Test description')).toBeInTheDocument();
});
it('should load rule when load button clicked', async () => {
const user = userEvent.setup();
mockedRuleStorage.getAll.mockReturnValue([mockRule]);
render(<RuleManager {...defaultProps} />);
const loadButton = screen.getByTitle('加载');
await user.click(loadButton);
expect(defaultProps.onLoad).toHaveBeenCalledWith(mockFields);
expect(mockedRuleStorage.recordUse).toHaveBeenCalledWith('rule-1');
// Note: t() mock doesn't handle placeholders, so we just check it was called
expect(mockedToast.success).toHaveBeenCalled();
});
it('should show delete confirmation dialog', async () => {
const user = userEvent.setup();
mockedRuleStorage.getAll.mockReturnValue([mockRule]);
render(<RuleManager {...defaultProps} />);
const deleteButton = screen.getByTitle('删除');
await user.click(deleteButton);
expect(screen.getByText('确认删除规则')).toBeInTheDocument();
expect(screen.getByText(/确定要删除规则/)).toBeInTheDocument();
});
it('should delete rule after confirmation', async () => {
const user = userEvent.setup();
mockedRuleStorage.getAll.mockReturnValue([mockRule]);
render(<RuleManager {...defaultProps} />);
// Open delete dialog
await user.click(screen.getByTitle('删除'));
// Confirm delete
await user.click(screen.getByText('确认'));
expect(mockedRuleStorage.deleteRule).toHaveBeenCalledWith('rule-1');
expect(mockedToast.success).toHaveBeenCalledWith('规则已删除');
});
it('should duplicate rule when duplicate button clicked', async () => {
const user = userEvent.setup();
mockedRuleStorage.getAll.mockReturnValue([mockRule]);
mockedRuleStorage.duplicate.mockReturnValue({
...mockRule,
id: 'rule-2',
name: 'Test Rule(副本)',
});
render(<RuleManager {...defaultProps} />);
await user.click(screen.getByTitle('复制'));
expect(mockedRuleStorage.duplicate).toHaveBeenCalledWith('rule-1');
expect(mockedToast.success).toHaveBeenCalled();
});
it('should update search input value', async () => {
const rules = [mockRule, { ...mockRule, id: 'rule-2', name: 'Another Rule' }];
mockedRuleStorage.getAll.mockReturnValue(rules);
render(<RuleManager {...defaultProps} />);
// Initially both rules should be visible
expect(screen.getByText('Test Rule')).toBeInTheDocument();
expect(screen.getByText('Another Rule')).toBeInTheDocument();
// Verify search input exists and is interactive
const searchInput = screen.getByPlaceholderText('搜索规则...');
expect(searchInput).toBeInTheDocument();
expect(searchInput).toHaveValue('');
});
it('should call exportRules when export button clicked', async () => {
const user = userEvent.setup();
mockedRuleStorage.getAll.mockReturnValue([mockRule]);
render(<RuleManager {...defaultProps} />);
// Mock URL methods and body methods after render to avoid interfering with createRoot
const mockCreateObjectURL = vi.fn(() => 'blob:mock');
const mockRevokeObjectURL = vi.fn();
vi.spyOn(URL, 'createObjectURL').mockImplementation(mockCreateObjectURL);
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(mockRevokeObjectURL);
vi.spyOn(document.body, 'appendChild').mockImplementation(() => null as unknown as Node);
vi.spyOn(document.body, 'removeChild').mockImplementation(() => null as unknown as Node);
const buttons = screen.getAllByRole('button');
const exportButton = buttons.find((btn) => btn.textContent?.includes('导出'));
expect(exportButton).toBeDefined();
await user.click(exportButton!);
expect(mockedRuleStorage.exportRules).toHaveBeenCalled();
expect(mockCreateObjectURL).toHaveBeenCalled();
vi.restoreAllMocks();
});
it('should show field count for each rule', () => {
mockedRuleStorage.getAll.mockReturnValue([mockRule]);
render(<RuleManager {...defaultProps} />);
// The component shows "{count} 字段" format
expect(screen.getByText(/字段/)).toBeInTheDocument();
});
});
+25 -3
View File
@@ -7,7 +7,7 @@ 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, GenerateResult } from '@/types/testDataGenerator';
import type { FieldConfig, GenerateResult, DataRule } from '@/types/testDataGenerator';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
@@ -36,6 +36,7 @@ export default function TestDataGeneratorPage() {
const [fields, setFields] = useState<FieldConfig[]>([]);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [editingRule, setEditingRule] = useState<DataRule | null>(null);
// 生成选项
const [count, setCount] = useState(100);
@@ -123,11 +124,30 @@ export default function TestDataGeneratorPage() {
(loadedFields: FieldConfig[]) => {
setFields(loadedFields);
setSelectedIndex(null);
setEditingRule(null);
clearResult();
},
[clearResult],
);
// 编辑规则
const handleEditRule = useCallback(
(rule: DataRule) => {
setFields(rule.fields);
setSelectedIndex(null);
setEditingRule(rule);
setActiveTab('fields');
clearResult();
toast.success(t('testDataGenerator_editingRule', { name: rule.name }));
},
[clearResult, t],
);
// 保存规则成功后清除编辑状态
const handleRuleSaved = useCallback(() => {
setEditingRule(null);
}, []);
// 开始生成
const handleGenerate = useCallback(() => {
if (fields.length === 0) return;
@@ -188,6 +208,8 @@ export default function TestDataGeneratorPage() {
onAdd={handleAddField}
onEdit={handleOpenEditor}
onReorder={handleReorder}
editingRule={editingRule}
onRuleSaved={handleRuleSaved}
/>
</div>
)}
@@ -195,7 +217,7 @@ export default function TestDataGeneratorPage() {
{/* 规则管理标签页 */}
{activeTab === 'rules' && (
<div className="p-4 rounded-xl border border-border bg-card shadow-sm">
<RuleManager currentFields={fields} onLoad={handleLoadRule} />
<RuleManager onLoad={handleLoadRule} onEdit={handleEditRule} />
</div>
)}
@@ -264,7 +286,7 @@ export default function TestDataGeneratorPage() {
<Dialog open={isEditorOpen} onOpenChange={setIsEditorOpen}>
<DialogContent
showCloseButton={false}
className="w-[calc(100vw-4rem)] max-w-[420px] max-h-[calc(100vh-4rem)] p-0 pt-6 flex flex-col"
className="w-[calc(100vw-2rem)] max-w-[520px] max-h-[calc(100vh-2rem)] p-0 pt-6 flex flex-col"
>
<div className="flex-1 overflow-y-auto px-6 pb-4">
{selectedField && selectedIndex !== null && (
+8
View File
@@ -33,6 +33,14 @@ export function getById(id: string): DataRule | undefined {
return rules.find((rule) => rule.id === id);
}
/**
* 根据名称获取规则
*/
export function getByName(name: string): DataRule | undefined {
const rules = getAll();
return rules.find((rule) => rule.name === name);
}
/**
* 获取规则数量
*/