Files
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

713 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};