Files
OCR/frontend/src/pages/ProcessingPage.tsx
egg 81a0a3ab0f feat: complete i18n support for all frontend pages and components
Add comprehensive bilingual (zh-TW/en-US) support across the entire frontend:

Pages updated:
- AdminDashboardPage: All 63+ strings translated
- TaskHistoryPage: All 80+ strings translated
- TaskDetailPage: All 90+ strings translated
- AuditLogsPage: All audit log UI translated
- ResultsPage/ProcessingPage: Fixed i18n integration
- UploadPage: Step indicators and file list UI translated

Components updated:
- TaskNotFound: Task deletion messages
- FileUpload: Prompts and file size limits
- ProcessingTrackSelector: Processing mode options with analysis info
- Layout: Navigation descriptions
- ProtectedRoute: Loading and access denied messages
- PDFViewer: Page navigation and error messages

Locale files: Added ~200 new translation keys to both zh-TW.json and en-US.json

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 11:56:18 +08:00

491 lines
18 KiB
TypeScript

import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useMutation, useQuery } 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 { apiClientV2 } from '@/services/apiV2'
import { Play, CheckCircle, FileText, AlertCircle, Clock, Activity, Loader2 } from 'lucide-react'
import LayoutModelSelector from '@/components/LayoutModelSelector'
import PreprocessingSettings from '@/components/PreprocessingSettings'
import PreprocessingPreview from '@/components/PreprocessingPreview'
import ProcessingTrackSelector from '@/components/ProcessingTrackSelector'
import TaskNotFound from '@/components/TaskNotFound'
import BatchProcessingPanel from '@/components/BatchProcessingPanel'
import { useTaskValidation } from '@/hooks/useTaskValidation'
import { useTaskStore, useProcessingState, useIsBatchMode } from '@/store/taskStore'
import type { LayoutModel, ProcessingOptions, PreprocessingMode, PreprocessingConfig, ProcessingTrack } from '@/types/apiV2'
/**
* ProcessingPage - Main entry point
* Routes to batch or single task processing based on state
*/
export default function ProcessingPage() {
const isBatchMode = useIsBatchMode()
// Route to appropriate component
if (isBatchMode) {
return <BatchProcessingPanel />
}
return <SingleTaskProcessing />
}
/**
* SingleTaskProcessing - Original single task processing UI
*/
function SingleTaskProcessing() {
const { t } = useTranslation()
const navigate = useNavigate()
const { toast } = useToast()
// Use TaskStore for processing state management
const { startProcessing, stopProcessing, updateTaskStatus } = useTaskStore()
// processingState is available for future use (e.g., displaying global processing status)
const _processingState = useProcessingState()
void _processingState // Suppress unused variable warning
// 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
},
})
// Layout model state (default to 'chinese' for best Chinese document support)
const [layoutModel, setLayoutModel] = useState<LayoutModel>('chinese')
// Preprocessing state
const [preprocessingMode, setPreprocessingMode] = useState<PreprocessingMode>('auto')
const [preprocessingConfig, setPreprocessingConfig] = useState<PreprocessingConfig>({
contrast: 'clahe',
contrast_strength: 1.0,
sharpen: true,
sharpen_strength: 1.0,
binarize: false,
remove_scan_artifacts: true,
})
const [showPreview, setShowPreview] = useState(false)
// Processing track override state (null = use system recommendation)
const [forceTrack, setForceTrack] = useState<ProcessingTrack | null>(null)
// Analyze document to determine if OCR is needed (only for pending tasks)
const { data: documentAnalysis, isLoading: isAnalyzing } = useQuery({
queryKey: ['documentAnalysis', taskId],
queryFn: () => apiClientV2.analyzeDocument(taskId!),
enabled: !!taskId && taskDetail?.status === 'pending',
staleTime: Infinity, // Cache indefinitely since document doesn't change
})
// Determine if preprocessing options should be shown
// Show OCR options when:
// 1. User explicitly selected OCR track
// 2. OR system recommends OCR/hybrid track (and user hasn't overridden to direct)
// 3. OR still analyzing (show by default)
const needsOcrTrack = forceTrack === 'ocr' ||
(forceTrack === null && (
documentAnalysis?.recommended_track === 'ocr' ||
documentAnalysis?.recommended_track === 'hybrid' ||
!documentAnalysis
))
// Start OCR processing
// NOTE: Simple OCR mode - using backend defaults for table/chart/formula recognition
// Only layout_model and preprocessing options are configurable from frontend
const processOCRMutation = useMutation({
mutationFn: () => {
const options: ProcessingOptions = {
use_dual_track: forceTrack === null, // Only use dual-track auto-detection if not forcing
force_track: forceTrack || undefined, // Pass force_track if user selected one
language: 'ch',
layout_model: layoutModel,
preprocessing_mode: preprocessingMode,
preprocessing_config: preprocessingMode === 'manual' ? preprocessingConfig : undefined,
// NOTE: table_detection, ocr_preset, ocr_config removed - using backend defaults
}
// Update TaskStore processing state
startProcessing(forceTrack, options)
return apiClientV2.startTask(taskId!, options)
},
onSuccess: () => {
// Update task status in cache
if (taskId) {
updateTaskStatus(taskId, 'processing', forceTrack || undefined)
}
toast({
title: t('processing.startProcessing'),
description: t('processing.ocrStarted'),
variant: 'success',
})
},
onError: (error: any) => {
// Stop processing state on error
stopProcessing()
toast({
title: t('errors.processingFailed'),
description: error.response?.data?.detail || t('errors.networkError'),
variant: 'destructive',
})
},
})
// Handle task status changes - update store and redirect when completed
useEffect(() => {
if (taskDetail?.status === 'completed') {
// Stop processing state and update cache
stopProcessing()
if (taskId) {
updateTaskStatus(taskId, 'completed', taskDetail.processing_track)
}
setTimeout(() => {
navigate('/tasks')
}, 1000)
} else if (taskDetail?.status === 'failed') {
// Stop processing state on failure
stopProcessing()
if (taskId) {
updateTaskStatus(taskId, 'failed')
}
}
}, [taskDetail?.status, taskDetail?.processing_track, taskId, navigate, stopProcessing, updateTaskStatus])
const handleStartProcessing = () => {
processOCRMutation.mutate()
}
const handleViewResults = () => {
navigate('/tasks')
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'completed':
return <Badge variant="success">{t('processing.completed')}</Badge>
case 'processing':
return <Badge variant="default">{t('processing.processing')}</Badge>
case 'failed':
return <Badge variant="destructive">{t('processing.failed')}</Badge>
default:
return <Badge variant="secondary">{t('processing.pending')}</Badge>
}
}
const getProgressPercentage = (status: string) => {
switch (status) {
case 'completed':
return 100
case 'processing':
return 50
case 'failed':
return 100
default:
return 0
}
}
// 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">{t('processing.loadingTask')}</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 (
<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-muted rounded-full flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-muted-foreground" />
</div>
</div>
<CardTitle className="text-xl">{t('processing.title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground">
{t('processing.noBatchMessage')}
</p>
<Button
onClick={() => navigate('/upload')}
size="lg"
>
{t('processing.goToUpload')}
</Button>
</CardContent>
</Card>
</div>
)
}
const isProcessing = taskDetail?.status === 'processing'
const isCompleted = taskDetail?.status === 'completed'
const isPending = !taskDetail || taskDetail.status === 'pending'
return (
<div className="space-y-6">
{/* Page Header */}
<div className="page-header">
<div className="flex items-center justify-between">
<div>
<h1 className="page-title">{t('processing.title')}</h1>
<p className="text-muted-foreground mt-1">
{t('taskDetail.taskId', { id: taskId })}
{taskDetail?.filename && ` · ${taskDetail.filename}`}
</p>
</div>
<div>
{isCompleted && (
<div className="flex items-center gap-2 text-success">
<CheckCircle className="w-6 h-6" />
<span className="font-semibold">{t('processing.completed')}</span>
</div>
)}
{isProcessing && (
<div className="flex items-center gap-2 text-primary">
<Loader2 className="w-6 h-6 animate-spin" />
<span className="font-semibold">{t('processing.processing')}</span>
</div>
)}
</div>
</div>
</div>
{/* Overall Progress */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Activity className="w-5 h-5 text-primary" />
</div>
<CardTitle>{t('processing.progress')}</CardTitle>
</div>
{taskDetail && getStatusBadge(taskDetail.status)}
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Progress bar */}
<div>
<div className="flex justify-between text-sm mb-3">
<span className="text-muted-foreground font-medium">{t('processing.status')}</span>
<span className="font-bold text-xl text-primary">
{taskDetail ? getProgressPercentage(taskDetail.status) : 0}%
</span>
</div>
<Progress
value={taskDetail ? getProgressPercentage(taskDetail.status) : 0}
max={100}
className="h-4"
/>
</div>
{/* Task Info */}
{taskDetail && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-muted/30 rounded-lg border border-border">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<FileText className="w-5 h-5 text-primary" />
</div>
<div>
<p className="text-xs text-muted-foreground mb-0.5">{t('taskDetail.filename')}</p>
<p className="text-sm font-medium text-foreground truncate">
{taskDetail.filename || t('common.unknownFile')}
</p>
</div>
</div>
</div>
{taskDetail.processing_time_ms && (
<div className="p-4 bg-muted/30 rounded-lg border border-border">
<div className="flex items-center gap-3">
<div className="p-2 bg-success/10 rounded-lg">
<Clock className="w-5 h-5 text-success" />
</div>
<div>
<p className="text-xs text-muted-foreground mb-0.5">{t('taskDetail.processingTime')}</p>
<p className="text-sm font-medium text-foreground">
{(taskDetail.processing_time_ms / 1000).toFixed(2)}s
</p>
</div>
</div>
</div>
)}
</div>
)}
{/* Error message */}
{taskDetail?.error_message && (
<div className="p-4 bg-destructive/10 rounded-lg border border-destructive/20">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-destructive mb-1">{t('processing.failed')}</p>
<p className="text-sm text-destructive/80">{taskDetail.error_message}</p>
</div>
</div>
</div>
)}
{/* Action buttons */}
{(isPending || isCompleted) && (
<div className="flex gap-3 pt-4 border-t border-border">
{isPending && (
<Button
onClick={handleStartProcessing}
disabled={processOCRMutation.isPending}
className="gap-2"
size="lg"
>
{processOCRMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('processing.processing')}
</>
) : (
<>
<Play className="w-4 h-4" />
{t('processing.startProcessing')}
</>
)}
</Button>
)}
{isCompleted && (
<Button
onClick={handleViewResults}
className="gap-2"
size="lg"
>
<CheckCircle className="w-4 h-4" />
{t('results.viewTaskHistory')}
</Button>
)}
</div>
)}
</CardContent>
</Card>
{/* Task Details Card */}
{taskDetail && (
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<FileText className="w-5 h-5 text-primary" />
</div>
<CardTitle>{t('taskDetail.title')}</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">{t('taskDetail.taskStatus')}</span>
{getStatusBadge(taskDetail.status)}
</div>
<div className="flex justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">{t('taskDetail.createdAt')}</span>
<span className="text-sm font-medium">
{new Date(taskDetail.created_at).toLocaleString('zh-TW')}
</span>
</div>
{taskDetail.updated_at && (
<div className="flex justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">{t('taskDetail.lastUpdated')}</span>
<span className="text-sm font-medium">
{new Date(taskDetail.updated_at).toLocaleString('zh-TW')}
</span>
</div>
)}
{taskDetail.completed_at && (
<div className="flex justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground">{t('taskDetail.completedAt')}</span>
<span className="text-sm font-medium">
{new Date(taskDetail.completed_at).toLocaleString('zh-TW')}
</span>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Processing Options (only show when task is pending) */}
{isPending && (
<div className="space-y-6">
{/* Document Analysis Loading */}
{isAnalyzing && (
<div className="flex items-center gap-2 p-4 bg-muted/30 rounded-lg border">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">{t('processing.analyzingDocument')}</span>
</div>
)}
{/* Processing Track Selector - Always show after analysis */}
{!isAnalyzing && (
<ProcessingTrackSelector
value={forceTrack}
onChange={setForceTrack}
documentAnalysis={documentAnalysis}
disabled={processOCRMutation.isPending}
/>
)}
{/* OCR Track Options - Only show when document needs OCR */}
{needsOcrTrack && !isAnalyzing && (
<>
{/* Layout Model Selection */}
<LayoutModelSelector
value={layoutModel}
onChange={setLayoutModel}
disabled={processOCRMutation.isPending}
/>
{/* Preprocessing Settings */}
<PreprocessingSettings
mode={preprocessingMode}
config={preprocessingConfig}
onModeChange={setPreprocessingMode}
onConfigChange={setPreprocessingConfig}
onPreview={() => setShowPreview(!showPreview)}
disabled={processOCRMutation.isPending}
/>
{/* Preprocessing Preview */}
{showPreview && taskId && (
<PreprocessingPreview
taskId={taskId}
mode={preprocessingMode}
config={preprocessingConfig}
/>
)}
</>
)}
</div>
)}
</div>
)
}