Files
aurak/web/components/ImportFolderDrawer.tsx
T
Developer 0a9588abb7 feat: implement QuestionBank CRUD with pagination and template query
- 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
2026-04-23 17:19:11 +08:00

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>
);