Files
aurak/web/components/views/NotebookDetailView.tsx
Developer 0b0a060967 fix: 全部TS错误修复(25->0) + 证书API 500修复 + i18n缺失key补全 + 类型定义修正
- 证书API 500修复: AssessmentCertificate实体注册到app.module.ts
- 前端TS错误25个清零: i18n key 17个, 类型定义8个
- i18n补全: 17个缺失key添加到zh/en/ja
- KnowledgeFile类型: 添加title, content字段
- importService: 改用apiClient.request替代raw fetch
- ModeSelector: 移除jsx prop
- questionBankService: .ok -> .status >= 400
- NotebookDetailView: .filter -> .items.filter
- ImportTasksDrawer: tasks.items提取
- API端点审计: 16/16通过
- 数据库Schema审计: 25表288列一致
- AGENTS.md更新
2026-05-18 08:30:59 +08:00

313 lines
15 KiB
TypeScript

import React, { useEffect, useState, useCallback, useMemo } from 'react'
import { ArrowLeft, Plus, MessageSquare, BookOpen, Trash2, Eye, FileText, FileType, Image as ImageIcon, Search, RefreshCw } from 'lucide-react'
import { KnowledgeGroup, KnowledgeFile } from '../../types'
import { knowledgeBaseService } from '../../services/knowledgeBaseService'
import { modelConfigService } from '../../services/modelConfigService'
import { uploadService } from '../../services/uploadService'
import { knowledgeGroupService } from '../../services/knowledgeGroupService'
import { useToast } from '../../contexts/ToastContext'
import { useConfirm } from '../../contexts/ConfirmContext'
import { RawFile, IndexingConfig, ModelConfig } from '../../types'
import IndexingModalWithMode from '../IndexingModalWithMode'
import { PDFPreview } from '../PDFPreview'
import { NotebookGlobalDragDropOverlay } from '../NotebookGlobalDragDropOverlay'
import { useLanguage } from '../../contexts/LanguageContext'
import { readFile, formatBytes } from '../../utils/fileUtils'
import { isExtensionAllowed, isFormatSupportedForPreview } from '../../constants/fileSupport'
import { motion, AnimatePresence } from 'framer-motion'
interface NotebookDetailViewProps {
authToken: string;
notebook: KnowledgeGroup;
onBack: () => void;
onChatWithContext?: (context: { selectedGroups?: string[], selectedFiles?: string[] }) => void;
isAdmin?: boolean;
}
export const NotebookDetailView: React.FC<NotebookDetailViewProps> = ({ authToken, notebook, onBack, onChatWithContext, isAdmin = false }) => {
const [files, setFiles] = useState<KnowledgeFile[]>([])
const [isLoading, setIsLoading] = useState(false)
const { showError, showSuccess } = useToast()
const { confirm } = useConfirm()
const { t } = useLanguage()
const [isIndexingModalOpen, setIsIndexingModalOpen] = useState(false)
const [pendingFiles, setPendingFiles] = useState<RawFile[]>([])
const [shouldOpenModal, setShouldOpenModal] = useState(false)
const [models, setModels] = useState<ModelConfig[]>([])
const [pdfPreview, setPdfPreview] = useState<{ fileId: string; fileName: string } | null>(null)
const [filterName, setFilterName] = useState('')
const fileInputRef = React.useRef<HTMLInputElement>(null)
useEffect(() => {
const fetchModels = async () => {
try {
const res = await modelConfigService.getAll(authToken)
setModels(res)
} catch (error) {
console.error('Failed to fetch models', error)
}
}
if (authToken) fetchModels()
}, [authToken])
useEffect(() => {
if (shouldOpenModal && pendingFiles.length > 0) {
setIsIndexingModalOpen(true);
setShouldOpenModal(false);
}
}, [shouldOpenModal, pendingFiles.length]);
const loadData = useCallback(async () => {
setIsLoading(true)
try {
const allFiles = await knowledgeBaseService.getAll(authToken)
const notebookFiles = allFiles.items.filter(f => f.groups?.some(g => g.id === notebook.id))
setFiles(notebookFiles)
} catch (error) {
console.error(error)
showError(t('errorLoadData'))
} finally {
setIsLoading(false)
}
}, [authToken, notebook.id, t, showError])
useEffect(() => {
loadData()
}, [loadData])
const handleFileUpload = async (fileList: FileList | File[]) => {
if (!fileList || fileList.length === 0) return
const errors: string[] = []
const newPendingFiles: RawFile[] = []
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i]
try {
if (file.size > 104857600) {
errors.push(`${file.name} - ${t('fileSizeLimitExceeded', file.name, formatBytes(file.size), 100)}`)
continue
}
const extension = file.name.split('.').pop() || ''
if (!isExtensionAllowed(extension, 'group')) {
if (!(await confirm(t('confirmUnsupportedFile', extension || t('unknown'))))) continue
}
const rawFile = await readFile(file)
newPendingFiles.push(rawFile)
} catch (error: any) {
errors.push(`${file.name} - ${t('readingFailed')}`)
}
}
if (errors.length > 0) showError(`${t('uploadErrors')}:\n${errors.join('\n')}`)
if (newPendingFiles.length > 0) {
setPendingFiles(prev => [...prev, ...newPendingFiles])
setShouldOpenModal(true)
}
}
const handleConfirmIndexing = async (config: IndexingConfig) => {
setIsIndexingModalOpen(false)
try {
for (const rawFile of pendingFiles) {
const uploadRes = await uploadService.uploadFileWithConfig(rawFile.file, config, authToken)
if (uploadRes && uploadRes.id) {
await knowledgeGroupService.addFileToGroups(uploadRes.id, [notebook.id])
}
}
showSuccess(t('successUploadFile'))
loadData()
} catch (error: any) {
showError(t('errorUploadFile', error.message || t('unknownError')))
} finally {
setPendingFiles([])
}
}
const handleRemoveFile = async (fileId: string, fileName: string) => {
if (!(await confirm(t('confirmRemoveFileFromGroup', fileName)))) return;
try {
await knowledgeGroupService.removeFileFromGroup(fileId, notebook.id);
setFiles(prev => prev.filter(f => f.id !== fileId));
showSuccess(t('fileDeleted'));
} catch (error) {
showError(t('deleteFailed'));
}
}
const filteredFiles = useMemo(() => {
return files.filter(file => file.name.toLowerCase().includes(filterName.toLowerCase()));
}, [files, filterName]);
const getFileIcon = (file: KnowledgeFile) => {
if (file.type.startsWith('image/')) return <ImageIcon size={20} className="text-slate-500" />;
if (file.type === 'application/pdf') return <FileType size={20} className="text-blue-500" />;
return <FileText size={20} className="text-blue-500" />;
};
return (
<div className="flex flex-col h-full bg-transparent overflow-hidden">
<NotebookGlobalDragDropOverlay onFilesSelected={handleFileUpload} isAdmin={isAdmin} />
<input
type="file"
ref={fileInputRef}
onChange={(e) => e.target.files && handleFileUpload(e.target.files)}
multiple
className="hidden"
/>
{/* Header */}
<div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
<div className="flex items-start gap-4 min-w-0">
<button
onClick={onBack}
className="mt-1 p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors active:scale-90"
>
<ArrowLeft size={20} />
</button>
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="p-1.5 bg-blue-50 rounded-lg text-blue-600 border border-blue-100/30">
<BookOpen size={18} />
</div>
<h1 className="text-2xl font-bold text-slate-900 truncate leading-tight">
{notebook.name}
</h1>
</div>
<p className="text-[15px] text-slate-500 mt-1 truncate max-w-2xl">
{notebook.description || t('browseManageFiles')}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => onChatWithContext?.({ selectedGroups: [notebook.id] })}
className="flex items-center gap-2 px-5 py-2.5 bg-white border border-slate-200 text-slate-700 rounded-lg font-semibold text-sm hover:bg-slate-50 transition-all shadow-sm"
>
<MessageSquare size={18} className="text-blue-600" />
{t('chatWithGroup')}
</button>
{isAdmin && (
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm shadow-blue-100 transition-all font-semibold text-sm active:scale-95"
>
<Plus size={18} />
{t('addFile')}
</button>
)}
</div>
</div>
{/* Filter Bar */}
<div className="px-8 pb-6 flex items-center justify-between gap-4 shrink-0">
<div className="relative max-w-xs w-full">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
<input
type="text"
placeholder={t('filterGroupFiles')}
value={filterName}
onChange={(e) => setFilterName(e.target.value)}
className="w-full h-9 pl-9 pr-4 bg-white border border-slate-200 rounded-lg text-sm transition-all focus:ring-1 focus:ring-blue-500 focus:border-blue-500 outline-none"
/>
</div>
<button
onClick={() => loadData()}
className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all"
>
<RefreshCw size={18} />
</button>
</div>
{/* Content Area */}
<div className="flex-1 overflow-y-auto px-8 pb-8">
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
</div>
) : filteredFiles.length > 0 ? (
<div className="flex flex-col gap-3">
<AnimatePresence mode="popLayout">
{filteredFiles.map((file) => (
<motion.div
key={file.id}
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-xl border border-slate-200/80 p-4 shadow-sm hover:shadow-md transition-all group relative overflow-hidden flex items-center gap-4"
>
{/* Icon */}
<div className="p-2.5 bg-blue-50 rounded-lg text-blue-600 shadow-sm border border-blue-100/30 shrink-0">
{getFileIcon(file)}
</div>
{/* Main info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<h3 className="font-bold text-slate-900 text-[15px] truncate">
{file.name}
</h3>
<span className="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-[10px] font-bold tracking-wider uppercase shrink-0">
{file.status}
</span>
</div>
</div>
{/* Meta info & Actions */}
<div className="flex items-center gap-6 shrink-0">
<div className="text-[12px] font-medium text-slate-400 flex flex-col items-end">
<span>{formatBytes(file.size)}</span>
<span className="text-[10px] font-bold text-slate-300 uppercase tracking-widest mt-0.5">
{file.name.split('.').pop()?.toUpperCase() || 'FILE'}
</span>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{isFormatSupportedForPreview(file.name) && (
<button onClick={() => setPdfPreview({ fileId: file.id, fileName: file.name })} className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md bg-slate-50">
<Eye size={16} />
</button>
)}
{isAdmin && (
<button onClick={() => handleRemoveFile(file.id, file.name)} className="p-1.5 text-slate-400 hover:text-red-500 rounded-md bg-slate-50">
<Plus size={16} className="rotate-45" />
</button>
)}
</div>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
) : (
<div className="flex flex-col items-center justify-center py-32 border-2 border-dashed border-slate-200 rounded-2xl bg-white/50 text-center">
<BookOpen className="w-12 h-12 text-slate-200 mx-auto mb-4" />
<h3 className="text-slate-900 font-bold">{t('noFiles')}</h3>
<p className="text-slate-500 text-sm mt-1">{t('noFilesDesc')}</p>
</div>
)}
</div>
<IndexingModalWithMode
isOpen={isIndexingModalOpen}
onClose={() => { setPendingFiles([]); setIsIndexingModalOpen(false); }}
files={pendingFiles}
embeddingModels={models.filter(m => m.type === 'embedding')}
defaultEmbeddingId={models.find(m => m.isDefault)?.id || ''}
onConfirm={handleConfirmIndexing}
authToken={authToken}
/>
{pdfPreview && (
<PDFPreview
fileId={pdfPreview.fileId}
fileName={pdfPreview.fileName}
authToken={authToken}
onClose={() => setPdfPreview(null)}
/>
)}
</div>
)
}