forked from hangshuo652/aurak
0a9588abb7
- Add pagination support to findAll (page, limit query params) - Add findByTemplateId method to service - Add GET /by-template/:templateId endpoint to controller - Service already includes CRUD for QuestionBank and QuestionBankItem
555 lines
27 KiB
TypeScript
555 lines
27 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { X, FolderInput, ArrowRight, Info, Layers, Clock, Upload, Calendar } from 'lucide-react';
|
|
import { isExtensionAllowed } from '../constants/fileSupport';
|
|
import { useLanguage } from '../contexts/LanguageContext';
|
|
import { ModelConfig, ModelType, IndexingConfig, KnowledgeGroup } from '../types';
|
|
import { modelConfigService } from '../services/modelConfigService';
|
|
import { knowledgeGroupService } from '../services/knowledgeGroupService';
|
|
import { apiClient } from '../services/apiClient';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
import IndexingModalWithMode from './IndexingModalWithMode';
|
|
|
|
interface ImportFolderDrawerProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
authToken: string;
|
|
initialGroupId?: string;
|
|
initialGroupName?: string;
|
|
onImportSuccess?: () => void;
|
|
}
|
|
|
|
interface FileWithPath {
|
|
file: File;
|
|
relativePath: string;
|
|
}
|
|
|
|
type ImportMode = 'immediate' | 'scheduled';
|
|
|
|
export const ImportFolderDrawer: React.FC<ImportFolderDrawerProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
authToken,
|
|
initialGroupId,
|
|
initialGroupName,
|
|
onImportSuccess,
|
|
}) => {
|
|
const { t } = useLanguage();
|
|
const { showError, showSuccess } = useToast();
|
|
|
|
// Tab
|
|
const [importMode, setImportMode] = useState<ImportMode>('immediate');
|
|
|
|
// Immediate mode state
|
|
const [localFiles, setLocalFiles] = useState<FileWithPath[]>([]);
|
|
const [folderName, setFolderName] = useState('');
|
|
const [targetName, setTargetName] = useState('');
|
|
const [useHierarchy, setUseHierarchy] = useState(false);
|
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
const [isIndexingConfigOpen, setIsIndexingConfigOpen] = useState(false);
|
|
const [models, setModels] = useState<ModelConfig[]>([]);
|
|
|
|
// Scheduled mode state
|
|
const [serverPath, setServerPath] = useState('');
|
|
const [scheduledTime, setScheduledTime] = useState(() => {
|
|
// Default to 30 min from now
|
|
const d = new Date();
|
|
d.setMinutes(d.getMinutes() + 30);
|
|
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
|
|
return d.toISOString().slice(0, 16); // "YYYY-MM-DDTHH:mm"
|
|
});
|
|
const [schedTargetName, setSchedTargetName] = useState('');
|
|
const [schedUseHierarchy, setSchedUseHierarchy] = useState(false);
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [allGroups, setAllGroups] = useState<KnowledgeGroup[]>([]);
|
|
const [parentGroupId, setParentGroupId] = useState<string>('');
|
|
const [schedParentGroupId, setSchedParentGroupId] = useState<string>('');
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setLocalFiles([]);
|
|
setFolderName('');
|
|
setTargetName(initialGroupName || '');
|
|
setUseHierarchy(false);
|
|
setIsIndexingConfigOpen(false);
|
|
setImportMode('immediate');
|
|
setServerPath('');
|
|
setSchedTargetName(initialGroupName || '');
|
|
setSchedUseHierarchy(false);
|
|
setParentGroupId('');
|
|
setSchedParentGroupId('');
|
|
|
|
// Default scheduled time = 30min from now
|
|
const d = new Date();
|
|
d.setMinutes(d.getMinutes() + 30);
|
|
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
|
|
setScheduledTime(d.toISOString().slice(0, 16));
|
|
|
|
modelConfigService.getAll(authToken).then(res => {
|
|
setModels(res.filter(m => m.type === ModelType.EMBEDDING));
|
|
});
|
|
|
|
knowledgeGroupService.getGroups().then(groups => {
|
|
const flat: any[] = [];
|
|
function walk(items: any[], depth = 0) {
|
|
for (const g of items) {
|
|
flat.push({ ...g, d: depth });
|
|
if (g.children?.length) walk(g.children, depth + 1);
|
|
}
|
|
}
|
|
walk(groups);
|
|
setAllGroups(flat);
|
|
});
|
|
}
|
|
}, [isOpen, authToken, initialGroupName]);
|
|
|
|
// ---- Immediate mode handlers ----
|
|
const handleLocalFolderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (e.target.files && e.target.files.length > 0) {
|
|
const allFiles = Array.from(e.target.files);
|
|
const files: FileWithPath[] = allFiles
|
|
.filter(file => {
|
|
const ext = file.name.split('.').pop() || '';
|
|
return isExtensionAllowed(ext, 'group');
|
|
})
|
|
.map(file => ({
|
|
file,
|
|
relativePath: file.webkitRelativePath || file.name,
|
|
}));
|
|
|
|
if (files.length === 0 && allFiles.length > 0) {
|
|
showError(t('noFilesFound'));
|
|
return;
|
|
}
|
|
setLocalFiles(files);
|
|
|
|
const firstPath = allFiles[0].webkitRelativePath;
|
|
if (firstPath) {
|
|
const parts = firstPath.split('/');
|
|
if (parts.length > 0) {
|
|
const name = parts[0];
|
|
setFolderName(name);
|
|
if (!initialGroupId && !targetName) setTargetName(name);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleImmediateNext = () => {
|
|
if (localFiles.length === 0) {
|
|
showError(t('clickToSelectFolder'));
|
|
return;
|
|
}
|
|
if (!initialGroupId && !targetName) {
|
|
showError(t('fillTargetName'));
|
|
return;
|
|
}
|
|
setIsIndexingConfigOpen(true);
|
|
};
|
|
|
|
const handleConfirmConfig = async (config: IndexingConfig) => {
|
|
setIsLoading(true);
|
|
try {
|
|
const { uploadService } = await import('../services/uploadService');
|
|
|
|
if (useHierarchy) {
|
|
// Step 1: Determine root group
|
|
let rootGroupId = initialGroupId ?? null;
|
|
if (!rootGroupId) {
|
|
const newGroup = await knowledgeGroupService.createGroup({
|
|
name: targetName,
|
|
description: t('importedFromLocalFolder').replace('$1', folderName),
|
|
parentId: parentGroupId || null,
|
|
});
|
|
rootGroupId = newGroup.id;
|
|
}
|
|
|
|
// Step 2: Collect all unique directory paths
|
|
const dirSet = new Set<string>();
|
|
for (const { relativePath } of localFiles) {
|
|
const parts = relativePath.split('/');
|
|
const dirParts = initialGroupId
|
|
? parts.slice(1, parts.length - 1)
|
|
: parts.slice(0, parts.length - 1);
|
|
for (let i = 1; i <= dirParts.length; i++) {
|
|
dirSet.add(dirParts.slice(0, i).join('/'));
|
|
}
|
|
}
|
|
|
|
// Step 3: Sort by depth, create groups sequentially
|
|
const sortedDirs = Array.from(dirSet).sort((a, b) =>
|
|
a.split('/').length - b.split('/').length
|
|
);
|
|
const dirToGroupId = new Map<string, string>();
|
|
dirToGroupId.set(initialGroupId ? '' : folderName, rootGroupId);
|
|
|
|
for (const dirPath of sortedDirs) {
|
|
if (!dirPath || dirToGroupId.has(dirPath)) continue;
|
|
const segments = dirPath.split('/');
|
|
const segName = segments[segments.length - 1];
|
|
const parentPath = segments.slice(0, segments.length - 1).join('/');
|
|
const parentId = dirToGroupId.get(parentPath) ?? rootGroupId;
|
|
const newGroup = await knowledgeGroupService.createGroup({ name: segName, parentId });
|
|
dirToGroupId.set(dirPath, newGroup.id);
|
|
}
|
|
|
|
// Step 4: Upload files in parallel batches
|
|
const BATCH_SIZE = 3;
|
|
for (let i = 0; i < localFiles.length; i += BATCH_SIZE) {
|
|
const batch = localFiles.slice(i, i + BATCH_SIZE);
|
|
await Promise.all(batch.map(async ({ file, relativePath }) => {
|
|
try {
|
|
const parts = relativePath.split('/');
|
|
const dirParts = initialGroupId
|
|
? parts.slice(1, parts.length - 1)
|
|
: parts.slice(0, parts.length - 1);
|
|
const fileDirPath = dirParts.join('/');
|
|
const targetGroupId = dirToGroupId.get(fileDirPath) ?? rootGroupId!;
|
|
const uploadedKb = await uploadService.uploadFileWithConfig(file, config, authToken);
|
|
await knowledgeGroupService.addFileToGroups(uploadedKb.id, [targetGroupId]);
|
|
} catch (err) {
|
|
console.error(`Failed to upload ${file.name}:`, err);
|
|
}
|
|
}));
|
|
}
|
|
} else {
|
|
// Single-group mode
|
|
let groupId = initialGroupId ?? null;
|
|
if (!groupId) {
|
|
const newGroup = await knowledgeGroupService.createGroup({
|
|
name: targetName,
|
|
description: t('importedFromLocalFolder').replace('$1', folderName),
|
|
});
|
|
groupId = newGroup.id;
|
|
}
|
|
const BATCH_SIZE = 3;
|
|
for (let i = 0; i < localFiles.length; i += BATCH_SIZE) {
|
|
const batch = localFiles.slice(i, i + BATCH_SIZE);
|
|
await Promise.all(batch.map(async ({ file }) => {
|
|
try {
|
|
const uploadedKb = await uploadService.uploadFileWithConfig(file, config, authToken);
|
|
if (groupId) await knowledgeGroupService.addFileToGroups(uploadedKb.id, [groupId]);
|
|
} catch (err) {
|
|
console.error(`Failed to upload ${file.name}:`, err);
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
|
|
showSuccess(t('importComplete'));
|
|
onImportSuccess?.();
|
|
onClose();
|
|
} catch (error: any) {
|
|
showError(t('submitFailed', error.message));
|
|
} finally {
|
|
setIsLoading(false);
|
|
setIsIndexingConfigOpen(false);
|
|
}
|
|
};
|
|
|
|
// ---- Scheduled mode handler ----
|
|
const handleScheduledSubmit = async () => {
|
|
if (!serverPath.trim()) {
|
|
showError(t('fillServerPath'));
|
|
return;
|
|
}
|
|
if (!initialGroupId && !schedTargetName.trim()) {
|
|
showError(t('fillTargetName'));
|
|
return;
|
|
}
|
|
|
|
const scheduledAt = new Date(scheduledTime);
|
|
if (isNaN(scheduledAt.getTime())) {
|
|
showError(t('invalidDateTime' as any));
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
let finalGroupId = initialGroupId || undefined;
|
|
if (!finalGroupId && schedTargetName.trim()) {
|
|
const newGroup = await knowledgeGroupService.createGroup({
|
|
name: schedTargetName.trim(),
|
|
description: t('importedFromLocalFolder').replace('$1', schedTargetName.trim()),
|
|
parentId: schedParentGroupId || null,
|
|
});
|
|
finalGroupId = newGroup.id;
|
|
}
|
|
|
|
const defaultModel = models[0];
|
|
await apiClient.post('/import-tasks', {
|
|
sourcePath: serverPath.trim(),
|
|
targetGroupId: finalGroupId,
|
|
targetGroupName: undefined,
|
|
embeddingModelId: defaultModel?.id,
|
|
scheduledAt: scheduledAt.toISOString(),
|
|
chunkSize: 500,
|
|
chunkOverlap: 50,
|
|
mode: 'fast',
|
|
useHierarchy: schedUseHierarchy,
|
|
});
|
|
|
|
showSuccess(t('scheduleTaskCreated'));
|
|
onImportSuccess?.();
|
|
onClose();
|
|
} catch (error: any) {
|
|
showError(t('submitFailed', error.message));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<>
|
|
<div className="fixed inset-0 z-50 flex justify-end">
|
|
<div className="absolute inset-0 bg-black/20 backdrop-blur-sm transition-opacity" onClick={onClose} />
|
|
|
|
<div className="relative w-full max-w-md bg-white shadow-2xl flex flex-col h-full animate-in slide-in-from-right duration-300">
|
|
{/* Header */}
|
|
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between shrink-0 bg-white">
|
|
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
|
<FolderInput className="w-5 h-5 text-blue-600" />
|
|
{t('importFolderTitle')}
|
|
</h2>
|
|
<button onClick={onClose} className="p-2 -mr-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-full transition-colors">
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Mode Tabs */}
|
|
<div className="flex border-b border-slate-100 shrink-0">
|
|
<button
|
|
onClick={() => setImportMode('immediate')}
|
|
className={`flex-1 flex items-center justify-center gap-2 py-3 text-sm font-semibold transition-colors border-b-2 ${importMode === 'immediate'
|
|
? 'border-blue-600 text-blue-600 bg-blue-50/40'
|
|
: 'border-transparent text-slate-500 hover:text-slate-700'
|
|
}`}
|
|
>
|
|
<Upload size={15} />
|
|
{t('importImmediate')}
|
|
</button>
|
|
<button
|
|
onClick={() => setImportMode('scheduled')}
|
|
className={`flex-1 flex items-center justify-center gap-2 py-3 text-sm font-semibold transition-colors border-b-2 ${importMode === 'scheduled'
|
|
? 'border-blue-600 text-blue-600 bg-blue-50/40'
|
|
: 'border-transparent text-slate-500 hover:text-slate-700'
|
|
}`}
|
|
>
|
|
<Clock size={15} />
|
|
{t('importScheduled')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="flex-1 overflow-y-auto p-6 space-y-5">
|
|
{importMode === 'immediate' ? (
|
|
<>
|
|
{/* Immediate: folder picker */}
|
|
<div
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="border-2 border-dashed border-slate-200 rounded-xl p-8 flex flex-col items-center justify-center gap-3 cursor-pointer hover:border-blue-400 hover:bg-blue-50 transition-all group"
|
|
>
|
|
<div className="w-12 h-12 bg-slate-50 text-slate-400 rounded-full flex items-center justify-center group-hover:bg-blue-100 group-hover:text-blue-600 transition-colors">
|
|
<FolderInput size={24} />
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-sm font-medium text-slate-700">
|
|
{localFiles.length > 0
|
|
? t('selectedFilesCount').replace('$1', localFiles.length.toString())
|
|
: t('clickToSelectFolder')}
|
|
</p>
|
|
<p className="text-xs text-slate-400 mt-1">
|
|
{localFiles.length > 0 ? folderName : t('selectFolderTip')}
|
|
</p>
|
|
</div>
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
onChange={handleLocalFolderChange}
|
|
className="hidden"
|
|
multiple
|
|
// @ts-ignore
|
|
webkitdirectory=""
|
|
directory=""
|
|
/>
|
|
</div>
|
|
|
|
{/* Target group */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium text-slate-700">{t('lblTargetGroup')}</label>
|
|
<input
|
|
type="text"
|
|
value={targetName}
|
|
onChange={e => setTargetName(e.target.value)}
|
|
disabled={!!initialGroupId}
|
|
placeholder={t('placeholderNewGroup')}
|
|
className={`w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none ${initialGroupId ? 'bg-slate-50 text-slate-500' : ''}`}
|
|
/>
|
|
{initialGroupId && <p className="text-xs text-slate-400">{t('importToCurrentGroup')}</p>}
|
|
</div>
|
|
{!initialGroupId && (
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium text-slate-700">{t('parentCategory') || 'Parent Category'}</label>
|
|
<select
|
|
value={parentGroupId}
|
|
onChange={e => setParentGroupId(e.target.value)}
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none"
|
|
>
|
|
<option value="">{t('allGroups' as any) || '-- Root --'}</option>
|
|
{allGroups.map((g: any) => (
|
|
<option key={g.id} value={g.id}>
|
|
{'\u00A0'.repeat(g.d * 4)}{g.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{/* Hierarchy toggle */}
|
|
<HierarchyToggle value={useHierarchy} onChange={setUseHierarchy} t={t} />
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* Scheduled: server path */}
|
|
<div className="bg-amber-50 border border-amber-100 rounded-lg p-3 text-sm text-amber-800 flex items-start gap-2">
|
|
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
|
<p className="text-xs">{t('scheduledImportTip')}</p>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium text-slate-700">{t('lblServerPath')}</label>
|
|
<input
|
|
type="text"
|
|
value={serverPath}
|
|
onChange={e => setServerPath(e.target.value)}
|
|
placeholder={t('placeholderServerPath')}
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none font-mono"
|
|
/>
|
|
</div>
|
|
|
|
{/* Target group */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium text-slate-700">{t('lblTargetGroup')}</label>
|
|
<input
|
|
type="text"
|
|
value={schedTargetName}
|
|
onChange={e => setSchedTargetName(e.target.value)}
|
|
disabled={!!initialGroupId}
|
|
placeholder={t('placeholderNewGroup')}
|
|
className={`w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none ${initialGroupId ? 'bg-slate-50 text-slate-500' : ''}`}
|
|
/>
|
|
{initialGroupId && <p className="text-xs text-slate-400">{t('importToCurrentGroup')}</p>}
|
|
</div>
|
|
{!initialGroupId && (
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium text-slate-700">{t('parentCategory') || 'Parent Category'}</label>
|
|
<select
|
|
value={schedParentGroupId}
|
|
onChange={e => setSchedParentGroupId(e.target.value)}
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none"
|
|
>
|
|
<option value="">{t('allGroups' as any) || '-- Root --'}</option>
|
|
{allGroups.map((g: any) => (
|
|
<option key={g.id} value={g.id}>
|
|
{'\u00A0'.repeat(g.d * 4)}{g.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{/* Scheduled datetime */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium text-slate-700 flex items-center gap-1.5">
|
|
<Calendar size={14} className="text-blue-500" />
|
|
{t('lblScheduledTime')}
|
|
</label>
|
|
<input
|
|
type="datetime-local"
|
|
value={scheduledTime}
|
|
onChange={e => setScheduledTime(e.target.value)}
|
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none"
|
|
/>
|
|
<p className="text-xs text-slate-400">{t('scheduledTimeHint')}</p>
|
|
</div>
|
|
|
|
{/* Hierarchy toggle */}
|
|
<HierarchyToggle value={schedUseHierarchy} onChange={setSchedUseHierarchy} t={t} />
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="p-6 border-t border-slate-100 shrink-0 flex gap-3 bg-slate-50">
|
|
<button
|
|
onClick={onClose}
|
|
className="flex-1 px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
|
|
>
|
|
{t('cancel')}
|
|
</button>
|
|
{importMode === 'immediate' ? (
|
|
<button
|
|
onClick={handleImmediateNext}
|
|
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg shadow-sm flex justify-center items-center gap-2 transition-all"
|
|
disabled={isLoading}
|
|
>
|
|
<span>{isLoading ? t('uploading') : t('nextStep')}</span>
|
|
<ArrowRight size={16} />
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={handleScheduledSubmit}
|
|
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg shadow-sm flex justify-center items-center gap-2 transition-all"
|
|
disabled={isLoading}
|
|
>
|
|
<Clock size={16} />
|
|
<span>{isLoading ? t('uploading') : t('scheduleImport')}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Indexing Config Modal (immediate mode only) */}
|
|
<IndexingModalWithMode
|
|
isOpen={isIndexingConfigOpen}
|
|
onClose={() => setIsIndexingConfigOpen(false)}
|
|
files={[]}
|
|
embeddingModels={models}
|
|
defaultEmbeddingId={models.length > 0 ? models[0].id : ''}
|
|
onConfirm={handleConfirmConfig}
|
|
isReconfiguring={false}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
/** Reusable hierarchy toggle */
|
|
const HierarchyToggle: React.FC<{
|
|
value: boolean;
|
|
onChange: (v: boolean) => void;
|
|
t: (key: string) => string;
|
|
}> = ({ value, onChange, t }) => (
|
|
<label className="flex items-center gap-3 cursor-pointer select-none">
|
|
<div className="relative">
|
|
<input
|
|
type="checkbox"
|
|
checked={value}
|
|
onChange={e => onChange(e.target.checked)}
|
|
className="sr-only"
|
|
/>
|
|
<div className={`w-10 h-5 rounded-full transition-colors ${value ? 'bg-blue-600' : 'bg-slate-200'}`} />
|
|
<div className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${value ? 'translate-x-5' : ''}`} />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-slate-700 flex items-center gap-1.5">
|
|
<Layers size={14} className="text-blue-500" />
|
|
{t('useHierarchyImport')}
|
|
</p>
|
|
<p className="text-xs text-slate-400 mt-0.5">{t('useHierarchyImportDesc')}</p>
|
|
</div>
|
|
</label>
|
|
);
|