forked from hangshuo652/aurak
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
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { knowledgeGroupService } from '../../services/knowledgeGroupService'
|
||||
import { KnowledgeGroup, UpdateGroupData, CreateGroupData } from '../../types'
|
||||
import { Plus, Book, Library, MessageSquare, Trash2, Edit2, FolderInput, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { NotebookDetailView } from './NotebookDetailView'
|
||||
import { CreateNotebookDrawer } from '../CreateNotebookDrawer'
|
||||
import { EditNotebookDrawer } from '../EditNotebookDrawer'
|
||||
import { ImportFolderDrawer } from '../ImportFolderDrawer'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { useToast } from '../../contexts/ToastContext'
|
||||
import { useConfirm } from '../../contexts/ConfirmContext'
|
||||
|
||||
interface NotebooksViewProps {
|
||||
authToken: string
|
||||
onChatWithContext: (context: { selectedGroups?: string[], selectedFiles?: string[] }) => void
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
/** Flatten a tree of groups into a flat list */
|
||||
function flattenGroups(groups: KnowledgeGroup[]): KnowledgeGroup[] {
|
||||
const result: KnowledgeGroup[] = [];
|
||||
function walk(items: KnowledgeGroup[]) {
|
||||
for (const g of items) {
|
||||
result.push(g);
|
||||
if (g.children?.length) walk(g.children);
|
||||
}
|
||||
}
|
||||
walk(groups);
|
||||
return result;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
export const NotebooksView: React.FC<NotebooksViewProps> = ({ authToken, onChatWithContext, isAdmin = false }) => {
|
||||
const { t } = useLanguage()
|
||||
const { showError } = useToast()
|
||||
const { confirm } = useConfirm()
|
||||
const [notebooks, setNotebooks] = React.useState<KnowledgeGroup[]>([])
|
||||
const [isLoading, setIsLoading] = React.useState(true)
|
||||
const [selectedNotebook, setSelectedNotebook] = React.useState<KnowledgeGroup | null>(null)
|
||||
const [isCreateDrawerOpen, setIsCreateDrawerOpen] = React.useState(false)
|
||||
const [isImportDrawerOpen, setIsImportDrawerOpen] = React.useState(false)
|
||||
const [editingNotebook, setEditingNotebook] = React.useState<KnowledgeGroup | null>(null)
|
||||
const [currentPage, setCurrentPage] = React.useState(1)
|
||||
|
||||
// Flatten tree for display in the grid
|
||||
const flatNotebooks = useMemo(() => flattenGroups(notebooks), [notebooks])
|
||||
const totalPages = Math.ceil(flatNotebooks.length / PAGE_SIZE)
|
||||
const paginatedNotebooks = useMemo(() => {
|
||||
const start = (currentPage - 1) * PAGE_SIZE;
|
||||
return flatNotebooks.slice(start, start + PAGE_SIZE);
|
||||
}, [flatNotebooks, currentPage])
|
||||
|
||||
const fetchNotebooks = async () => {
|
||||
try {
|
||||
const result = await knowledgeGroupService.getGroups()
|
||||
// result can be an array (tree/list) or an object (paginated flat list)
|
||||
if (Array.isArray(result)) {
|
||||
setNotebooks(result)
|
||||
} else if (result && result.items) {
|
||||
setNotebooks(result.items)
|
||||
} else {
|
||||
setNotebooks([])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchNotebooks()
|
||||
}, [authToken, selectedNotebook])
|
||||
|
||||
const handleCreateNotebook = async (data: CreateGroupData) => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
await knowledgeGroupService.createGroup(data)
|
||||
await fetchNotebooks()
|
||||
setIsCreateDrawerOpen(false)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
showError(t('createFailed'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateNotebook = async (id: string, data: UpdateGroupData) => {
|
||||
await knowledgeGroupService.updateGroup(id, data)
|
||||
await fetchNotebooks()
|
||||
}
|
||||
|
||||
const handleDeleteNotebook = async (e: React.MouseEvent, id: string, name: string) => {
|
||||
e.stopPropagation()
|
||||
if (!(await confirm(t('confirmDeleteNotebook').replace('$1', name)))) return
|
||||
try {
|
||||
setIsLoading(true)
|
||||
await knowledgeGroupService.deleteGroup(id)
|
||||
setNotebooks(prev => flattenGroups(prev).filter(n => n.id !== id) as any)
|
||||
await fetchNotebooks()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
showError(t('deleteFailed'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedNotebook) {
|
||||
return (
|
||||
<NotebookDetailView
|
||||
authToken={authToken}
|
||||
notebook={selectedNotebook}
|
||||
onBack={() => setSelectedNotebook(null)}
|
||||
onChatWithContext={onChatWithContext}
|
||||
isAdmin={!!isAdmin}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-transparent overflow-hidden">
|
||||
<div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 leading-tight">{t('navKnowledgeGroups')}</h1>
|
||||
<p className="text-[15px] text-slate-500 mt-1">{t('notebooksDesc')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => setIsImportDrawerOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-white border border-slate-200 text-slate-700 text-sm font-semibold rounded-lg hover:bg-slate-50 hover:border-slate-300 transition-all active:scale-95 shadow-sm"
|
||||
>
|
||||
<FolderInput size={18} className="text-blue-600" />
|
||||
<span>{t('importFolder')}</span>
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => setIsCreateDrawerOpen(true)}
|
||||
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} />
|
||||
<span>{t('newGroup')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-8 flex-1 overflow-y-auto pb-4">
|
||||
{isLoading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
) : flatNotebooks.length === 0 ? (
|
||||
<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">
|
||||
<Library className="w-12 h-12 text-slate-200 mx-auto mb-4" />
|
||||
<h3 className="text-slate-900 font-bold">{t('noKnowledgeGroups')}</h3>
|
||||
<p className="text-slate-500 text-sm mt-1">{t('createGroupDesc')}</p>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => setIsCreateDrawerOpen(true)}
|
||||
className="mt-6 px-5 py-2 bg-blue-600 text-white rounded-lg font-semibold text-sm hover:bg-blue-700 transition-all shadow-sm"
|
||||
>
|
||||
{t('createNotebook')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 max-w-7xl mx-auto">
|
||||
<AnimatePresence>
|
||||
{paginatedNotebooks.map((notebook) => (
|
||||
<motion.div
|
||||
key={notebook.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
onClick={() => setSelectedNotebook(notebook)}
|
||||
className="bg-white rounded-xl border border-slate-200/80 p-5 shadow-sm hover:shadow-md transition-all group relative overflow-hidden flex flex-col h-64 cursor-pointer"
|
||||
>
|
||||
<div className="absolute top-4 right-4 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChatWithContext({ selectedGroups: [notebook.id] })
|
||||
}}
|
||||
className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md"
|
||||
>
|
||||
<MessageSquare size={16} />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingNotebook(notebook)
|
||||
}}
|
||||
className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={(e) => handleDeleteNotebook(e, notebook.id, notebook.name)}
|
||||
className="p-1.5 text-slate-400 hover:text-red-500 rounded-md"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="w-11 h-11 bg-blue-50 rounded-lg text-blue-600 shadow-sm border border-blue-100/30 flex items-center justify-center mb-4 transition-transform group-hover:scale-105">
|
||||
<Book size={20} />
|
||||
</div>
|
||||
<h3 className="font-bold text-slate-900 text-[16px] mb-1 leading-tight group-hover:text-blue-600 transition-colors truncate">
|
||||
{notebook.parentId && <span className="text-slate-300 text-xs mr-1">↳</span>}
|
||||
{notebook.name}
|
||||
</h3>
|
||||
<p className="text-[13px] text-slate-500 leading-relaxed line-clamp-3 italic opacity-85">
|
||||
{notebook.description || t('noDescriptionProvided')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-4 border-t border-slate-50 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 px-2.5 py-1 bg-slate-50 border border-slate-100 rounded-md">
|
||||
<span className="text-[11px] font-bold text-slate-700">{notebook.fileCount || 0}</span>
|
||||
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-tight">{t('files')}</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-slate-300">
|
||||
{notebook.updatedAt ? new Date(notebook.updatedAt).toLocaleDateString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination: always show when there are notebooks */}
|
||||
{flatNotebooks.length > 0 && (
|
||||
<div className="px-8 py-4 border-t border-slate-200/60 bg-white/50 backdrop-blur-md flex items-center justify-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-30 transition-all text-slate-600 flex items-center gap-1 text-sm font-medium"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
{t('previous')}
|
||||
</button>
|
||||
<div className="px-3 py-2 text-sm font-semibold text-slate-700">
|
||||
{t('showingRange', (currentPage - 1) * PAGE_SIZE + 1, Math.min(currentPage * PAGE_SIZE, flatNotebooks.length), flatNotebooks.length)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
className="p-2 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-30 transition-all text-slate-600 flex items-center gap-1 text-sm font-medium"
|
||||
>
|
||||
{t('next')}
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCreateDrawerOpen && (
|
||||
<CreateNotebookDrawer
|
||||
isOpen={isCreateDrawerOpen}
|
||||
onClose={() => setIsCreateDrawerOpen(false)}
|
||||
onCreate={handleCreateNotebook}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingNotebook && (
|
||||
<EditNotebookDrawer
|
||||
isOpen={!!editingNotebook}
|
||||
onClose={() => setEditingNotebook(null)}
|
||||
notebook={editingNotebook}
|
||||
onUpdate={handleUpdateNotebook}
|
||||
/>
|
||||
)}
|
||||
<ImportFolderDrawer
|
||||
isOpen={isImportDrawerOpen}
|
||||
onClose={() => setIsImportDrawerOpen(false)}
|
||||
authToken={authToken}
|
||||
onImportSuccess={fetchNotebooks}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user