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,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