feat: add real-time preprocessing preview with side-by-side comparison
- Create PreprocessingPreview component with debounced config updates - Show original vs preprocessed images side-by-side - Display image quality metrics (contrast, sharpness) with quality indicators - Add zoom controls and fullscreen view for detailed inspection - Show auto-detected configuration when in auto mode - Integrate preview toggle with PreprocessingSettings component - Add i18n translations for preview panel UI 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
357
frontend/src/components/PreprocessingPreview.tsx
Normal file
357
frontend/src/components/PreprocessingPreview.tsx
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Loader2, AlertCircle, RefreshCw, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { apiClientV2 } from '@/services/apiV2'
|
||||||
|
import type {
|
||||||
|
PreprocessingMode,
|
||||||
|
PreprocessingConfig,
|
||||||
|
PreprocessingPreviewResponse,
|
||||||
|
ImageQualityMetrics,
|
||||||
|
} from '@/types/apiV2'
|
||||||
|
|
||||||
|
interface PreprocessingPreviewProps {
|
||||||
|
taskId: string
|
||||||
|
mode: PreprocessingMode
|
||||||
|
config: PreprocessingConfig
|
||||||
|
page?: number
|
||||||
|
className?: string
|
||||||
|
onAutoConfigReceived?: (config: PreprocessingConfig) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PreprocessingPreview({
|
||||||
|
taskId,
|
||||||
|
mode,
|
||||||
|
config,
|
||||||
|
page = 1,
|
||||||
|
className,
|
||||||
|
onAutoConfigReceived,
|
||||||
|
}: PreprocessingPreviewProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [previewData, setPreviewData] = useState<PreprocessingPreviewResponse | null>(null)
|
||||||
|
const [originalImageUrl, setOriginalImageUrl] = useState<string | null>(null)
|
||||||
|
const [preprocessedImageUrl, setPreprocessedImageUrl] = useState<string | null>(null)
|
||||||
|
const [zoom, setZoom] = useState(1)
|
||||||
|
const [showFullscreen, setShowFullscreen] = useState<'original' | 'preprocessed' | null>(null)
|
||||||
|
|
||||||
|
// Debounce config changes to avoid too many API calls
|
||||||
|
const [debouncedConfig, setDebouncedConfig] = useState(config)
|
||||||
|
const [debouncedMode, setDebouncedMode] = useState(mode)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedConfig(config)
|
||||||
|
setDebouncedMode(mode)
|
||||||
|
}, 500)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [config, mode])
|
||||||
|
|
||||||
|
// Fetch preview when debounced config changes
|
||||||
|
const fetchPreview = useCallback(async () => {
|
||||||
|
if (!taskId || debouncedMode === 'disabled') {
|
||||||
|
setPreviewData(null)
|
||||||
|
setOriginalImageUrl(null)
|
||||||
|
setPreprocessedImageUrl(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClientV2.previewPreprocessing(taskId, {
|
||||||
|
page,
|
||||||
|
mode: debouncedMode,
|
||||||
|
config: debouncedMode === 'manual' ? debouncedConfig : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
setPreviewData(response)
|
||||||
|
|
||||||
|
// Fetch images as blobs to handle authentication
|
||||||
|
const [originalBlob, preprocessedBlob] = await Promise.all([
|
||||||
|
apiClientV2.getPreviewImage(taskId, 'original', page),
|
||||||
|
apiClientV2.getPreviewImage(taskId, 'preprocessed', page),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Revoke old URLs to prevent memory leaks
|
||||||
|
if (originalImageUrl) URL.revokeObjectURL(originalImageUrl)
|
||||||
|
if (preprocessedImageUrl) URL.revokeObjectURL(preprocessedImageUrl)
|
||||||
|
|
||||||
|
setOriginalImageUrl(URL.createObjectURL(originalBlob))
|
||||||
|
setPreprocessedImageUrl(URL.createObjectURL(preprocessedBlob))
|
||||||
|
|
||||||
|
// Pass auto config to parent if available
|
||||||
|
if (response.auto_config && onAutoConfigReceived) {
|
||||||
|
onAutoConfigReceived(response.auto_config)
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Preview fetch error:', err)
|
||||||
|
setError(err.response?.data?.detail || err.message || 'Failed to load preview')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [taskId, debouncedMode, debouncedConfig, page, onAutoConfigReceived])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPreview()
|
||||||
|
}, [fetchPreview])
|
||||||
|
|
||||||
|
// Cleanup URLs on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (originalImageUrl) URL.revokeObjectURL(originalImageUrl)
|
||||||
|
if (preprocessedImageUrl) URL.revokeObjectURL(preprocessedImageUrl)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleZoomIn = () => setZoom((z) => Math.min(z + 0.25, 3))
|
||||||
|
const handleZoomOut = () => setZoom((z) => Math.max(z - 0.25, 0.5))
|
||||||
|
const handleResetZoom = () => setZoom(1)
|
||||||
|
|
||||||
|
const formatMetric = (value: number) => value.toFixed(2)
|
||||||
|
|
||||||
|
const getMetricQuality = (metric: keyof ImageQualityMetrics, value: number) => {
|
||||||
|
const pp = 'processing.preprocessing.previewPanel'
|
||||||
|
if (metric === 'contrast') {
|
||||||
|
if (value < 30) return { label: t(`${pp}.qualityLow`), color: 'text-red-600' }
|
||||||
|
if (value < 50) return { label: t(`${pp}.qualityMedium`), color: 'text-yellow-600' }
|
||||||
|
return { label: t(`${pp}.qualityHigh`), color: 'text-green-600' }
|
||||||
|
}
|
||||||
|
if (metric === 'edge_strength') {
|
||||||
|
if (value < 20) return { label: t(`${pp}.qualityBlurry`), color: 'text-red-600' }
|
||||||
|
if (value < 40) return { label: t(`${pp}.qualityNormal`), color: 'text-yellow-600' }
|
||||||
|
return { label: t(`${pp}.qualitySharp`), color: 'text-green-600' }
|
||||||
|
}
|
||||||
|
return { label: '', color: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debouncedMode === 'disabled') {
|
||||||
|
return (
|
||||||
|
<div className={cn('border rounded-lg p-6 bg-gray-50 text-center', className)}>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{t('processing.preprocessing.mode.disabledDesc', '前處理已停用,將直接使用原始影像')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('border rounded-lg bg-white overflow-hidden', className)}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b bg-gray-50">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700">
|
||||||
|
{t('processing.preprocessing.preview', '前處理預覽')}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Zoom controls */}
|
||||||
|
<div className="flex items-center gap-1 bg-white rounded-md border px-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
className="p-1 hover:bg-gray-100 rounded"
|
||||||
|
title="縮小"
|
||||||
|
>
|
||||||
|
<ZoomOut className="w-4 h-4 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResetZoom}
|
||||||
|
className="px-2 py-1 text-xs text-gray-600 hover:bg-gray-100 rounded min-w-[3rem]"
|
||||||
|
>
|
||||||
|
{Math.round(zoom * 100)}%
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
className="p-1 hover:bg-gray-100 rounded"
|
||||||
|
title="放大"
|
||||||
|
>
|
||||||
|
<ZoomIn className="w-4 h-4 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Refresh button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={fetchPreview}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={cn(
|
||||||
|
'p-1.5 rounded-md hover:bg-gray-200 transition-colors',
|
||||||
|
isLoading && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
title="重新整理預覽"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('w-4 h-4 text-gray-600', isLoading && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoading && !previewData && (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-blue-500 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-gray-500">{t('processing.preprocessing.previewPanel.loading')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-start gap-3 p-3 bg-red-50 rounded-lg border border-red-200">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-red-700">{t('processing.preprocessing.previewPanel.loadError')}</p>
|
||||||
|
<p className="text-xs text-red-600 mt-1">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preview content */}
|
||||||
|
{previewData && originalImageUrl && preprocessedImageUrl && (
|
||||||
|
<>
|
||||||
|
{/* Image quality metrics */}
|
||||||
|
<div className="px-4 py-3 border-b bg-blue-50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-blue-700">{t('processing.preprocessing.previewPanel.qualityAnalysis')}</span>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-600">{t('processing.preprocessing.previewPanel.contrast')}:</span>
|
||||||
|
<span className={cn('text-xs font-medium', getMetricQuality('contrast', previewData.quality_metrics.contrast).color)}>
|
||||||
|
{formatMetric(previewData.quality_metrics.contrast)}
|
||||||
|
<span className="ml-1">({getMetricQuality('contrast', previewData.quality_metrics.contrast).label})</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-600">{t('processing.preprocessing.previewPanel.sharpness')}:</span>
|
||||||
|
<span className={cn('text-xs font-medium', getMetricQuality('edge_strength', previewData.quality_metrics.edge_strength).color)}>
|
||||||
|
{formatMetric(previewData.quality_metrics.edge_strength)}
|
||||||
|
<span className="ml-1">({getMetricQuality('edge_strength', previewData.quality_metrics.edge_strength).label})</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Side-by-side comparison */}
|
||||||
|
<div className="grid grid-cols-2 divide-x">
|
||||||
|
{/* Original */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="px-3 py-2 border-b bg-gray-100">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-gray-600">{t('processing.preprocessing.previewPanel.original')}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowFullscreen('original')}
|
||||||
|
className="p-1 hover:bg-gray-200 rounded"
|
||||||
|
title={t('processing.preprocessing.previewPanel.fullscreen')}
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-3.5 h-3.5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-auto max-h-[400px] bg-gray-200">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center min-h-[300px] p-2"
|
||||||
|
style={{ transform: `scale(${zoom})`, transformOrigin: 'center center' }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={originalImageUrl}
|
||||||
|
alt="Original"
|
||||||
|
className="max-w-full h-auto shadow-md"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preprocessed */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="px-3 py-2 border-b bg-blue-100">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-blue-700">{t('processing.preprocessing.previewPanel.preprocessed')}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowFullscreen('preprocessed')}
|
||||||
|
className="p-1 hover:bg-blue-200 rounded"
|
||||||
|
title={t('processing.preprocessing.previewPanel.fullscreen')}
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-3.5 h-3.5 text-blue-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-auto max-h-[400px] bg-gray-200">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center min-h-[300px] p-2"
|
||||||
|
style={{ transform: `scale(${zoom})`, transformOrigin: 'center center' }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={preprocessedImageUrl}
|
||||||
|
alt="Preprocessed"
|
||||||
|
className="max-w-full h-auto shadow-md"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto config info */}
|
||||||
|
{debouncedMode === 'auto' && previewData.auto_config && (
|
||||||
|
<div className="px-4 py-3 border-t bg-green-50">
|
||||||
|
<div className="text-xs text-green-700">
|
||||||
|
<span className="font-medium">{t('processing.preprocessing.previewPanel.autoDetectedConfig')}:</span>
|
||||||
|
<span className="ml-2">
|
||||||
|
{t('processing.preprocessing.previewPanel.contrastEnhancement')}: {previewData.auto_config.contrast}
|
||||||
|
{previewData.auto_config.contrast !== 'none' && ` (${previewData.auto_config.contrast_strength.toFixed(1)})`}
|
||||||
|
</span>
|
||||||
|
<span className="mx-2">|</span>
|
||||||
|
<span>
|
||||||
|
{t('processing.preprocessing.sharpen')}: {previewData.auto_config.sharpen ? t('processing.preprocessing.previewPanel.sharpenEnabled') : t('processing.preprocessing.previewPanel.sharpenDisabled')}
|
||||||
|
{previewData.auto_config.sharpen && ` (${previewData.auto_config.sharpen_strength.toFixed(1)})`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading overlay for refresh */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 bg-white/50 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fullscreen modal */}
|
||||||
|
{showFullscreen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
|
||||||
|
onClick={() => setShowFullscreen(null)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute top-4 right-4 text-white hover:text-gray-300 text-xl"
|
||||||
|
onClick={() => setShowFullscreen(null)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
src={showFullscreen === 'original' ? originalImageUrl! : preprocessedImageUrl!}
|
||||||
|
alt={showFullscreen === 'original' ? 'Original (fullscreen)' : 'Preprocessed (fullscreen)'}
|
||||||
|
className="max-w-[90vw] max-h-[90vh] object-contain"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-white/90 px-4 py-2 rounded-full text-sm font-medium">
|
||||||
|
{showFullscreen === 'original'
|
||||||
|
? t('processing.preprocessing.previewPanel.original')
|
||||||
|
: t('processing.preprocessing.previewPanel.preprocessed')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -94,7 +94,29 @@
|
|||||||
"advanced": "進階選項",
|
"advanced": "進階選項",
|
||||||
"binarize": "二值化處理",
|
"binarize": "二值化處理",
|
||||||
"binarizeWarning": "不建議使用",
|
"binarizeWarning": "不建議使用",
|
||||||
"note": "前處理僅影響版面偵測階段,用於改善表格和文字區塊的識別。原始影像仍用於最終的 OCR 文字提取,確保最佳識別品質。"
|
"note": "前處理僅影響版面偵測階段,用於改善表格和文字區塊的識別。原始影像仍用於最終的 OCR 文字提取,確保最佳識別品質。",
|
||||||
|
"previewPanel": {
|
||||||
|
"title": "前處理預覽",
|
||||||
|
"loading": "載入預覽中...",
|
||||||
|
"loadError": "載入預覽失敗",
|
||||||
|
"refresh": "重新整理預覽",
|
||||||
|
"original": "原始影像",
|
||||||
|
"preprocessed": "前處理後",
|
||||||
|
"fullscreen": "全螢幕檢視",
|
||||||
|
"qualityAnalysis": "影像品質分析",
|
||||||
|
"contrast": "對比度",
|
||||||
|
"sharpness": "清晰度",
|
||||||
|
"qualityLow": "低",
|
||||||
|
"qualityMedium": "中",
|
||||||
|
"qualityHigh": "高",
|
||||||
|
"qualityBlurry": "模糊",
|
||||||
|
"qualityNormal": "一般",
|
||||||
|
"qualitySharp": "清晰",
|
||||||
|
"autoDetectedConfig": "自動偵測設定",
|
||||||
|
"contrastEnhancement": "對比度增強",
|
||||||
|
"sharpenEnabled": "啟用",
|
||||||
|
"sharpenDisabled": "停用"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"results": {
|
"results": {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { apiClientV2 } from '@/services/apiV2'
|
|||||||
import { Play, CheckCircle, FileText, AlertCircle, Clock, Activity, Loader2 } from 'lucide-react'
|
import { Play, CheckCircle, FileText, AlertCircle, Clock, Activity, Loader2 } from 'lucide-react'
|
||||||
import LayoutModelSelector from '@/components/LayoutModelSelector'
|
import LayoutModelSelector from '@/components/LayoutModelSelector'
|
||||||
import PreprocessingSettings from '@/components/PreprocessingSettings'
|
import PreprocessingSettings from '@/components/PreprocessingSettings'
|
||||||
|
import PreprocessingPreview from '@/components/PreprocessingPreview'
|
||||||
import TaskNotFound from '@/components/TaskNotFound'
|
import TaskNotFound from '@/components/TaskNotFound'
|
||||||
import { useTaskValidation } from '@/hooks/useTaskValidation'
|
import { useTaskValidation } from '@/hooks/useTaskValidation'
|
||||||
import type { LayoutModel, ProcessingOptions, PreprocessingMode, PreprocessingConfig } from '@/types/apiV2'
|
import type { LayoutModel, ProcessingOptions, PreprocessingMode, PreprocessingConfig } from '@/types/apiV2'
|
||||||
@@ -44,6 +45,7 @@ export default function ProcessingPage() {
|
|||||||
sharpen_strength: 1.0,
|
sharpen_strength: 1.0,
|
||||||
binarize: false,
|
binarize: false,
|
||||||
})
|
})
|
||||||
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
|
|
||||||
// Start OCR processing
|
// Start OCR processing
|
||||||
const processOCRMutation = useMutation({
|
const processOCRMutation = useMutation({
|
||||||
@@ -371,8 +373,24 @@ export default function ProcessingPage() {
|
|||||||
config={preprocessingConfig}
|
config={preprocessingConfig}
|
||||||
onModeChange={setPreprocessingMode}
|
onModeChange={setPreprocessingMode}
|
||||||
onConfigChange={setPreprocessingConfig}
|
onConfigChange={setPreprocessingConfig}
|
||||||
|
onPreview={() => setShowPreview(!showPreview)}
|
||||||
disabled={processOCRMutation.isPending}
|
disabled={processOCRMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Preprocessing Preview */}
|
||||||
|
{showPreview && taskId && (
|
||||||
|
<PreprocessingPreview
|
||||||
|
taskId={taskId}
|
||||||
|
mode={preprocessingMode}
|
||||||
|
config={preprocessingConfig}
|
||||||
|
onAutoConfigReceived={(autoConfig) => {
|
||||||
|
// Only update if user hasn't switched to manual mode
|
||||||
|
if (preprocessingMode === 'auto') {
|
||||||
|
setPreprocessingConfig(autoConfig)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import type {
|
|||||||
ProcessingOptions,
|
ProcessingOptions,
|
||||||
ProcessingMetadata,
|
ProcessingMetadata,
|
||||||
DocumentAnalysisResponse,
|
DocumentAnalysisResponse,
|
||||||
|
PreprocessingPreviewRequest,
|
||||||
|
PreprocessingPreviewResponse,
|
||||||
} from '@/types/apiV2'
|
} from '@/types/apiV2'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -517,6 +519,41 @@ class ApiClientV2 {
|
|||||||
window.URL.revokeObjectURL(link.href)
|
window.URL.revokeObjectURL(link.href)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Preprocessing Preview APIs ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview preprocessing effect on a page
|
||||||
|
*/
|
||||||
|
async previewPreprocessing(
|
||||||
|
taskId: string,
|
||||||
|
request: PreprocessingPreviewRequest
|
||||||
|
): Promise<PreprocessingPreviewResponse> {
|
||||||
|
const response = await this.client.post<PreprocessingPreviewResponse>(
|
||||||
|
`/tasks/${taskId}/preview/preprocessing`,
|
||||||
|
request
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get preprocessing preview image URL (with auth token)
|
||||||
|
*/
|
||||||
|
getPreviewImageUrl(taskId: string, type: 'original' | 'preprocessed', page: number): string {
|
||||||
|
const baseUrl = `${API_BASE_URL}/api/${API_VERSION}/tasks/${taskId}/preview/image`
|
||||||
|
return `${baseUrl}?type=${type}&page=${page}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get preview image as blob (with authentication)
|
||||||
|
*/
|
||||||
|
async getPreviewImage(taskId: string, type: 'original' | 'preprocessed', page: number): Promise<Blob> {
|
||||||
|
const response = await this.client.get(`/tasks/${taskId}/preview/image`, {
|
||||||
|
params: { type, page },
|
||||||
|
responseType: 'blob',
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Admin APIs ====================
|
// ==================== Admin APIs ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user