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:
Developer
2026-04-23 17:19:11 +08:00
commit 0a9588abb7
492 changed files with 112453 additions and 0 deletions
+713
View File
@@ -0,0 +1,713 @@
import React, { useState, useEffect, useRef } from 'react';
import { isFormatSupportedForPreview } from '../constants/fileSupport';
import { PDFStatus } from '../types';
import { pdfPreviewService } from '../services/pdfPreviewService';
import { useToast } from '../contexts/ToastContext';
import { useConfirm } from '../contexts/ConfirmContext';
import { X, FileText, Loader, AlertCircle, Maximize2, Eye, Download, ExternalLink, RefreshCw, Scissors, ChevronLeft, ChevronRight } from 'lucide-react';
import { PDFSelectionTool } from './PDFSelectionTool';
import { CreateNoteFromPDFDialog } from './CreateNoteFromPDFDialog';
import { noteService } from '../services/noteService';
import { useLanguage } from '../contexts/LanguageContext';
import { knowledgeBaseService } from '../services/knowledgeBaseService';
import * as pdfjs from 'pdfjs-dist';
// Set worker path for PDF.js
pdfjs.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.mjs`;
interface PDFPreviewProps {
fileId: string;
fileName: string;
authToken: string;
groupId?: string;
onClose: () => void;
}
export const PDFPreview: React.FC<PDFPreviewProps> = ({ fileId, fileName, authToken, groupId, onClose }) => {
const [status, setStatus] = useState<PDFStatus>({ status: 'pending' });
const [loading, setLoading] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false);
const [pdfUrl, setPdfUrl] = useState<string>('');
const [iframeError, setIframeError] = useState(false);
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pdfBlob, setPdfBlob] = useState<Blob | null>(null);
const [selectionData, setSelectionData] = useState<{ screenshot: Blob; text: string } | null>(null);
const [numPages, setNumPages] = useState<number>(0);
const [pdfDoc, setPdfDoc] = useState<pdfjs.PDFDocumentProxy | null>(null);
const [zoomLevel, setZoomLevel] = useState<number>(1.0); // Add zoom level state
const currentRenderTask = useRef<pdfjs.RenderTask | null>(null); // Save current rendering task
const scrollContainerRef = useRef<HTMLDivElement>(null);
const flipDirection = useRef<'next' | 'prev' | null>(null);
const lastFlipTime = useRef<number>(0);
const { showToast } = useToast();
const { confirm } = useConfirm();
const { t, language } = useLanguage();
const containerRef = React.useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (status.status === 'ready') {
pdfPreviewService.getPDFUrl(fileId)
.then(result => {
setPdfUrl(result.url); // Set pdfUrl for download
// Fetch PDF data and create blob URL
fetch(result.url)
.then(async response => {
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to fetch PDF data');
}
return response.blob();
})
.then(blob => {
setPdfBlob(blob);
// Start fetching and rendering PDF document
loadAndRenderPDF(blob);
})
.catch((err) => {
console.error('PDF fetch error:', err);
setIframeError(true);
setStatus({ status: 'failed', error: err.message });
});
})
.catch((err) => {
console.error('getPDFUrl error:', err);
setIframeError(true);
setStatus({ status: 'failed', error: err.message });
});
}
}, [status.status, fileId]);
useEffect(() => {
if (pdfDoc && currentPage) {
// Re-render on page change or zoom level change
renderCurrentPage(pdfDoc, currentPage);
}
}, [currentPage, pdfDoc, zoomLevel]);
const isSupported = isFormatSupportedForPreview(fileName);
useEffect(() => {
if (isSupported) {
checkPDFStatus();
const interval = setInterval(checkPDFStatus, 3000);
return () => clearInterval(interval);
} else {
setLoading(false);
setStatus({ status: 'failed', error: t('previewNotSupported') });
}
}, [fileId, isSupported]);
const checkPDFStatus = async () => {
try {
const pdfStatus = await pdfPreviewService.getPDFStatus(fileId);
setStatus(pdfStatus);
// Actively trigger conversion if status is pending
if (pdfStatus.status === 'pending') {
setStatus({ status: 'converting' });
try {
// Access PDF URL to trigger conversion
await pdfPreviewService.preloadPDF(fileId);
} catch (error) {
console.log('Preload triggered, conversion should start');
}
}
if (pdfStatus.status === 'ready' || pdfStatus.status === 'failed') {
setLoading(false);
}
} catch (error: any) {
setLoading(false);
const errorMessage = error.message || t('checkPDFStatusFailed');
setStatus({ status: 'failed', error: errorMessage });
showToast(errorMessage, 'error');
}
};
const loadAndRenderPDF = async (blob: Blob) => {
try {
const pdfData = await blob.arrayBuffer();
const pdf = await pdfjs.getDocument({ data: pdfData }).promise;
setPdfDoc(pdf);
setNumPages(pdf.numPages);
if (currentPage > pdf.numPages) {
setCurrentPage(pdf.numPages);
}
renderCurrentPage(pdf, currentPage);
} catch (error) {
console.error('Failed to load PDF:', error);
setIframeError(true);
}
};
const handleZoomIn = () => {
setZoomLevel(prev => Math.min(3.0, prev + 0.1));
};
const handleZoomOut = () => {
setZoomLevel(prev => Math.max(0.5, prev - 0.1));
};
const handleResetZoom = () => {
setZoomLevel(1.0);
};
const renderCurrentPage = async (pdf: pdfjs.PDFDocumentProxy, pageNum: number) => {
if (!canvasRef.current) return;
try {
// Cancel rendering task if one is in progress
if (currentRenderTask.current) {
currentRenderTask.current.cancel();
}
const page = await pdf.getPage(pageNum);
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
if (!context) return;
// Get container dimensions
if (!containerRef.current) return;
const container = containerRef.current;
const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight;
// Handle high DPI displays
const devicePixelRatio = window.devicePixelRatio || 1;
// Calculate scale to fit page in container width while maintaining aspect ratio
const viewport = page.getViewport({ scale: 1 });
const baseScale = (containerWidth - 48) / viewport.width; // Add padding for width
// Apply zoom level to base scale
const scale = baseScale * zoomLevel;
const finalScale = scale * devicePixelRatio;
const scaledViewport = page.getViewport({ scale: finalScale });
const cssViewport = page.getViewport({ scale: scale });
// Set canvas dimensions with device pixel ratio
canvas.width = scaledViewport.width;
canvas.height = scaledViewport.height;
// Set CSS dimensions explicitly for high-DPI
canvas.style.width = `${cssViewport.width}px`;
canvas.style.height = `${cssViewport.height}px`;
// Reset any previous transforms
context.setTransform(1, 0, 0, 1, 0, 0);
// Clear canvas
context.clearRect(0, 0, canvas.width, canvas.height);
// Fill with white background
context.fillStyle = 'white';
context.fillRect(0, 0, canvas.width, canvas.height);
// Set proper transform for high DPI
context.scale(devicePixelRatio, devicePixelRatio);
// Create and save the new render task
const renderContext = {
canvasContext: context,
viewport: page.getViewport({ scale: scale }),
};
// Save the render task to allow for cancellation
currentRenderTask.current = page.render(renderContext);
// Wait for rendering to complete
await currentRenderTask.current.promise;
// Clear the current render task
currentRenderTask.current = null;
// Adjust scroll position after page turn
if (flipDirection.current && scrollContainerRef.current) {
const container = scrollContainerRef.current;
if (flipDirection.current === 'next') {
container.scrollTop = 0;
} else if (flipDirection.current === 'prev') {
container.scrollTop = container.scrollHeight;
}
flipDirection.current = null;
}
} catch (error) {
if (error instanceof Error && error.name !== 'RenderingCancelledException') {
console.error('Failed to render PDF page:', error);
}
// Clear the current render task even if there's an error
currentRenderTask.current = null;
}
};
const handleFullscreen = () => {
setIsFullscreen(!isFullscreen);
};
const handleDownload = () => {
if (pdfUrl) {
// Directly download if pdfUrl already exists
const link = document.createElement('a');
link.href = pdfUrl;
link.download = fileName.replace(/\.[^/.]+$/, '.pdf');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
// Try fetching and downloading if pdfUrl does not exist
pdfPreviewService.getPDFUrl(fileId)
.then(result => {
const link = document.createElement('a');
link.href = result.url;
link.download = fileName.replace(/\.[^/.]+$/, '.pdf');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
})
.catch(error => {
console.error('Failed to download PDF:', error);
showToast('error', t('downloadPDFFailed'));
});
}
};
const handleOpenInNewTab = () => {
if (pdfUrl) {
window.open(pdfUrl, '_blank');
} else {
// Try fetching and opening if pdfUrl does not exist
pdfPreviewService.getPDFUrl(fileId)
.then(result => {
window.open(result.url, '_blank');
})
.catch(error => {
console.error('Failed to open PDF in new tab:', error);
showToast('error', t('openPDFInNewTabFailed'));
});
}
};
const handleRegenerate = async () => {
if (await confirm(t('confirmRegeneratePDF'))) {
setStatus({ status: 'converting' });
setLoading(true);
try {
await pdfPreviewService.preloadPDF(fileId, true);
// Reset state and trigger reload
setPdfUrl('');
setIframeError(false);
setPdfDoc(null);
setPdfBlob(null);
setNumPages(0);
} catch (error) {
showToast('error', t('requestRegenerationFailed'));
setStatus({ status: 'failed', error: t('requestRegenerationFailed') });
}
}
};
const handleIframeError = () => {
setIframeError(true);
};
const handleWheel = (e: React.WheelEvent) => {
if (!scrollContainerRef.current || isSelectionMode) return;
const container = scrollContainerRef.current;
const { scrollTop, scrollHeight, clientHeight } = container;
const now = Date.now();
const throttleMs = 600; // Prevent rapid page turning
// Scroll down for next page
if (e.deltaY > 0 && scrollTop + clientHeight >= scrollHeight - 1) {
if (currentPage < numPages && now - lastFlipTime.current > throttleMs) {
flipDirection.current = 'next';
lastFlipTime.current = now;
setCurrentPage(prev => prev + 1);
}
}
// Scroll up for previous page
else if (e.deltaY < 0 && scrollTop <= 1) {
if (currentPage > 1 && now - lastFlipTime.current > throttleMs) {
flipDirection.current = 'prev';
lastFlipTime.current = now;
setCurrentPage(prev => prev - 1);
}
}
};
const handleSelectionComplete = (screenshot: Blob, text: string) => {
// Set preliminary data and open dialog
setSelectionData({ screenshot, text });
setIsSelectionMode(false);
};
const handleSaveNote = async (title: string, content: string, selectedCategoryId?: string) => {
if (!authToken || !selectionData) return;
try {
await noteService.createFromPDFSelection(
authToken,
fileId,
selectionData.screenshot,
undefined, // groupId is no longer used for notes from PDF
selectedCategoryId,
currentPage
);
showToast('success', t('noteCreatedSuccess'));
setSelectionData(null);
} catch (error) {
console.error('Failed to create note:', error);
showToast('error', t('noteCreatedFailed'));
}
};
const renderContent = () => {
switch (status.status) {
case 'pending':
return (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Loader size={48} className="animate-spin mb-4" />
<div className="text-lg font-medium mb-2">{t('preparingPDFConversion')}</div>
<div className="text-sm">{t('pleaseWait')}</div>
</div>
);
case 'converting':
return (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Loader size={48} className="animate-spin mb-4" />
<div className="text-lg font-medium mb-2">{t('convertingPDF')}</div>
<div className="text-sm">{t('pleaseWait')}</div>
</div>
);
case 'failed':
return (
<div className="flex flex-col items-center justify-center h-full text-red-500">
<AlertCircle size={48} className="mb-4" />
<div className="text-lg font-medium mb-2">{t('pdfConversionFailed')}</div>
<div className="text-sm text-gray-500 text-center max-w-md">
{status.error || t('pdfConversionError')}
</div>
<button
onClick={checkPDFStatus}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
{t('retry')}
</button>
</div>
);
case 'ready':
if (iframeError) {
return (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<AlertCircle size={48} className="mb-4" />
<div className="text-lg font-medium mb-2">{t('pdfLoadFailed')}</div>
<div className="text-sm text-gray-500 mb-4">{t('pdfLoadError')}</div>
<div className="flex gap-2">
<button
onClick={handleDownload}
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
<Download size={16} />
{t('downloadPDF')}
</button>
<button
onClick={handleOpenInNewTab}
className="flex items-center gap-2 px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors"
>
<ExternalLink size={16} />
{t('openInNewWindow')}
</button>
</div>
</div>
);
}
if (!pdfDoc) {
return (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Loader size={48} className="animate-spin mb-4" />
<div className="text-lg font-medium mb-2">{t('loadingPDF')}</div>
</div>
);
}
return (
<div className="relative w-full h-full flex flex-col" ref={containerRef}>
<div
ref={scrollContainerRef}
onWheel={handleWheel}
className="flex-grow overflow-auto pdf-canvas-container bg-gray-100"
>
<div className="flex flex-col items-center py-12 pb-32 min-h-full">
<canvas
ref={canvasRef}
className="bg-white shadow-xl max-w-full"
/>
</div>
</div>
{isSelectionMode && (
<PDFSelectionTool
containerRef={containerRef}
canvasRef={canvasRef}
pdfBlob={pdfBlob}
pageNumber={currentPage}
authToken={authToken}
zoomLevel={zoomLevel}
onSelectionComplete={handleSelectionComplete}
onCancel={() => setIsSelectionMode(false)}
/>
)}
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 flex items-center gap-2 bg-white/90 border border-slate-200 px-3 py-1.5 rounded-full shadow-lg z-40">
<div className="flex items-center border-r border-slate-200 pr-2 mr-2">
<button
onClick={handleZoomOut}
className="p-1 hover:bg-slate-100 rounded text-slate-600 min-w-[28px] flex items-center justify-center"
title={t('zoomOut')}
>
<span className="text-lg"></span>
</button>
<span className="mx-1 text-sm text-slate-600 min-w-[40px] text-center">{Math.round(zoomLevel * 100)}%</span>
<button
onClick={handleZoomIn}
className="p-1 hover:bg-slate-100 rounded text-slate-600 min-w-[28px] flex items-center justify-center"
title={t('zoomIn')}
>
<span className="text-lg">+</span>
</button>
<button
onClick={handleResetZoom}
className="ml-1 p-1 px-2 hover:bg-slate-100 rounded text-slate-600 text-xs"
title={t('resetZoom')}
>
100%
</button>
</div>
<button
onClick={() => {
const newPage = Math.max(1, currentPage - 1);
setCurrentPage(newPage);
}}
className="p-1 hover:bg-slate-100 rounded text-slate-600"
>
<ChevronLeft size={16} />
</button>
<div className="flex items-center gap-1">
<input
type="number"
value={currentPage}
onChange={(e) => {
const val = Math.max(1, Math.min(numPages, parseInt(e.target.value) || 1));
setCurrentPage(val);
}}
className="w-12 text-center text-sm border-none focus:ring-0 bg-transparent font-medium"
/>
<span className="text-sm text-slate-500">/ {numPages}</span>
</div>
<button
onClick={() => {
const newPage = Math.min(numPages, currentPage + 1);
setCurrentPage(newPage);
}}
className="p-1 hover:bg-slate-100 rounded text-slate-600"
>
<ChevronRight size={16} />
</button>
</div>
{selectionData && (
<CreateNoteFromPDFDialog
screenshot={selectionData.screenshot}
extractedText={selectionData.text}
authToken={authToken}
initialCategoryId={undefined} // Notes don't inherit KB group
initialPageNumber={currentPage}
onSave={handleSaveNote}
onCancel={() => setSelectionData(null)}
/>
)}
</div>
);
default:
return null;
}
};
return (
<div className={`fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[999] ${isFullscreen ? 'p-0' : 'p-4'
}`}>
<div className={`bg-white rounded-lg overflow-hidden flex flex-col ${isFullscreen ? 'w-full h-full' : 'w-full max-w-4xl h-5/6'
}`}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b bg-gray-50">
<div className="flex items-center space-x-3">
<FileText size={20} className="text-gray-600" />
<div>
<div className="font-medium text-gray-900">{fileName}</div>
<div className="text-sm text-gray-500">{t('pdfPreview')}</div>
</div>
</div>
<div className="flex items-center space-x-2">
{status.status === 'ready' && !iframeError && (
<>
<div className="flex items-center gap-2 mr-2 border-r pr-2">
<span className="text-sm text-gray-500">{t('selectPageNumber')}</span>
<input
type="number"
min={1}
max={numPages}
value={currentPage}
onChange={(e) => {
const val = Math.max(1, Math.min(numPages, parseInt(e.target.value) || 1));
setCurrentPage(val);
}}
className="w-16 px-2 py-1 border rounded text-sm"
title={t('enterPageNumber')}
/>
</div>
<button
onClick={() => setIsSelectionMode(!isSelectionMode)}
className={`p-2 transition-colors ${isSelectionMode ? 'bg-blue-100 text-blue-600 rounded' : 'text-gray-400 hover:text-blue-600'}`}
title={isSelectionMode ? t('exitSelectionMode') : t('clickToSelectAndNote')}
>
<Scissors size={18} />
</button>
<button
onClick={handleRegenerate}
className="p-2 text-gray-400 hover:text-blue-600 transition-colors"
title={t('regeneratePDF')}
>
<RefreshCw size={18} />
</button>
<button
onClick={handleDownload}
className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
title={t('downloadPDF')}
>
<Download size={18} />
</button>
<button
onClick={handleOpenInNewTab}
className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
title={t('openInNewWindow')}
>
<ExternalLink size={18} />
</button>
<button
onClick={handleFullscreen}
className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
title={isFullscreen ? t('exitFullscreen') : t('fullscreenDisplay')}
>
<Maximize2 size={18} />
</button>
</>
)}
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 transition-colors"
>
<X size={18} />
</button>
</div>
</div>
{/* Content Area */}
<div className="flex-1 h-full">
{renderContent()}
</div>
</div>
</div>
);
};
interface PDFPreviewButtonProps {
fileId: string;
fileName: string;
onPreview: () => void;
}
export const PDFPreviewButton: React.FC<PDFPreviewButtonProps> = ({
fileId,
fileName,
onPreview
}) => {
const [status, setStatus] = useState<PDFStatus>({ status: 'pending' });
const [loading, setLoading] = useState(true);
const { t } = useLanguage();
const isSupported = isFormatSupportedForPreview(fileName);
useEffect(() => {
if (isSupported) {
checkStatus();
} else {
setLoading(false);
}
}, [fileId, isSupported]);
const checkStatus = async () => {
try {
const pdfStatus = await pdfPreviewService.getPDFStatus(fileId);
setStatus(pdfStatus);
} catch (error) {
// Ignore error and use default state
} finally {
setLoading(false);
}
};
const getIcon = () => {
if (!isSupported) {
return <Eye className="w-3 h-3 text-slate-200" />;
}
if (loading || status.status === 'converting') {
return <Loader className="w-3 h-3 animate-spin" />;
}
if (status.status === 'failed') {
return <AlertCircle className="w-3 h-3" />;
}
return <Eye className="w-3 h-3" />;
};
const getTitle = () => {
if (!isSupported) return t('previewNotSupported');
switch (status.status) {
case 'ready': return t('pdfPreviewReady');
case 'converting': return t('convertingInProgress');
case 'failed': return t('conversionFailed');
default: return t('generatePDFPreviewButton');
}
};
return (
<button
onClick={onPreview}
disabled={loading || status.status === 'converting' || !isSupported}
className={`p-1 rounded transition-colors ${!isSupported
? 'text-slate-200 cursor-not-allowed'
: status.status === 'failed'
? 'text-red-400 hover:text-red-500 hover:bg-red-50'
: 'text-slate-400 hover:text-blue-500 hover:bg-blue-50'
} disabled:opacity-50 disabled:cursor-not-allowed`}
title={getTitle()}
>
{getIcon()}
</button>
);
};