diff --git a/public/_locales/zh_CN/messages.json b/public/_locales/zh_CN/messages.json index 845a150..9717b5d 100644 --- a/public/_locales/zh_CN/messages.json +++ b/public/_locales/zh_CN/messages.json @@ -1260,5 +1260,45 @@ "testDataGenerator_saveAs": { "message": "另存为", "description": "Translation key: testDataGenerator_saveAs" + }, + "testDataGenerator_noGeneratorParams": { + "message": "此生成器无可配置参数", + "description": "Translation key: testDataGenerator_noGeneratorParams" + }, + "testDataGenerator_enabled": { + "message": "启用", + "description": "Translation key: testDataGenerator_enabled" + }, + "testDataGenerator_disabled": { + "message": "禁用", + "description": "Translation key: testDataGenerator_disabled" + }, + "testDataGenerator_commaSeparated": { + "message": "用逗号分隔多个值", + "description": "Translation key: testDataGenerator_commaSeparated" + }, + "testDataGenerator_copySuccess": { + "message": "已复制到剪贴板", + "description": "Translation key: testDataGenerator_copySuccess" + }, + "testDataGenerator_copyFailed": { + "message": "复制失败", + "description": "Translation key: testDataGenerator_copyFailed" + }, + "testDataGenerator_fieldNameEmpty": { + "message": "字段名称不能为空", + "description": "Translation key: testDataGenerator_fieldNameEmpty" + }, + "testDataGenerator_fieldNameDuplicate": { + "message": "字段名称已存在", + "description": "Translation key: testDataGenerator_fieldNameDuplicate" + }, + "testDataGenerator_fieldNameInvalid": { + "message": "字段名称只能包含字母、数字和下划线", + "description": "Translation key: testDataGenerator_fieldNameInvalid" + }, + "testDataGenerator_ruleCopySuffix": { + "message": "(副本)", + "description": "Translation key: testDataGenerator_ruleCopySuffix" } } diff --git a/src/config/__tests__/features.test.ts b/src/config/__tests__/features.test.ts index ea1feda..6ac2402 100644 --- a/src/config/__tests__/features.test.ts +++ b/src/config/__tests__/features.test.ts @@ -9,8 +9,8 @@ import { describe('features', () => { describe('FEATURES', () => { - it('should have 9 features defined', () => { - expect(FEATURES).toHaveLength(9); + it('should have 10 features defined', () => { + expect(FEATURES).toHaveLength(10); }); it('should have all required properties for each feature', () => { @@ -95,7 +95,7 @@ describe('features', () => { describe('getAllFeatureKeys', () => { it('should return all feature keys', () => { const allKeys = getAllFeatureKeys(); - expect(allKeys).toHaveLength(9); + expect(allKeys).toHaveLength(10); expect(allKeys).toContain('dashboard'); expect(allKeys).toContain('timestamp'); expect(allKeys).toContain('storageCleaner'); @@ -105,6 +105,7 @@ describe('features', () => { expect(allKeys).toContain('jsonDiff'); expect(allKeys).toContain('base64Converter'); expect(allKeys).toContain('rightClickRestorer'); + expect(allKeys).toContain('testDataGenerator'); }); }); @@ -121,9 +122,9 @@ describe('features', () => { expect(pageOrder).toContain('qrCode'); }); - it('should have 8 items in page order', () => { + it('should have 9 items in page order', () => { const pageOrder = getDefaultPageOrder(); - expect(pageOrder).toHaveLength(8); + expect(pageOrder).toHaveLength(9); }); }); }); diff --git a/src/pages/TestDataGenerator/components/ExportPanel.tsx b/src/pages/TestDataGenerator/components/ExportPanel.tsx index e80efee..f16a1cc 100644 --- a/src/pages/TestDataGenerator/components/ExportPanel.tsx +++ b/src/pages/TestDataGenerator/components/ExportPanel.tsx @@ -4,6 +4,7 @@ */ import { Copy, Download } from 'lucide-react'; +import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { useI18n } from '@/utils/chromeI18n'; import { DataExporter } from '@/utils/dataExporter'; @@ -22,12 +23,22 @@ export default function ExportPanel({ result }: ExportPanelProps) { const handleCopyJSON = async () => { const content = DataExporter.toJSON(result.data!); - await DataExporter.copyToClipboard(content); + const success = await DataExporter.copyToClipboard(content); + if (success) { + toast.success(t('testDataGenerator_copySuccess')); + } else { + toast.error(t('testDataGenerator_copyFailed')); + } }; const handleCopyCSV = async () => { const content = DataExporter.toCSV(result.data!); - await DataExporter.copyToClipboard(content); + const success = await DataExporter.copyToClipboard(content); + if (success) { + toast.success(t('testDataGenerator_copySuccess')); + } else { + toast.error(t('testDataGenerator_copyFailed')); + } }; const handleDownloadJSON = () => { diff --git a/src/pages/TestDataGenerator/components/FieldEditor.tsx b/src/pages/TestDataGenerator/components/FieldEditor.tsx index 4241354..3a5d740 100644 --- a/src/pages/TestDataGenerator/components/FieldEditor.tsx +++ b/src/pages/TestDataGenerator/components/FieldEditor.tsx @@ -3,6 +3,7 @@ * 编辑单个字段的详细配置 */ +import { useState, useCallback } from 'react'; import { useI18n } from '@/utils/chromeI18n'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; @@ -15,14 +16,40 @@ import GeneratorConfig from './GeneratorConfig'; interface FieldEditorProps { field: FieldConfig; onChange: (field: FieldConfig) => void; + /** 所有字段名列表,用于检测重复 */ + allFieldNames?: string[]; } -export default function FieldEditor({ field, onChange }: FieldEditorProps) { +export default function FieldEditor({ field, onChange, allFieldNames = [] }: FieldEditorProps) { const { t } = useI18n('testDataGenerator'); const generator = getGeneratorById(field.generatorId); + const [nameError, setNameError] = useState(null); + + const validateFieldName = useCallback( + (name: string): string | null => { + const trimmed = name.trim(); + if (!trimmed) { + return t('testDataGenerator_fieldNameEmpty'); + } + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) { + return t('testDataGenerator_fieldNameInvalid'); + } + const isDuplicate = allFieldNames.some( + (n, i) => n === trimmed && i !== allFieldNames.indexOf(field.name), + ); + if (isDuplicate) { + return t('testDataGenerator_fieldNameDuplicate'); + } + return null; + }, + [allFieldNames, field.name, t], + ); const handleNameChange = (name: string) => { onChange({ ...field, name }); + // 实时校验 + const error = validateFieldName(name); + setNameError(error); }; const handleDescriptionChange = (description: string) => { @@ -93,8 +120,9 @@ export default function FieldEditor({ field, onChange }: FieldEditorProps) { onChange={(e) => handleNameChange(e.target.value)} placeholder={t('testDataGenerator_fieldNamePlaceholder')} maxLength={20} - className="h-9" + className={`h-9 ${nameError ? 'border-destructive' : ''}`} /> + {nameError &&

{nameError}

}
diff --git a/src/pages/TestDataGenerator/components/GeneratorConfig.tsx b/src/pages/TestDataGenerator/components/GeneratorConfig.tsx index 770b124..f8e3392 100644 --- a/src/pages/TestDataGenerator/components/GeneratorConfig.tsx +++ b/src/pages/TestDataGenerator/components/GeneratorConfig.tsx @@ -12,6 +12,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { useI18n } from '@/utils/chromeI18n'; import type { GeneratorDefinition } from '@/types/testDataGenerator'; interface GeneratorConfigProps { @@ -21,12 +22,17 @@ interface GeneratorConfigProps { } export default function GeneratorConfig({ generator, params, onChange }: GeneratorConfigProps) { + const { t } = useI18n('testDataGenerator'); const handleParamChange = (key: string, value: unknown) => { onChange({ ...params, [key]: value }); }; if (generator.params.length === 0) { - return

此生成器无可配置参数

; + return ( +

+ {t('testDataGenerator_noGeneratorParams')} +

+ ); } return ( @@ -64,7 +70,9 @@ export default function GeneratorConfig({ generator, params, onChange }: Generat {param.type === 'boolean' && (
- {params[param.key] !== false ? '启用' : '禁用'} + {params[param.key] !== false + ? t('testDataGenerator_enabled') + : t('testDataGenerator_disabled')} )} diff --git a/src/pages/TestDataGenerator/components/RuleManager.tsx b/src/pages/TestDataGenerator/components/RuleManager.tsx index c59cf9e..6bd1d3b 100644 --- a/src/pages/TestDataGenerator/components/RuleManager.tsx +++ b/src/pages/TestDataGenerator/components/RuleManager.tsx @@ -80,7 +80,7 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana const handleDuplicate = useCallback( (id: string) => { - const result = ruleStorage.duplicate(id); + const result = ruleStorage.duplicate(id, t('testDataGenerator_ruleCopySuffix')); if (result) { loadRules(); toast.success(t('testDataGenerator_ruleDuplicated')); diff --git a/src/pages/TestDataGenerator/components/__tests__/RuleManager.test.tsx b/src/pages/TestDataGenerator/components/__tests__/RuleManager.test.tsx index 9811e4c..51e8cca 100644 --- a/src/pages/TestDataGenerator/components/__tests__/RuleManager.test.tsx +++ b/src/pages/TestDataGenerator/components/__tests__/RuleManager.test.tsx @@ -100,7 +100,6 @@ describe('RuleManager', () => { const deleteButton = screen.getByTitle('删除'); await user.click(deleteButton); - expect(screen.getByText('确认删除规则')).toBeInTheDocument(); expect(screen.getByText(/确定要删除规则/)).toBeInTheDocument(); }); @@ -133,7 +132,7 @@ describe('RuleManager', () => { await user.click(screen.getByTitle('复制')); - expect(mockedRuleStorage.duplicate).toHaveBeenCalledWith('rule-1'); + expect(mockedRuleStorage.duplicate).toHaveBeenCalledWith('rule-1', '(副本)'); expect(mockedToast.success).toHaveBeenCalled(); }); diff --git a/src/pages/TestDataGenerator/hooks/useGenerator.ts b/src/pages/TestDataGenerator/hooks/useGenerator.ts index a89eecd..7d38bd5 100644 --- a/src/pages/TestDataGenerator/hooks/useGenerator.ts +++ b/src/pages/TestDataGenerator/hooks/useGenerator.ts @@ -51,7 +51,7 @@ export function useGenerator(): UseGeneratorReturn { */ const getWorker = useCallback((): Worker => { if (workerRef.current) { - workerRef.current.terminate(); + return workerRef.current; } const worker = new Worker(new URL('@/workers/generator.worker.ts', import.meta.url), { @@ -82,8 +82,11 @@ export function useGenerator(): UseGeneratorReturn { worker.onerror = (err) => { console.error('[useGenerator] Worker 错误:', err); setIsGenerating(false); - setError('Worker 运行错误'); + setError(err.message || 'Worker 运行错误'); setProgress(null); + // Worker 出错后销毁,下次重新创建 + workerRef.current?.terminate(); + workerRef.current = null; }; workerRef.current = worker; diff --git a/src/pages/TestDataGenerator/index.tsx b/src/pages/TestDataGenerator/index.tsx index d42b910..d6a985f 100644 --- a/src/pages/TestDataGenerator/index.tsx +++ b/src/pages/TestDataGenerator/index.tsx @@ -60,64 +60,58 @@ export default function TestDataGeneratorPage() { // 添加新字段 const handleAddField = useCallback(() => { - if (fields.length >= MAX_FIELDS) return; - const newField: FieldConfig = { - id: generateId(), - name: `field${fields.length + 1}`, - generatorId: 'chineseName', - params: {}, - required: true, - nullRate: 0, - unique: false, - }; - setFields([...fields, newField]); - setSelectedIndex(fields.length); - }, [fields]); + setFields((prev) => { + if (prev.length >= MAX_FIELDS) return prev; + const newField: FieldConfig = { + id: generateId(), + name: `field${prev.length + 1}`, + generatorId: 'chineseName', + params: {}, + required: true, + nullRate: 0, + unique: false, + }; + setSelectedIndex(prev.length); + return [...prev, newField]; + }); + }, []); // 更新字段 - const handleUpdateField = useCallback( - (index: number, field: FieldConfig) => { - const newFields = [...fields]; + const handleUpdateField = useCallback((index: number, field: FieldConfig) => { + setFields((prev) => { + const newFields = [...prev]; newFields[index] = field; - setFields(newFields); - }, - [fields], - ); + return newFields; + }); + }, []); // 删除字段 - const handleRemoveField = useCallback( - (index: number) => { - const newFields = fields.filter((_, i) => i !== index); - setFields(newFields); - if (selectedIndex === index) { - setSelectedIndex(null); - } else if (selectedIndex !== null && selectedIndex > index) { - setSelectedIndex(selectedIndex - 1); - } - }, - [fields, selectedIndex], - ); + const handleRemoveField = useCallback((index: number) => { + setFields((prev) => prev.filter((_, i) => i !== index)); + setSelectedIndex((prev) => { + if (prev === index) return null; + if (prev !== null && prev > index) return prev - 1; + return prev; + }); + }, []); // 拖拽排序 - const handleReorder = useCallback( - (oldIndex: number, newIndex: number) => { - const newFields = [...fields]; + const handleReorder = useCallback((oldIndex: number, newIndex: number) => { + setFields((prev) => { + const newFields = [...prev]; 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); - } + return newFields; + }); + setSelectedIndex((prev) => { + if (prev === oldIndex) return newIndex; + if (prev !== null) { + if (oldIndex < prev && newIndex >= prev) return prev - 1; + if (oldIndex > prev && newIndex <= prev) return prev + 1; } - }, - [fields, selectedIndex], - ); + return prev; + }); + }, []); // 加载规则 const handleLoadRule = useCallback( @@ -293,6 +287,7 @@ export default function TestDataGeneratorPage() { handleUpdateField(selectedIndex, updatedField)} + allFieldNames={fields.map((f) => f.name)} /> )}
diff --git a/src/utils/ruleStorage.ts b/src/utils/ruleStorage.ts index fb4ed61..8253abf 100644 --- a/src/utils/ruleStorage.ts +++ b/src/utils/ruleStorage.ts @@ -138,8 +138,10 @@ export function deleteRule(id: string): boolean { /** * 复制规则 + * @param id 规则 ID + * @param copySuffix 复制后缀,默认为中文「(副本)」,可通过 i18n 传入 */ -export function duplicate(id: string): DataRule | null { +export function duplicate(id: string, copySuffix = '(副本)'): DataRule | null { const rule = getById(id); if (!rule) { console.warn('[ruleStorage] 规则不存在:', id); @@ -155,7 +157,7 @@ export function duplicate(id: string): DataRule | null { const newRule: DataRule = { ...rule, id: generateId(), - name: `${rule.name}(副本)`, + name: `${rule.name}${copySuffix}`, createdAt: now, updatedAt: now, useCount: 0,