fix: 更新 features 测试断言以匹配新增的 testDataGenerator
fix: 修复测试数据生成器多项代码问题 - useCallback 闭包问题:改用函数式 setFields 更新,避免依赖过时引用 - Worker 复用:复用已有 Worker 而非每次 generate 都销毁重建 - 导出复制反馈:复制操作完成后添加 toast 成功/失败提示 - 字段名校验:添加空值、非法字符、重复名称校验 - i18n 硬编码:GeneratorConfig 和 ruleStorage 统一使用 t() 函数
This commit is contained in:
@@ -1260,5 +1260,45 @@
|
|||||||
"testDataGenerator_saveAs": {
|
"testDataGenerator_saveAs": {
|
||||||
"message": "另存为",
|
"message": "另存为",
|
||||||
"description": "Translation key: testDataGenerator_saveAs"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
|
|
||||||
describe('features', () => {
|
describe('features', () => {
|
||||||
describe('FEATURES', () => {
|
describe('FEATURES', () => {
|
||||||
it('should have 9 features defined', () => {
|
it('should have 10 features defined', () => {
|
||||||
expect(FEATURES).toHaveLength(9);
|
expect(FEATURES).toHaveLength(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have all required properties for each feature', () => {
|
it('should have all required properties for each feature', () => {
|
||||||
@@ -95,7 +95,7 @@ describe('features', () => {
|
|||||||
describe('getAllFeatureKeys', () => {
|
describe('getAllFeatureKeys', () => {
|
||||||
it('should return all feature keys', () => {
|
it('should return all feature keys', () => {
|
||||||
const allKeys = getAllFeatureKeys();
|
const allKeys = getAllFeatureKeys();
|
||||||
expect(allKeys).toHaveLength(9);
|
expect(allKeys).toHaveLength(10);
|
||||||
expect(allKeys).toContain('dashboard');
|
expect(allKeys).toContain('dashboard');
|
||||||
expect(allKeys).toContain('timestamp');
|
expect(allKeys).toContain('timestamp');
|
||||||
expect(allKeys).toContain('storageCleaner');
|
expect(allKeys).toContain('storageCleaner');
|
||||||
@@ -105,6 +105,7 @@ describe('features', () => {
|
|||||||
expect(allKeys).toContain('jsonDiff');
|
expect(allKeys).toContain('jsonDiff');
|
||||||
expect(allKeys).toContain('base64Converter');
|
expect(allKeys).toContain('base64Converter');
|
||||||
expect(allKeys).toContain('rightClickRestorer');
|
expect(allKeys).toContain('rightClickRestorer');
|
||||||
|
expect(allKeys).toContain('testDataGenerator');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,9 +122,9 @@ describe('features', () => {
|
|||||||
expect(pageOrder).toContain('qrCode');
|
expect(pageOrder).toContain('qrCode');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have 8 items in page order', () => {
|
it('should have 9 items in page order', () => {
|
||||||
const pageOrder = getDefaultPageOrder();
|
const pageOrder = getDefaultPageOrder();
|
||||||
expect(pageOrder).toHaveLength(8);
|
expect(pageOrder).toHaveLength(9);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Copy, Download } from 'lucide-react';
|
import { Copy, Download } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { DataExporter } from '@/utils/dataExporter';
|
import { DataExporter } from '@/utils/dataExporter';
|
||||||
@@ -22,12 +23,22 @@ export default function ExportPanel({ result }: ExportPanelProps) {
|
|||||||
|
|
||||||
const handleCopyJSON = async () => {
|
const handleCopyJSON = async () => {
|
||||||
const content = DataExporter.toJSON(result.data!);
|
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 handleCopyCSV = async () => {
|
||||||
const content = DataExporter.toCSV(result.data!);
|
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 = () => {
|
const handleDownloadJSON = () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* 编辑单个字段的详细配置
|
* 编辑单个字段的详细配置
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
@@ -15,14 +16,40 @@ import GeneratorConfig from './GeneratorConfig';
|
|||||||
interface FieldEditorProps {
|
interface FieldEditorProps {
|
||||||
field: FieldConfig;
|
field: FieldConfig;
|
||||||
onChange: (field: FieldConfig) => void;
|
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 { t } = useI18n('testDataGenerator');
|
||||||
const generator = getGeneratorById(field.generatorId);
|
const generator = getGeneratorById(field.generatorId);
|
||||||
|
const [nameError, setNameError] = useState<string | null>(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) => {
|
const handleNameChange = (name: string) => {
|
||||||
onChange({ ...field, name });
|
onChange({ ...field, name });
|
||||||
|
// 实时校验
|
||||||
|
const error = validateFieldName(name);
|
||||||
|
setNameError(error);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDescriptionChange = (description: string) => {
|
const handleDescriptionChange = (description: string) => {
|
||||||
@@ -93,8 +120,9 @@ export default function FieldEditor({ field, onChange }: FieldEditorProps) {
|
|||||||
onChange={(e) => handleNameChange(e.target.value)}
|
onChange={(e) => handleNameChange(e.target.value)}
|
||||||
placeholder={t('testDataGenerator_fieldNamePlaceholder')}
|
placeholder={t('testDataGenerator_fieldNamePlaceholder')}
|
||||||
maxLength={20}
|
maxLength={20}
|
||||||
className="h-9"
|
className={`h-9 ${nameError ? 'border-destructive' : ''}`}
|
||||||
/>
|
/>
|
||||||
|
{nameError && <p className="text-xs text-destructive mt-1">{nameError}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import type { GeneratorDefinition } from '@/types/testDataGenerator';
|
import type { GeneratorDefinition } from '@/types/testDataGenerator';
|
||||||
|
|
||||||
interface GeneratorConfigProps {
|
interface GeneratorConfigProps {
|
||||||
@@ -21,12 +22,17 @@ interface GeneratorConfigProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function GeneratorConfig({ generator, params, onChange }: GeneratorConfigProps) {
|
export default function GeneratorConfig({ generator, params, onChange }: GeneratorConfigProps) {
|
||||||
|
const { t } = useI18n('testDataGenerator');
|
||||||
const handleParamChange = (key: string, value: unknown) => {
|
const handleParamChange = (key: string, value: unknown) => {
|
||||||
onChange({ ...params, [key]: value });
|
onChange({ ...params, [key]: value });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (generator.params.length === 0) {
|
if (generator.params.length === 0) {
|
||||||
return <p className="text-sm text-muted-foreground py-2">此生成器无可配置参数</p>;
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground py-2">
|
||||||
|
{t('testDataGenerator_noGeneratorParams')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -64,7 +70,9 @@ export default function GeneratorConfig({ generator, params, onChange }: Generat
|
|||||||
{param.type === 'boolean' && (
|
{param.type === 'boolean' && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{params[param.key] !== false ? '启用' : '禁用'}
|
{params[param.key] !== false
|
||||||
|
? t('testDataGenerator_enabled')
|
||||||
|
: t('testDataGenerator_disabled')}
|
||||||
</span>
|
</span>
|
||||||
<Switch
|
<Switch
|
||||||
checked={params[param.key] !== false}
|
checked={params[param.key] !== false}
|
||||||
@@ -107,7 +115,7 @@ export default function GeneratorConfig({ generator, params, onChange }: Generat
|
|||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
placeholder="用逗号分隔多个值"
|
placeholder={t('testDataGenerator_commaSeparated')}
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
|
|||||||
|
|
||||||
const handleDuplicate = useCallback(
|
const handleDuplicate = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
const result = ruleStorage.duplicate(id);
|
const result = ruleStorage.duplicate(id, t('testDataGenerator_ruleCopySuffix'));
|
||||||
if (result) {
|
if (result) {
|
||||||
loadRules();
|
loadRules();
|
||||||
toast.success(t('testDataGenerator_ruleDuplicated'));
|
toast.success(t('testDataGenerator_ruleDuplicated'));
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ describe('RuleManager', () => {
|
|||||||
const deleteButton = screen.getByTitle('删除');
|
const deleteButton = screen.getByTitle('删除');
|
||||||
await user.click(deleteButton);
|
await user.click(deleteButton);
|
||||||
|
|
||||||
expect(screen.getByText('确认删除规则')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/确定要删除规则/)).toBeInTheDocument();
|
expect(screen.getByText(/确定要删除规则/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,7 +132,7 @@ describe('RuleManager', () => {
|
|||||||
|
|
||||||
await user.click(screen.getByTitle('复制'));
|
await user.click(screen.getByTitle('复制'));
|
||||||
|
|
||||||
expect(mockedRuleStorage.duplicate).toHaveBeenCalledWith('rule-1');
|
expect(mockedRuleStorage.duplicate).toHaveBeenCalledWith('rule-1', '(副本)');
|
||||||
expect(mockedToast.success).toHaveBeenCalled();
|
expect(mockedToast.success).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function useGenerator(): UseGeneratorReturn {
|
|||||||
*/
|
*/
|
||||||
const getWorker = useCallback((): Worker => {
|
const getWorker = useCallback((): Worker => {
|
||||||
if (workerRef.current) {
|
if (workerRef.current) {
|
||||||
workerRef.current.terminate();
|
return workerRef.current;
|
||||||
}
|
}
|
||||||
|
|
||||||
const worker = new Worker(new URL('@/workers/generator.worker.ts', import.meta.url), {
|
const worker = new Worker(new URL('@/workers/generator.worker.ts', import.meta.url), {
|
||||||
@@ -82,8 +82,11 @@ export function useGenerator(): UseGeneratorReturn {
|
|||||||
worker.onerror = (err) => {
|
worker.onerror = (err) => {
|
||||||
console.error('[useGenerator] Worker 错误:', err);
|
console.error('[useGenerator] Worker 错误:', err);
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
setError('Worker 运行错误');
|
setError(err.message || 'Worker 运行错误');
|
||||||
setProgress(null);
|
setProgress(null);
|
||||||
|
// Worker 出错后销毁,下次重新创建
|
||||||
|
workerRef.current?.terminate();
|
||||||
|
workerRef.current = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
workerRef.current = worker;
|
workerRef.current = worker;
|
||||||
|
|||||||
@@ -60,64 +60,58 @@ export default function TestDataGeneratorPage() {
|
|||||||
|
|
||||||
// 添加新字段
|
// 添加新字段
|
||||||
const handleAddField = useCallback(() => {
|
const handleAddField = useCallback(() => {
|
||||||
if (fields.length >= MAX_FIELDS) return;
|
setFields((prev) => {
|
||||||
|
if (prev.length >= MAX_FIELDS) return prev;
|
||||||
const newField: FieldConfig = {
|
const newField: FieldConfig = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
name: `field${fields.length + 1}`,
|
name: `field${prev.length + 1}`,
|
||||||
generatorId: 'chineseName',
|
generatorId: 'chineseName',
|
||||||
params: {},
|
params: {},
|
||||||
required: true,
|
required: true,
|
||||||
nullRate: 0,
|
nullRate: 0,
|
||||||
unique: false,
|
unique: false,
|
||||||
};
|
};
|
||||||
setFields([...fields, newField]);
|
setSelectedIndex(prev.length);
|
||||||
setSelectedIndex(fields.length);
|
return [...prev, newField];
|
||||||
}, [fields]);
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 更新字段
|
// 更新字段
|
||||||
const handleUpdateField = useCallback(
|
const handleUpdateField = useCallback((index: number, field: FieldConfig) => {
|
||||||
(index: number, field: FieldConfig) => {
|
setFields((prev) => {
|
||||||
const newFields = [...fields];
|
const newFields = [...prev];
|
||||||
newFields[index] = field;
|
newFields[index] = field;
|
||||||
setFields(newFields);
|
return newFields;
|
||||||
},
|
});
|
||||||
[fields],
|
}, []);
|
||||||
);
|
|
||||||
|
|
||||||
// 删除字段
|
// 删除字段
|
||||||
const handleRemoveField = useCallback(
|
const handleRemoveField = useCallback((index: number) => {
|
||||||
(index: number) => {
|
setFields((prev) => prev.filter((_, i) => i !== index));
|
||||||
const newFields = fields.filter((_, i) => i !== index);
|
setSelectedIndex((prev) => {
|
||||||
setFields(newFields);
|
if (prev === index) return null;
|
||||||
if (selectedIndex === index) {
|
if (prev !== null && prev > index) return prev - 1;
|
||||||
setSelectedIndex(null);
|
return prev;
|
||||||
} else if (selectedIndex !== null && selectedIndex > index) {
|
});
|
||||||
setSelectedIndex(selectedIndex - 1);
|
}, []);
|
||||||
}
|
|
||||||
},
|
|
||||||
[fields, selectedIndex],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 拖拽排序
|
// 拖拽排序
|
||||||
const handleReorder = useCallback(
|
const handleReorder = useCallback((oldIndex: number, newIndex: number) => {
|
||||||
(oldIndex: number, newIndex: number) => {
|
setFields((prev) => {
|
||||||
const newFields = [...fields];
|
const newFields = [...prev];
|
||||||
const [moved] = newFields.splice(oldIndex, 1);
|
const [moved] = newFields.splice(oldIndex, 1);
|
||||||
newFields.splice(newIndex, 0, moved);
|
newFields.splice(newIndex, 0, moved);
|
||||||
setFields(newFields);
|
return newFields;
|
||||||
// 同步更新选中索引
|
});
|
||||||
if (selectedIndex === oldIndex) {
|
setSelectedIndex((prev) => {
|
||||||
setSelectedIndex(newIndex);
|
if (prev === oldIndex) return newIndex;
|
||||||
} else if (selectedIndex !== null) {
|
if (prev !== null) {
|
||||||
if (oldIndex < selectedIndex && newIndex >= selectedIndex) {
|
if (oldIndex < prev && newIndex >= prev) return prev - 1;
|
||||||
setSelectedIndex(selectedIndex - 1);
|
if (oldIndex > prev && newIndex <= prev) return prev + 1;
|
||||||
} else if (oldIndex > selectedIndex && newIndex <= selectedIndex) {
|
|
||||||
setSelectedIndex(selectedIndex + 1);
|
|
||||||
}
|
}
|
||||||
}
|
return prev;
|
||||||
},
|
});
|
||||||
[fields, selectedIndex],
|
}, []);
|
||||||
);
|
|
||||||
|
|
||||||
// 加载规则
|
// 加载规则
|
||||||
const handleLoadRule = useCallback(
|
const handleLoadRule = useCallback(
|
||||||
@@ -293,6 +287,7 @@ export default function TestDataGeneratorPage() {
|
|||||||
<FieldEditor
|
<FieldEditor
|
||||||
field={selectedField}
|
field={selectedField}
|
||||||
onChange={(updatedField) => handleUpdateField(selectedIndex, updatedField)}
|
onChange={(updatedField) => handleUpdateField(selectedIndex, updatedField)}
|
||||||
|
allFieldNames={fields.map((f) => f.name)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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);
|
const rule = getById(id);
|
||||||
if (!rule) {
|
if (!rule) {
|
||||||
console.warn('[ruleStorage] 规则不存在:', id);
|
console.warn('[ruleStorage] 规则不存在:', id);
|
||||||
@@ -155,7 +157,7 @@ export function duplicate(id: string): DataRule | null {
|
|||||||
const newRule: DataRule = {
|
const newRule: DataRule = {
|
||||||
...rule,
|
...rule,
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
name: `${rule.name}(副本)`,
|
name: `${rule.name}${copySuffix}`,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
useCount: 0,
|
useCount: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user