feat: implement hybrid image extraction and memory management
Backend: - Add hybrid image extraction for Direct track (inline image blocks) - Add render_inline_image_regions() fallback when OCR doesn't find images - Add check_document_for_missing_images() for detecting missing images - Add memory management system (MemoryGuard, ModelManager, ServicePool) - Update pdf_generator_service to handle HYBRID processing track - Add ElementType.LOGO for logo extraction Frontend: - Fix PDF viewer re-rendering issues with memoization - Add TaskNotFound component and useTaskValidation hook - Disable StrictMode due to react-pdf incompatibility - Fix task detail and results page loading states 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,17 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Document, Page } from 'react-pdf'
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react'
|
||||
import { Document, Page, pdfjs } from 'react-pdf'
|
||||
import type { PDFDocumentProxy } from 'pdfjs-dist'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Loader2 } from 'lucide-react'
|
||||
import 'react-pdf/dist/Page/AnnotationLayer.css'
|
||||
import 'react-pdf/dist/Page/TextLayer.css'
|
||||
|
||||
// Configure standard font data URL for proper font rendering
|
||||
const pdfOptions = {
|
||||
standardFontDataUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/standard_fonts/`,
|
||||
}
|
||||
|
||||
interface PDFViewerProps {
|
||||
title?: string
|
||||
pdfUrl: string
|
||||
@@ -17,41 +23,56 @@ export default function PDFViewer({ title, pdfUrl, className, httpHeaders }: PDF
|
||||
const [numPages, setNumPages] = useState<number>(0)
|
||||
const [pageNumber, setPageNumber] = useState<number>(1)
|
||||
const [scale, setScale] = useState<number>(1.0)
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [documentLoaded, setDocumentLoaded] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Memoize the file prop to prevent unnecessary reloads
|
||||
// Store PDF document reference
|
||||
const pdfDocRef = useRef<PDFDocumentProxy | null>(null)
|
||||
|
||||
// Memoize file config to prevent unnecessary reloads
|
||||
const fileConfig = useMemo(() => {
|
||||
return httpHeaders ? { url: pdfUrl, httpHeaders } : pdfUrl
|
||||
}, [pdfUrl, httpHeaders])
|
||||
|
||||
const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => {
|
||||
setNumPages(numPages)
|
||||
setLoading(false)
|
||||
// Reset state when URL changes
|
||||
useEffect(() => {
|
||||
setDocumentLoaded(false)
|
||||
setError(null)
|
||||
}
|
||||
setNumPages(0)
|
||||
setPageNumber(1)
|
||||
pdfDocRef.current = null
|
||||
}, [pdfUrl])
|
||||
|
||||
const onDocumentLoadError = (error: Error) => {
|
||||
console.error('Error loading PDF:', error)
|
||||
setError('Failed to load PDF. Please try again later.')
|
||||
setLoading(false)
|
||||
}
|
||||
const onDocumentLoadSuccess = useCallback((pdf: { numPages: number }) => {
|
||||
pdfDocRef.current = pdf as unknown as PDFDocumentProxy
|
||||
setNumPages(pdf.numPages)
|
||||
setPageNumber(1)
|
||||
setDocumentLoaded(true)
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
const goToPreviousPage = () => {
|
||||
const onDocumentLoadError = useCallback((err: Error) => {
|
||||
console.error('Error loading PDF:', err)
|
||||
setError('無法載入 PDF 檔案。請稍後再試。')
|
||||
setDocumentLoaded(false)
|
||||
pdfDocRef.current = null
|
||||
}, [])
|
||||
|
||||
const goToPreviousPage = useCallback(() => {
|
||||
setPageNumber((prev) => Math.max(prev - 1, 1))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const goToNextPage = () => {
|
||||
const goToNextPage = useCallback(() => {
|
||||
setPageNumber((prev) => Math.min(prev + 1, numPages))
|
||||
}
|
||||
}, [numPages])
|
||||
|
||||
const zoomIn = () => {
|
||||
const zoomIn = useCallback(() => {
|
||||
setScale((prev) => Math.min(prev + 0.2, 3.0))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const zoomOut = () => {
|
||||
const zoomOut = useCallback(() => {
|
||||
setScale((prev) => Math.max(prev - 0.2, 0.5))
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
@@ -69,18 +90,18 @@ export default function PDFViewer({ title, pdfUrl, className, httpHeaders }: PDF
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={goToPreviousPage}
|
||||
disabled={pageNumber <= 1 || loading}
|
||||
disabled={pageNumber <= 1 || !documentLoaded}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm whitespace-nowrap">
|
||||
Page {pageNumber} of {numPages || '...'}
|
||||
第 {pageNumber} 頁 / 共 {numPages || '...'} 頁
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={goToNextPage}
|
||||
disabled={pageNumber >= numPages || loading}
|
||||
disabled={pageNumber >= numPages || !documentLoaded}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -92,7 +113,7 @@ export default function PDFViewer({ title, pdfUrl, className, httpHeaders }: PDF
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={zoomOut}
|
||||
disabled={scale <= 0.5 || loading}
|
||||
disabled={scale <= 0.5 || !documentLoaded}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -103,7 +124,7 @@ export default function PDFViewer({ title, pdfUrl, className, httpHeaders }: PDF
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={zoomIn}
|
||||
disabled={scale >= 3.0 || loading}
|
||||
disabled={scale >= 3.0 || !documentLoaded}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -113,39 +134,48 @@ export default function PDFViewer({ title, pdfUrl, className, httpHeaders }: PDF
|
||||
{/* PDF Document */}
|
||||
<div className="border rounded-md bg-muted/10 overflow-auto max-h-[800px]">
|
||||
<div className="flex justify-center p-4">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
{error ? (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<p className="text-destructive font-semibold mb-2">Error</p>
|
||||
<p className="text-destructive font-semibold mb-2">錯誤</p>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && (
|
||||
) : (
|
||||
<Document
|
||||
key={pdfUrl}
|
||||
file={fileConfig}
|
||||
options={pdfOptions}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
onLoadError={onDocumentLoadError}
|
||||
loading={
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-primary mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">載入 PDF 中...</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Page
|
||||
pageNumber={pageNumber}
|
||||
scale={scale}
|
||||
renderTextLayer={true}
|
||||
renderAnnotationLayer={true}
|
||||
className="shadow-lg"
|
||||
/>
|
||||
{documentLoaded && (
|
||||
<Page
|
||||
pageNumber={pageNumber}
|
||||
scale={scale}
|
||||
renderTextLayer={true}
|
||||
renderAnnotationLayer={true}
|
||||
className="shadow-lg"
|
||||
loading={
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
}
|
||||
error={
|
||||
<div className="text-center p-4 text-destructive">
|
||||
無法載入第 {pageNumber} 頁
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Document>
|
||||
)}
|
||||
</div>
|
||||
|
||||
46
frontend/src/components/TaskNotFound.tsx
Normal file
46
frontend/src/components/TaskNotFound.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
|
||||
interface TaskNotFoundProps {
|
||||
taskId: string | null
|
||||
onClearAndUpload: () => void
|
||||
}
|
||||
|
||||
export default function TaskNotFound({ taskId, onClearAndUpload }: TaskNotFoundProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleClick = () => {
|
||||
onClearAndUpload()
|
||||
navigate('/upload')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Card className="max-w-md text-center">
|
||||
<CardHeader>
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="w-16 h-16 bg-destructive/10 rounded-full flex items-center justify-center">
|
||||
<Trash2 className="w-8 h-8 text-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-xl">任務已刪除</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
此任務已被刪除或不存在。請上傳新檔案以建立新任務。
|
||||
</p>
|
||||
{taskId && (
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
任務 ID: {taskId}
|
||||
</p>
|
||||
)}
|
||||
<Button onClick={handleClick} size="lg">
|
||||
前往上傳頁面
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import * as React from 'react'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
|
||||
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
// Simple native select for backwards compatibility
|
||||
export interface NativeSelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
options: Array<{ value: string; label: string }>
|
||||
}
|
||||
|
||||
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
const NativeSelect = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
|
||||
({ className, options, ...props }, ref) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -33,6 +35,168 @@ const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
)
|
||||
}
|
||||
)
|
||||
Select.displayName = 'Select'
|
||||
NativeSelect.displayName = 'NativeSelect'
|
||||
|
||||
export { Select }
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-lg border border-border bg-background px-3 py-2 text-sm',
|
||||
'ring-offset-background placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'[&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-md',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none',
|
||||
'focus:bg-accent focus:text-accent-foreground',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
NativeSelect,
|
||||
}
|
||||
|
||||
64
frontend/src/hooks/useTaskValidation.ts
Normal file
64
frontend/src/hooks/useTaskValidation.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useUploadStore } from '@/store/uploadStore'
|
||||
import { apiClientV2 } from '@/services/apiV2'
|
||||
import type { TaskDetail } from '@/types/apiV2'
|
||||
|
||||
interface UseTaskValidationResult {
|
||||
taskId: string | null
|
||||
taskDetail: TaskDetail | undefined
|
||||
isLoading: boolean
|
||||
isNotFound: boolean
|
||||
clearAndReset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for validating task existence and handling deleted tasks gracefully.
|
||||
* Shows loading state first, then either returns task data or marks as not found.
|
||||
*/
|
||||
export function useTaskValidation(options?: {
|
||||
refetchInterval?: number | false | ((query: any) => number | false)
|
||||
}): UseTaskValidationResult {
|
||||
const { batchId, clearUpload } = useUploadStore()
|
||||
const taskId = batchId ? String(batchId) : null
|
||||
|
||||
const [isNotFound, setIsNotFound] = useState(false)
|
||||
|
||||
const { data: taskDetail, isLoading, error, isFetching } = useQuery({
|
||||
queryKey: ['taskDetail', taskId],
|
||||
queryFn: () => apiClientV2.getTask(taskId!),
|
||||
enabled: !!taskId && !isNotFound,
|
||||
retry: (failureCount, error: any) => {
|
||||
// Don't retry on 404
|
||||
if (error?.response?.status === 404) {
|
||||
return false
|
||||
}
|
||||
return failureCount < 2
|
||||
},
|
||||
refetchInterval: options?.refetchInterval ?? false,
|
||||
// Disable stale time to ensure we check fresh data
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
// Handle 404 error - mark as not found immediately
|
||||
useEffect(() => {
|
||||
if (error && (error as any)?.response?.status === 404) {
|
||||
setIsNotFound(true)
|
||||
}
|
||||
}, [error])
|
||||
|
||||
// Clear state and store
|
||||
const clearAndReset = () => {
|
||||
clearUpload()
|
||||
setIsNotFound(false)
|
||||
}
|
||||
|
||||
return {
|
||||
taskId,
|
||||
taskDetail,
|
||||
// Show loading if we have a taskId and are still fetching (but not if already marked as not found)
|
||||
isLoading: !!taskId && !isNotFound && (isLoading || isFetching) && !taskDetail,
|
||||
isNotFound,
|
||||
clearAndReset,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
@@ -10,8 +9,8 @@ import App from './App.tsx'
|
||||
|
||||
// Configure PDF.js worker for react-pdf
|
||||
import { pdfjs } from 'react-pdf'
|
||||
// Use the worker from react-pdf's bundled pdfjs-dist
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`
|
||||
// Use CDN for the worker (most reliable for Vite)
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`
|
||||
|
||||
// Create React Query client
|
||||
const queryClient = new QueryClient({
|
||||
@@ -24,16 +23,16 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
})
|
||||
|
||||
// Note: StrictMode disabled due to react-pdf incompatibility
|
||||
// StrictMode's double-invocation causes PDF worker race conditions
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ToastProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ToastProvider>
|
||||
</I18nextProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ToastProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ToastProvider>
|
||||
</I18nextProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
import { useUploadStore } from '@/store/uploadStore'
|
||||
import { apiClientV2 } from '@/services/apiV2'
|
||||
import { Play, CheckCircle, FileText, AlertCircle, Clock, Activity, Loader2 } from 'lucide-react'
|
||||
import PPStructureParams from '@/components/PPStructureParams'
|
||||
import TaskNotFound from '@/components/TaskNotFound'
|
||||
import { useTaskValidation } from '@/hooks/useTaskValidation'
|
||||
import type { PPStructureV3Params, ProcessingOptions } from '@/types/apiV2'
|
||||
|
||||
export default function ProcessingPage() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { toast } = useToast()
|
||||
const { batchId } = useUploadStore()
|
||||
|
||||
// In V2, batchId is actually a task_id (string)
|
||||
const taskId = batchId ? String(batchId) : null
|
||||
// Use shared hook for task validation
|
||||
const { taskId, taskDetail, isLoading: isValidating, isNotFound, clearAndReset } = useTaskValidation({
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data
|
||||
if (!data) return 2000
|
||||
if (data.status === 'completed' || data.status === 'failed') {
|
||||
return false
|
||||
}
|
||||
return 2000
|
||||
},
|
||||
})
|
||||
|
||||
// PP-StructureV3 parameters state
|
||||
const [ppStructureParams, setPpStructureParams] = useState<PPStructureV3Params>({})
|
||||
@@ -56,22 +65,6 @@ export default function ProcessingPage() {
|
||||
},
|
||||
})
|
||||
|
||||
// Poll task status
|
||||
const { data: taskDetail } = useQuery({
|
||||
queryKey: ['taskDetail', taskId],
|
||||
queryFn: () => apiClientV2.getTask(taskId!),
|
||||
enabled: !!taskId,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data
|
||||
if (!data) return 2000
|
||||
// Stop polling if completed or failed
|
||||
if (data.status === 'completed' || data.status === 'failed') {
|
||||
return false
|
||||
}
|
||||
return 2000 // Poll every 2 seconds
|
||||
},
|
||||
})
|
||||
|
||||
// Auto-redirect when completed
|
||||
useEffect(() => {
|
||||
if (taskDetail?.status === 'completed') {
|
||||
@@ -115,6 +108,23 @@ export default function ProcessingPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading while validating task
|
||||
if (isValidating) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-primary mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">載入任務資訊...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show message when task was deleted
|
||||
if (isNotFound) {
|
||||
return <TaskNotFound taskId={taskId} onClearAndUpload={clearAndReset} />
|
||||
}
|
||||
|
||||
// Show helpful message when no task is selected
|
||||
if (!taskId) {
|
||||
return (
|
||||
|
||||
@@ -1,29 +1,23 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import PDFViewer from '@/components/PDFViewer'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
import { useUploadStore } from '@/store/uploadStore'
|
||||
import { apiClientV2 } from '@/services/apiV2'
|
||||
import { FileText, Download, AlertCircle, TrendingUp, Clock, Layers, FileJson, Loader2 } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import TaskNotFound from '@/components/TaskNotFound'
|
||||
import { useTaskValidation } from '@/hooks/useTaskValidation'
|
||||
|
||||
export default function ResultsPage() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { toast } = useToast()
|
||||
const { batchId } = useUploadStore()
|
||||
|
||||
// In V2, batchId is actually a task_id (string)
|
||||
const taskId = batchId ? String(batchId) : null
|
||||
|
||||
// Get task details
|
||||
const { data: taskDetail, isLoading } = useQuery({
|
||||
queryKey: ['taskDetail', taskId],
|
||||
queryFn: () => apiClientV2.getTask(taskId!),
|
||||
enabled: !!taskId,
|
||||
// Use shared hook for task validation
|
||||
const { taskId, taskDetail, isLoading, isNotFound, clearAndReset } = useTaskValidation({
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data
|
||||
if (!data) return 2000
|
||||
@@ -34,6 +28,19 @@ export default function ResultsPage() {
|
||||
},
|
||||
})
|
||||
|
||||
// Construct PDF URL for preview - memoize to prevent unnecessary reloads
|
||||
// Must be called unconditionally before any early returns (React hooks rule)
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'
|
||||
const pdfUrl = useMemo(() => {
|
||||
return taskId ? `${API_BASE_URL}/api/v2/tasks/${taskId}/download/pdf` : ''
|
||||
}, [taskId, API_BASE_URL])
|
||||
|
||||
// Get auth token for PDF preview - memoize to prevent new object reference each render
|
||||
const pdfHttpHeaders = useMemo(() => {
|
||||
const authToken = localStorage.getItem('auth_token_v2')
|
||||
return authToken ? { Authorization: `Bearer ${authToken}` } : undefined
|
||||
}, [])
|
||||
|
||||
const handleDownloadPDF = async () => {
|
||||
if (!taskId) return
|
||||
try {
|
||||
@@ -101,6 +108,23 @@ export default function ResultsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading while validating task
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-primary mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">載入任務結果...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show message when task was deleted
|
||||
if (isNotFound) {
|
||||
return <TaskNotFound taskId={taskId} onClearAndUpload={clearAndReset} />
|
||||
}
|
||||
|
||||
// Show helpful message when no task is selected
|
||||
if (!taskId) {
|
||||
return (
|
||||
@@ -127,17 +151,7 @@ export default function ResultsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-primary mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">載入任務結果...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback for no task detail (shouldn't happen with proper validation)
|
||||
if (!taskDetail) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
@@ -157,14 +171,6 @@ export default function ResultsPage() {
|
||||
|
||||
const isCompleted = taskDetail.status === 'completed'
|
||||
|
||||
// Construct PDF URL for preview
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'
|
||||
const pdfUrl = taskId ? `${API_BASE_URL}/api/v2/tasks/${taskId}/download/pdf` : ''
|
||||
|
||||
// Get auth token for PDF preview
|
||||
const authToken = localStorage.getItem('auth_token_v2')
|
||||
const pdfHttpHeaders = authToken ? { Authorization: `Bearer ${authToken}` } : undefined
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
@@ -65,6 +66,19 @@ export default function TaskDetailPage() {
|
||||
retry: false,
|
||||
})
|
||||
|
||||
// Construct PDF URL for preview - memoize to prevent unnecessary reloads
|
||||
// Must be called unconditionally before any early returns (React hooks rule)
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'
|
||||
const pdfUrl = useMemo(() => {
|
||||
return taskId ? `${API_BASE_URL}/api/v2/tasks/${taskId}/download/pdf` : ''
|
||||
}, [taskId, API_BASE_URL])
|
||||
|
||||
// Get auth token for PDF preview - memoize to prevent new object reference each render
|
||||
const pdfHttpHeaders = useMemo(() => {
|
||||
const authToken = localStorage.getItem('auth_token_v2')
|
||||
return authToken ? { Authorization: `Bearer ${authToken}` } : undefined
|
||||
}, [])
|
||||
|
||||
const getTrackBadge = (track?: ProcessingTrack) => {
|
||||
if (!track) return null
|
||||
switch (track) {
|
||||
@@ -218,14 +232,6 @@ export default function TaskDetailPage() {
|
||||
const isProcessing = taskDetail.status === 'processing'
|
||||
const isFailed = taskDetail.status === 'failed'
|
||||
|
||||
// Construct PDF URL for preview
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'
|
||||
const pdfUrl = taskId ? `${API_BASE_URL}/api/v2/tasks/${taskId}/download/pdf` : ''
|
||||
|
||||
// Get auth token for PDF preview
|
||||
const authToken = localStorage.getItem('auth_token_v2')
|
||||
const pdfHttpHeaders = authToken ? { Authorization: `Bearer ${authToken}` } : undefined
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { NativeSelect } from '@/components/ui/select'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
export default function TaskHistoryPage() {
|
||||
@@ -112,6 +112,43 @@ export default function TaskHistoryPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all tasks
|
||||
const handleDeleteAll = async () => {
|
||||
if (tasks.length === 0) {
|
||||
alert('沒有可刪除的任務')
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(`確定要刪除所有 ${total} 個任務嗎?此操作無法復原!`)) return
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
// Delete tasks one by one
|
||||
for (const task of tasks) {
|
||||
await apiClientV2.deleteTask(task.task_id)
|
||||
}
|
||||
// If there are more pages, keep fetching and deleting
|
||||
let hasMoreTasks = hasMore
|
||||
while (hasMoreTasks) {
|
||||
const response = await apiClientV2.listTasks({ page: 1, page_size: 100 })
|
||||
if (response.tasks.length === 0) break
|
||||
for (const task of response.tasks) {
|
||||
await apiClientV2.deleteTask(task.task_id)
|
||||
}
|
||||
hasMoreTasks = response.has_more
|
||||
}
|
||||
fetchTasks()
|
||||
fetchStats()
|
||||
alert('所有任務已刪除')
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || '刪除任務失敗')
|
||||
fetchTasks()
|
||||
fetchStats()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// View task details
|
||||
const handleViewDetails = (taskId: string) => {
|
||||
navigate(`/tasks/${taskId}`)
|
||||
@@ -220,10 +257,16 @@ export default function TaskHistoryPage() {
|
||||
<h1 className="text-3xl font-bold text-gray-900">任務歷史</h1>
|
||||
<p className="text-gray-600 mt-1">查看和管理您的 OCR 任務</p>
|
||||
</div>
|
||||
<Button onClick={() => fetchTasks()} variant="outline">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => fetchTasks()} variant="outline">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={handleDeleteAll} variant="destructive" disabled={loading || tasks.length === 0}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
刪除全部
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
@@ -288,7 +331,7 @@ export default function TaskHistoryPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">狀態</label>
|
||||
<Select
|
||||
<NativeSelect
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value as any)
|
||||
|
||||
Reference in New Issue
Block a user