diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 767643f..e343e88 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -91,10 +91,7 @@ async def get_top_users( """ try: top_users = admin_service.get_top_users(db, metric=metric, limit=limit) - return { - "metric": metric, - "users": top_users - } + return top_users except Exception as e: logger.exception("Failed to get top users") diff --git a/backend/app/routers/tasks.py b/backend/app/routers/tasks.py index 3a50347..1b5b5cf 100644 --- a/backend/app/routers/tasks.py +++ b/backend/app/routers/tasks.py @@ -87,7 +87,7 @@ def process_task_ocr(task_id: str, task_db_id: int, file_path: str, filename: st # Save Markdown result markdown_path = result_dir / f"{Path(filename).stem}_result.md" - markdown_content = ocr_result.get('markdown', '') + markdown_content = ocr_result.get('markdown_content', '') with open(markdown_path, 'w', encoding='utf-8') as f: f.write(markdown_content) diff --git a/backend/app/services/admin_service.py b/backend/app/services/admin_service.py index 213db6f..933114c 100644 --- a/backend/app/services/admin_service.py +++ b/backend/app/services/admin_service.py @@ -86,22 +86,16 @@ class AdminService: ).count() return { - "users": { - "total": total_users, - "active": active_users, - "active_30d": active_users_30d - }, - "tasks": { - "total": total_tasks, - "by_status": tasks_by_status, - "recent_7d": recent_tasks - }, - "sessions": { - "active": active_sessions - }, - "activity": { - "logins_7d": recent_logins, - "tasks_7d": recent_tasks + "total_users": total_users, + "active_users": active_users, + "total_tasks": total_tasks, + "total_sessions": active_sessions, + "recent_activity_count": recent_tasks, + "task_stats": { + "pending": tasks_by_status.get("pending", 0), + "processing": tasks_by_status.get("processing", 0), + "completed": tasks_by_status.get("completed", 0), + "failed": tasks_by_status.get("failed", 0) } } @@ -142,6 +136,14 @@ class AdminService: ) ).count() + # Count failed tasks + failed_tasks = db.query(Task).filter( + and_( + Task.user_id == user.id, + Task.status == TaskStatus.FAILED + ) + ).count() + # Count active sessions active_sessions = db.query(UserSession).filter( and_( @@ -152,8 +154,9 @@ class AdminService: user_list.append({ **user.to_dict(), - "total_tasks": task_count, + "task_count": task_count, "completed_tasks": completed_tasks, + "failed_tasks": failed_tasks, "active_sessions": active_sessions, "is_admin": self.is_admin(user.email) }) @@ -177,34 +180,34 @@ class AdminService: Returns: List of top users with counts """ - if metric == "completed_tasks": - # Top users by completed tasks - results = db.query( - User, - func.count(Task.id).label("task_count") - ).join(Task).filter( - Task.status == TaskStatus.COMPLETED - ).group_by(User.id).order_by( - func.count(Task.id).desc() - ).limit(limit).all() - else: - # Top users by total tasks (default) - results = db.query( - User, - func.count(Task.id).label("task_count") - ).join(Task).group_by(User.id).order_by( - func.count(Task.id).desc() - ).limit(limit).all() + # Get top users by total tasks + results = db.query( + User, + func.count(Task.id).label("task_count") + ).join(Task).group_by(User.id).order_by( + func.count(Task.id).desc() + ).limit(limit).all() - return [ - { + # Build result list with both task_count and completed_tasks + top_users = [] + for user, task_count in results: + # Count completed tasks for this user + completed_tasks = db.query(Task).filter( + and_( + Task.user_id == user.id, + Task.status == TaskStatus.COMPLETED + ) + ).count() + + top_users.append({ "user_id": user.id, "email": user.email, "display_name": user.display_name, - "count": count - } - for user, count in results - ] + "task_count": task_count, + "completed_tasks": completed_tasks + }) + + return top_users # Singleton instance diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1fde0bc..ae987ba 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import ResultsPage from '@/pages/ResultsPage' import ExportPage from '@/pages/ExportPage' import SettingsPage from '@/pages/SettingsPage' import TaskHistoryPage from '@/pages/TaskHistoryPage' +import TaskDetailPage from '@/pages/TaskDetailPage' import AdminDashboardPage from '@/pages/AdminDashboardPage' import AuditLogsPage from '@/pages/AuditLogsPage' import Layout from '@/components/Layout' @@ -32,6 +33,7 @@ function App() { } /> } /> } /> + } /> } /> {/* Admin routes - require admin privileges */} diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..4cf7182 --- /dev/null +++ b/frontend/src/components/ui/checkbox.tsx @@ -0,0 +1,46 @@ +import * as React from 'react' +import { Check } from 'lucide-react' +import { cn } from '@/lib/utils' + +export interface CheckboxProps extends Omit, 'type'> { + checked?: boolean + onCheckedChange?: (checked: boolean) => void +} + +const Checkbox = React.forwardRef( + ({ className, checked, onCheckedChange, onChange, ...props }, ref) => { + const handleChange = (e: React.ChangeEvent) => { + onChange?.(e) + onCheckedChange?.(e.target.checked) + } + + return ( +
+ +
+ {checked && } +
+
+ ) + } +) +Checkbox.displayName = 'Checkbox' + +export { Checkbox } diff --git a/frontend/src/pages/ExportPage.tsx b/frontend/src/pages/ExportPage.tsx index c86d881..6f470e7 100644 --- a/frontend/src/pages/ExportPage.tsx +++ b/frontend/src/pages/ExportPage.tsx @@ -1,217 +1,180 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' -import { useMutation, useQuery } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' import { useToast } from '@/components/ui/toast' -import { useUploadStore } from '@/store/uploadStore' -import { apiClient } from '@/services/api' -import type { ExportRequest, ExportOptions } from '@/types/api' +import { apiClientV2 } from '@/services/apiV2' import { Download, FileText, FileJson, - FileSpreadsheet, - FileCode, FileType, AlertCircle, Settings, CheckCircle2, - ArrowLeft + ArrowLeft, + Loader2 } from 'lucide-react' -type ExportFormat = 'txt' | 'json' | 'excel' | 'markdown' | 'pdf' +type ExportFormat = 'json' | 'markdown' | 'pdf' export default function ExportPage() { const { t } = useTranslation() const navigate = useNavigate() const { toast } = useToast() - const { batchId } = useUploadStore() - const [format, setFormat] = useState('txt') - const [selectedRuleId, setSelectedRuleId] = useState() - const [options, setOptions] = useState({ - confidence_threshold: 0.5, - include_metadata: true, - filename_pattern: '{filename}_ocr', - css_template: 'default', + const [selectedTasks, setSelectedTasks] = useState>(new Set()) + const [exportFormat, setExportFormat] = useState('markdown') + const [isExporting, setIsExporting] = useState(false) + + // Fetch completed tasks + const { data: tasksData, isLoading } = useQuery({ + queryKey: ['tasks', 'completed'], + queryFn: () => apiClientV2.listTasks({ + status: 'completed', + page: 1, + page_size: 100, + order_by: 'completed_at', + order_desc: true + }), }) - // Fetch export rules - const { data: exportRules } = useQuery({ - queryKey: ['exportRules'], - queryFn: () => apiClient.getExportRules(), - enabled: true, - }) + const completedTasks = tasksData?.tasks || [] - // Fetch CSS templates - const { data: cssTemplates } = useQuery({ - queryKey: ['cssTemplates'], - queryFn: () => apiClient.getCSSTemplates(), - enabled: format === 'pdf', - }) + // Select/Deselect all + const handleSelectAll = () => { + if (selectedTasks.size === completedTasks.length) { + setSelectedTasks(new Set()) + } else { + setSelectedTasks(new Set(completedTasks.map(t => t.task_id))) + } + } - // Export mutation - const exportMutation = useMutation({ - mutationFn: async (data: ExportRequest) => { - const blob = await apiClient.exportResults(data) - return { blob, format: data.format } - }, - onSuccess: ({ blob, format: exportFormat }) => { - // Create download link - const url = window.URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - - // Determine file extension - const extensions: Record = { - txt: 'txt', - json: 'json', - excel: 'xlsx', - markdown: 'md', - pdf: 'pdf', - } - - a.download = `batch_${batchId}_export.${extensions[exportFormat]}` - document.body.appendChild(a) - a.click() - window.URL.revokeObjectURL(url) - document.body.removeChild(a) + // Toggle task selection + const handleToggleTask = (taskId: string) => { + const newSelected = new Set(selectedTasks) + if (newSelected.has(taskId)) { + newSelected.delete(taskId) + } else { + newSelected.add(taskId) + } + setSelectedTasks(newSelected) + } + // Export selected tasks + const handleExport = async () => { + if (selectedTasks.size === 0) { toast({ - title: t('export.exportSuccess'), - description: `已成功匯出為 ${exportFormat.toUpperCase()} 格式`, - variant: 'success', - }) - }, - onError: (error: any) => { - toast({ - title: t('export.exportError'), - description: error.response?.data?.detail || t('errors.networkError'), - variant: 'destructive', - }) - }, - }) - - const handleExport = () => { - if (!batchId) { - toast({ - title: t('errors.validationError'), - description: '請先上傳並處理檔案', + title: '請選擇任務', + description: '請至少選擇一個任務進行匯出', variant: 'destructive', }) return } - const exportRequest: ExportRequest = { - batch_id: batchId, - format, - rule_id: selectedRuleId, - options, - } + setIsExporting(true) + let successCount = 0 + let errorCount = 0 - exportMutation.mutate(exportRequest) - } - - const handleFormatChange = (newFormat: ExportFormat) => { - setFormat(newFormat) - // Reset CSS template if switching away from PDF - if (newFormat !== 'pdf') { - setOptions((prev) => ({ ...prev, css_template: undefined })) - } else { - setOptions((prev) => ({ ...prev, css_template: 'default' })) - } - } - - const handleRuleChange = (ruleId: number | undefined) => { - setSelectedRuleId(ruleId) - if (ruleId && exportRules) { - const rule = exportRules.find((r) => r.id === ruleId) - if (rule && rule.config_json) { - // Apply rule configuration - setOptions((prev) => ({ - ...prev, - ...rule.config_json, - css_template: rule.css_template || prev.css_template, - })) + try { + for (const taskId of selectedTasks) { + try { + if (exportFormat === 'json') { + await apiClientV2.downloadJSON(taskId) + } else if (exportFormat === 'markdown') { + await apiClientV2.downloadMarkdown(taskId) + } else if (exportFormat === 'pdf') { + await apiClientV2.downloadPDF(taskId) + } + successCount++ + } catch (error) { + console.error(`Export failed for task ${taskId}:`, error) + errorCount++ + } } + + if (successCount > 0) { + toast({ + title: t('export.exportSuccess'), + description: `成功匯出 ${successCount} 個檔案${errorCount > 0 ? `,${errorCount} 個失敗` : ''}`, + variant: 'success', + }) + } + + if (errorCount > 0 && successCount === 0) { + toast({ + title: t('export.exportError'), + description: `所有匯出失敗 (${errorCount} 個檔案)`, + variant: 'destructive', + }) + } + } finally { + setIsExporting(false) } } const formatIcons = { - txt: FileText, json: FileJson, - excel: FileSpreadsheet, - markdown: FileCode, + markdown: FileText, pdf: FileType, } - // Show helpful message when no batch is selected - if (!batchId) { - return ( -
- - -
-
- -
-
- {t('export.title')} -
- -

- {t('export.noBatchMessage', { defaultValue: '尚未選擇任何批次。請先上傳並完成處理檔案。' })} -

- -
-
-
- ) + const formatLabels = { + json: 'JSON', + markdown: 'Markdown', + pdf: 'PDF', } return (
{/* Page Header */}
-

{t('export.title')}

-

- 批次 ID: {batchId} -

+
+
+ +
+

{t('export.title')}

+

批次匯出 OCR 處理結果

+
+
+
- {/* Left Column - Configuration */} + {/* Left Column - Task Selection */}
{/* Format Selection */} - {t('export.format')} + 選擇匯出格式 選擇要匯出的檔案格式 -
- {(['txt', 'json', 'excel', 'markdown', 'pdf'] as ExportFormat[]).map((fmt) => { +
+ {(['json', 'markdown', 'pdf'] as ExportFormat[]).map((fmt) => { const Icon = formatIcons[fmt] return ( ) @@ -220,150 +183,77 @@ export default function ExportPage() { - {/* Export Rules */} - {exportRules && exportRules.length > 0 && ( - - - - - {t('export.rules.title')} - - 選擇預設的匯出規則 - - - - - - )} - - {/* Export Options */} + {/* Task List */} - - - {t('export.options.title')} - - 自訂匯出參數 - - - {/* Confidence Threshold */} -
-
- - - {((options.confidence_threshold ?? 0.5) * 100).toFixed(0)}% - -
- - setOptions((prev) => ({ - ...prev, - confidence_threshold: Number(e.target.value), - })) - } - className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary" - /> -
- 0% - 50% - 100% -
-
- - {/* Include Metadata */} -
- - setOptions((prev) => ({ - ...prev, - include_metadata: e.target.checked, - })) - } - className="w-5 h-5 border border-border rounded accent-primary" - /> - -
- - {/* Filename Pattern */} -
- - - setOptions((prev) => ({ - ...prev, - filename_pattern: e.target.value, - })) - } - className="w-full px-4 py-3 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors font-mono text-sm" - placeholder="{filename}_ocr" - /> -

- 可用變數: {'{filename}'},{' '} - {'{batch_id}'},{' '} - {'{date}'} -

-
- - {/* CSS Template (PDF only) */} - {format === 'pdf' && cssTemplates && cssTemplates.length > 0 && ( +
- - + + + 選擇任務 + + + 選擇要匯出的已完成任務 ({selectedTasks.size}/{completedTasks.length} 已選) + +
+ {completedTasks.length > 0 && ( + + )} +
+ + + {isLoading ? ( +
+ +
+ ) : completedTasks.length === 0 ? ( +
+ +

沒有已完成的任務

+ +
+ ) : ( +
+ {completedTasks.map((task) => ( +
handleToggleTask(task.task_id)} + > + handleToggleTask(task.task_id)} + onClick={(e) => e.stopPropagation()} + /> +
+

{task.filename || '未知檔案'}

+

+ {new Date(task.completed_at!).toLocaleString('zh-TW')} + {task.processing_time_ms && ` · ${(task.processing_time_ms / 1000).toFixed(2)}s`} +

+
+ +
+ ))}
)}
- {/* Right Column - Preview */} + {/* Right Column - Export Summary */}
- 匯出預覽 + 匯出摘要 當前設定概覽 @@ -372,71 +262,55 @@ export default function ExportPage() {
格式
{(() => { - const Icon = formatIcons[format] + const Icon = formatIcons[exportFormat] return })()} - {format.toUpperCase()} + {formatLabels[exportFormat]}
- {selectedRuleId && exportRules && ( +
+
已選任務
+
{selectedTasks.size}
+
+ + {selectedTasks.size > 0 && (
-
匯出規則
+
預計下載
- {exportRules.find((r) => r.id === selectedRuleId)?.rule_name || '未選擇'} + {selectedTasks.size} 個 {formatLabels[exportFormat]} 檔案
)} - -
-
準確率門檻
-
- {((options.confidence_threshold ?? 0.5) * 100).toFixed(0)}% -
-
- -
-
包含元數據
-
- {options.include_metadata ? '是' : '否'} -
-
- -
-
檔名模式
-
- {options.filename_pattern || '{filename}_ocr'} -
-
diff --git a/frontend/src/pages/ResultsPage.tsx b/frontend/src/pages/ResultsPage.tsx index af52607..2722543 100644 --- a/frontend/src/pages/ResultsPage.tsx +++ b/frontend/src/pages/ResultsPage.tsx @@ -1,53 +1,43 @@ -import { useState } 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 ResultsTable from '@/components/ResultsTable' import MarkdownPreview from '@/components/MarkdownPreview' import { useToast } from '@/components/ui/toast' import { useUploadStore } from '@/store/uploadStore' -import { apiClient } from '@/services/api' -import { FileText, Download, Languages, AlertCircle, TrendingUp, Clock, Layers } from 'lucide-react' +import { apiClientV2 } from '@/services/apiV2' +import { FileText, Download, AlertCircle, TrendingUp, Clock, Layers, FileJson, Loader2 } from 'lucide-react' +import { Badge } from '@/components/ui/badge' export default function ResultsPage() { const { t } = useTranslation() const navigate = useNavigate() const { toast } = useToast() const { batchId } = useUploadStore() - const [selectedFileId, setSelectedFileId] = useState(null) - // Get batch status to show results - const { data: batchStatus } = useQuery({ - queryKey: ['batchStatus', batchId], - queryFn: () => apiClient.getBatchStatus(batchId!), - enabled: !!batchId, + // 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, + refetchInterval: (query) => { + const data = query.state.data + if (!data) return 2000 + if (data.status === 'completed' || data.status === 'failed') { + return false + } + return 2000 + }, }) - // Get OCR result for selected file - const { data: ocrResult, isLoading: isLoadingResult } = useQuery({ - queryKey: ['ocrResult', selectedFileId], - queryFn: () => apiClient.getOCRResult(selectedFileId!), - enabled: !!selectedFileId, - }) - - const handleViewResult = (fileId: number) => { - setSelectedFileId(fileId) - } - - const handleDownloadPDF = async (fileId: number) => { + const handleDownloadPDF = async () => { + if (!taskId) return try { - const blob = await apiClient.exportPDF(fileId) - const url = window.URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `ocr-result-${fileId}.pdf` - document.body.appendChild(a) - a.click() - window.URL.revokeObjectURL(url) - document.body.removeChild(a) - + await apiClientV2.downloadPDF(taskId) toast({ title: t('export.exportSuccess'), description: 'PDF 已下載', @@ -62,12 +52,57 @@ export default function ResultsPage() { } } - const handleExport = () => { - navigate('/export') + const handleDownloadMarkdown = async () => { + if (!taskId) return + try { + await apiClientV2.downloadMarkdown(taskId) + toast({ + title: t('export.exportSuccess'), + description: 'Markdown 已下載', + variant: 'success', + }) + } catch (error: any) { + toast({ + title: t('export.exportError'), + description: error.response?.data?.detail || t('errors.networkError'), + variant: 'destructive', + }) + } } - // Show helpful message when no batch is selected - if (!batchId) { + const handleDownloadJSON = async () => { + if (!taskId) return + try { + await apiClientV2.downloadJSON(taskId) + toast({ + title: t('export.exportSuccess'), + description: 'JSON 已下載', + variant: 'success', + }) + } catch (error: any) { + toast({ + title: t('export.exportError'), + description: error.response?.data?.detail || t('errors.networkError'), + variant: 'destructive', + }) + } + } + + const getStatusBadge = (status: string) => { + switch (status) { + case 'completed': + return 已完成 + case 'processing': + return 處理中 + case 'failed': + return 失敗 + default: + return 待處理 + } + } + + // Show helpful message when no task is selected + if (!taskId) { return (
@@ -81,7 +116,7 @@ export default function ResultsPage() {

- {t('results.noBatchMessage', { defaultValue: '尚未選擇任何批次。請先上傳並處理檔案。' })} + {t('results.noBatchMessage', { defaultValue: '尚未選擇任何任務。請先上傳並處理檔案。' })}

+
+
+
+ ) + } + + const isCompleted = taskDetail.status === 'completed' return (
@@ -102,115 +165,124 @@ export default function ResultsPage() {

{t('results.title')}

- 批次 ID: {batchId} · 已完成 {completedFiles.length} 個檔案 + 任務 ID: {taskId} + {taskDetail.filename && ` · ${taskDetail.filename}`}

-
- - +
+ {getStatusBadge(taskDetail.status)} + {isCompleted && ( + <> + + + + + )}
-
- {/* Results Table - Takes 2 columns */} -
- -
- - {/* Preview Panel - Takes 3 columns */} -
- {selectedFileId && ocrResult ? ( -
- {/* Preview Card */} - - - {/* Stats Grid */} -
- - -
-
- -
-
-

準確率

-

- {((ocrResult.confidence || 0) * 100).toFixed(1)}% -

-
-
-
-
- - - -
-
- -
-
-

處理時間

-

- {(ocrResult.processing_time || 0).toFixed(2)}s -

-
-
-
-
- - - -
-
- -
-
-

文字區塊

-

- {ocrResult.json_data?.total_text_regions || 0} -

-
-
-
-
-
-
- ) : ( - - -
- + {/* Stats Grid */} + {isCompleted && ( +
+ + +
+
+
-

- {isLoadingResult ? t('common.loading') : '選擇左側檔案以查看詳細結果'} -

- - - )} +
+

處理時間

+

+ {taskDetail.processing_time_ms ? (taskDetail.processing_time_ms / 1000).toFixed(2) : '0'}s +

+
+
+
+
+ + + +
+
+ +
+
+

處理狀態

+

成功

+
+
+
+
+ + + +
+
+ +
+
+

任務類型

+

OCR

+
+
+
+
-
+ )} + + {/* Results Preview */} + {isCompleted ? ( + + + 處理結果預覽 + + + + + + ) : taskDetail.status === 'processing' ? ( + + + +

正在處理中...

+

請稍候,OCR 處理需要一些時間

+
+
+ ) : taskDetail.status === 'failed' ? ( + + + +

處理失敗

+ {taskDetail.error_message && ( +

{taskDetail.error_message}

+ )} +
+
+ ) : ( + + + +

等待處理

+

請前往處理頁面啟動 OCR 處理

+ +
+
+ )}
) } diff --git a/frontend/src/pages/TaskDetailPage.tsx b/frontend/src/pages/TaskDetailPage.tsx new file mode 100644 index 0000000..db7d6c3 --- /dev/null +++ b/frontend/src/pages/TaskDetailPage.tsx @@ -0,0 +1,346 @@ +import { useParams, 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 MarkdownPreview from '@/components/MarkdownPreview' +import { useToast } from '@/components/ui/toast' +import { apiClientV2 } from '@/services/apiV2' +import { + FileText, + Download, + AlertCircle, + TrendingUp, + Clock, + Layers, + FileJson, + Loader2, + ArrowLeft, + RefreshCw +} from 'lucide-react' +import { Badge } from '@/components/ui/badge' + +export default function TaskDetailPage() { + const { taskId } = useParams<{ taskId: string }>() + const { t } = useTranslation() + const navigate = useNavigate() + const { toast } = useToast() + + // Get task details + const { data: taskDetail, isLoading, refetch } = useQuery({ + queryKey: ['taskDetail', taskId], + queryFn: () => apiClientV2.getTask(taskId!), + enabled: !!taskId, + refetchInterval: (query) => { + const data = query.state.data + if (!data) return 2000 + if (data.status === 'completed' || data.status === 'failed') { + return false + } + return 2000 // Poll every 2 seconds for processing tasks + }, + }) + + const handleDownloadPDF = async () => { + if (!taskId) return + try { + await apiClientV2.downloadPDF(taskId) + toast({ + title: t('export.exportSuccess'), + description: 'PDF 已下載', + variant: 'success', + }) + } catch (error: any) { + toast({ + title: t('export.exportError'), + description: error.response?.data?.detail || t('errors.networkError'), + variant: 'destructive', + }) + } + } + + const handleDownloadMarkdown = async () => { + if (!taskId) return + try { + await apiClientV2.downloadMarkdown(taskId) + toast({ + title: t('export.exportSuccess'), + description: 'Markdown 已下載', + variant: 'success', + }) + } catch (error: any) { + toast({ + title: t('export.exportError'), + description: error.response?.data?.detail || t('errors.networkError'), + variant: 'destructive', + }) + } + } + + const handleDownloadJSON = async () => { + if (!taskId) return + try { + await apiClientV2.downloadJSON(taskId) + toast({ + title: t('export.exportSuccess'), + description: 'JSON 已下載', + variant: 'success', + }) + } catch (error: any) { + toast({ + title: t('export.exportError'), + description: error.response?.data?.detail || t('errors.networkError'), + variant: 'destructive', + }) + } + } + + const getStatusBadge = (status: string) => { + switch (status) { + case 'completed': + return 已完成 + case 'processing': + return 處理中 + case 'failed': + return 失敗 + default: + return 待處理 + } + } + + const formatDate = (dateStr: string) => { + const date = new Date(dateStr) + return date.toLocaleString('zh-TW') + } + + if (isLoading) { + return ( +
+
+ +

載入任務詳情...

+
+
+ ) + } + + if (!taskDetail) { + return ( +
+ + +
+ +
+ 任務不存在 +
+ +

找不到任務 ID: {taskId}

+ +
+
+
+ ) + } + + const isCompleted = taskDetail.status === 'completed' + const isProcessing = taskDetail.status === 'processing' + const isFailed = taskDetail.status === 'failed' + + return ( +
+ {/* Page Header */} +
+
+
+ +
+

任務詳情

+

+ 任務 ID: {taskId} +

+
+
+
+ + {getStatusBadge(taskDetail.status)} +
+
+
+ + {/* Task Info Card */} + + + + + 任務資訊 + + + +
+
+
+

檔案名稱

+

{taskDetail.filename || '未知檔案'}

+
+
+

建立時間

+

{formatDate(taskDetail.created_at)}

+
+ {taskDetail.completed_at && ( +
+

完成時間

+

{formatDate(taskDetail.completed_at)}

+
+ )} +
+
+
+

任務狀態

+ {getStatusBadge(taskDetail.status)} +
+ {taskDetail.processing_time_ms && ( +
+

處理時間

+

{(taskDetail.processing_time_ms / 1000).toFixed(2)} 秒

+
+ )} + {taskDetail.updated_at && ( +
+

最後更新

+

{formatDate(taskDetail.updated_at)}

+
+ )} +
+
+
+
+ + {/* Download Options */} + {isCompleted && ( + + + + + 下載結果 + + + +
+ + + +
+
+
+ )} + + {/* Error Message */} + {isFailed && taskDetail.error_message && ( + + + + + 錯誤訊息 + + + +

{taskDetail.error_message}

+
+
+ )} + + {/* Processing Status */} + {isProcessing && ( + + + +

正在處理中...

+

請稍候,OCR 處理需要一些時間

+
+
+ )} + + {/* Stats Grid (for completed tasks) */} + {isCompleted && ( +
+ + +
+
+ +
+
+

處理時間

+

+ {taskDetail.processing_time_ms ? (taskDetail.processing_time_ms / 1000).toFixed(2) : '0'}s +

+
+
+
+
+ + + +
+
+ +
+
+

處理狀態

+

成功

+
+
+
+
+ + + +
+
+ +
+
+

任務類型

+

OCR

+
+
+
+
+
+ )} + + {/* Result Preview */} + {isCompleted && ( + + + 處理結果預覽 + + + + + + )} +
+ ) +} diff --git a/frontend/src/services/apiV2.ts b/frontend/src/services/apiV2.ts index 820a085..721912b 100644 --- a/frontend/src/services/apiV2.ts +++ b/frontend/src/services/apiV2.ts @@ -47,6 +47,8 @@ class ApiClientV2 { private userInfo: UserInfo | null = null private tokenExpiresAt: number | null = null private refreshTimer: NodeJS.Timeout | null = null + private isRefreshing: boolean = false + private refreshFailed: boolean = false constructor() { this.client = axios.create({ @@ -73,21 +75,41 @@ class ApiClientV2 { (response) => response, async (error: AxiosError) => { if (error.response?.status === 401) { + // If refresh has already failed, don't try again - just redirect + if (this.refreshFailed) { + console.warn('Refresh already failed, redirecting to login') + this.clearAuth() + window.location.href = '/login' + return Promise.reject(error) + } + + // If already refreshing, reject immediately to prevent retry storm + if (this.isRefreshing) { + console.warn('Token refresh already in progress, rejecting request') + return Promise.reject(error) + } + // Token expired or invalid const detail = error.response?.data?.detail if (detail?.includes('Session expired') || detail?.includes('Invalid session')) { console.warn('Session expired, attempting refresh') - // Try to refresh token once + this.isRefreshing = true + try { await this.refreshToken() - // Retry the original request + this.isRefreshing = false + + // Retry the original request only if refresh succeeded if (error.config) { return this.client.request(error.config) } } catch (refreshError) { console.error('Token refresh failed, redirecting to login') + this.isRefreshing = false + this.refreshFailed = true this.clearAuth() window.location.href = '/login' + return Promise.reject(error) } } else { this.clearAuth() @@ -126,6 +148,8 @@ class ApiClientV2 { this.token = null this.userInfo = null this.tokenExpiresAt = null + this.isRefreshing = false + this.refreshFailed = false // Clear refresh timer if (this.refreshTimer) { diff --git a/openspec/changes/archive/2025-11-17-fix-v2-api-ui-issues/proposal.md b/openspec/changes/archive/2025-11-17-fix-v2-api-ui-issues/proposal.md new file mode 100644 index 0000000..0be756d --- /dev/null +++ b/openspec/changes/archive/2025-11-17-fix-v2-api-ui-issues/proposal.md @@ -0,0 +1,24 @@ +# Change: Fix V2 API UI Integration Issues + +## Why +After migrating from V1 batch-based architecture to V2 task-based architecture, several UI pages still reference V1 APIs or have incomplete implementations: +1. Results page (http://127.0.0.1:5173/results) doesn't display task details - uses non-existent V1 `getBatchStatus` API +2. Task History page markdown downloads produce empty files (0 bytes) - OCR service not generating markdown content +3. Task History page "View Details" button navigates to `/tasks/{taskId}` route that doesn't exist +4. Export page (http://127.0.0.1:5173/export) uses non-existent V1 `/api/v2/export` endpoint (404) and lacks multi-task selection +5. Admin Dashboard page loads but may have permission or API issues + +These issues were discovered during testing with task ID: `88c6c2d2-37e1-48fd-a50f-406142987bdf` using file `Henkel-84-1LMISR4 (漢高).pdf`. + +## What Changes +- Migrate ResultsPage from V1 batch API to V2 task API +- Fix OCR service markdown generation to produce non-empty .md files +- Add task detail page route and component at `/tasks/:taskId` +- Update ExportPage to use V2 download endpoints and support multi-task selection +- Verify and fix Admin Dashboard API integration and permissions + +## Impact +- Affected specs: task-management, result-export +- Affected code: + - Frontend: `src/pages/ResultsPage.tsx`, `src/pages/ExportPage.tsx`, `src/App.tsx` (routes), new `src/pages/TaskDetailPage.tsx` + - Backend: `app/services/ocr_service.py` (markdown generation), `app/routers/tasks.py` (download endpoints) diff --git a/openspec/changes/archive/2025-11-17-fix-v2-api-ui-issues/specs/result-export/spec.md b/openspec/changes/archive/2025-11-17-fix-v2-api-ui-issues/specs/result-export/spec.md new file mode 100644 index 0000000..6df518a --- /dev/null +++ b/openspec/changes/archive/2025-11-17-fix-v2-api-ui-issues/specs/result-export/spec.md @@ -0,0 +1,46 @@ +# Result Export - Delta Changes + +## ADDED Requirements + +### Requirement: Export Interface +The Export page SHALL support downloading OCR results in multiple formats using V2 task APIs. + +#### Scenario: Export page uses V2 download endpoints +- **WHEN** user selects a format and clicks export button +- **THEN** frontend SHALL call V2 endpoint `/api/v2/tasks/{task_id}/download/{format}` +- **AND** frontend SHALL NOT call V1 `/api/v2/export` endpoint (which returns 404) +- **AND** file SHALL download successfully + +#### Scenario: Export supports multiple formats +- **WHEN** user exports a completed task +- **THEN** system SHALL support downloading as TXT, JSON, Excel, Markdown, and PDF +- **AND** each format SHALL use correct V2 download endpoint +- **AND** downloaded files SHALL contain task OCR results + +### Requirement: Multi-Task Export Selection +The Export page SHALL allow users to select and export multiple tasks. + +#### Scenario: Select multiple tasks for export +- **WHEN** Export page loads +- **THEN** page SHALL display list of user's completed tasks +- **AND** page SHALL provide checkboxes to select multiple tasks +- **AND** page SHALL NOT require batch ID from upload store (legacy V1 behavior) + +#### Scenario: Export selected tasks +- **WHEN** user selects multiple tasks and clicks export +- **THEN** system SHALL download each selected task's results in chosen format +- **AND** downloaded files SHALL be named distinctly (e.g., `{task_id}_result.{ext}`) +- **AND** system MAY provide option to download as ZIP archive for multiple files + +### Requirement: Export Configuration Persistence +Export settings (format, thresholds, templates) SHALL apply consistently to V2 task downloads. + +#### Scenario: Apply confidence threshold to export +- **WHEN** user sets confidence threshold to 0.7 and exports +- **THEN** downloaded results SHALL only include OCR text with confidence >= 0.7 +- **AND** threshold SHALL apply via V2 download endpoint query parameters + +#### Scenario: Apply CSS template to PDF export +- **WHEN** user selects CSS template for PDF format +- **THEN** downloaded PDF SHALL use selected styling +- **AND** template SHALL be passed to V2 `/tasks/{id}/download/pdf` endpoint diff --git a/openspec/changes/archive/2025-11-17-fix-v2-api-ui-issues/specs/task-management/spec.md b/openspec/changes/archive/2025-11-17-fix-v2-api-ui-issues/specs/task-management/spec.md new file mode 100644 index 0000000..7dffa3d --- /dev/null +++ b/openspec/changes/archive/2025-11-17-fix-v2-api-ui-issues/specs/task-management/spec.md @@ -0,0 +1,51 @@ +# Task Management - Delta Changes + +## ADDED Requirements + +### Requirement: Task Result Generation +The OCR service SHALL generate both JSON and Markdown result files for completed tasks with actual content. + +#### Scenario: Markdown file contains OCR results +- **WHEN** a task completes OCR processing successfully +- **THEN** the generated `.md` file SHALL contain the extracted text in markdown format +- **AND** the file size SHALL be greater than 0 bytes +- **AND** the markdown SHALL include headings, paragraphs, and formatting based on OCR layout detection + +#### Scenario: Result files stored in task directory +- **WHEN** OCR processing completes for task ID `88c6c2d2-37e1-48fd-a50f-406142987bdf` +- **THEN** result files SHALL be stored in `storage/results/88c6c2d2-37e1-48fd-a50f-406142987bdf/` +- **AND** both `_result.json` and `_result.md` SHALL exist +- **AND** both files SHALL contain valid OCR output data + +### Requirement: Task Detail View +The frontend SHALL provide a dedicated page for viewing individual task details. + +#### Scenario: Navigate to task detail page +- **WHEN** user clicks "View Details" button on task in Task History page +- **THEN** browser SHALL navigate to `/tasks/{task_id}` +- **AND** TaskDetailPage component SHALL render + +#### Scenario: Display task information +- **WHEN** TaskDetailPage loads for a valid task ID +- **THEN** page SHALL display task metadata (filename, status, processing time, confidence) +- **AND** page SHALL show markdown preview of OCR results +- **AND** page SHALL provide download buttons for JSON, Markdown, and PDF formats + +#### Scenario: Download from task detail page +- **WHEN** user clicks download button for a specific format +- **THEN** browser SHALL download the file using `/api/v2/tasks/{task_id}/download/{format}` endpoint +- **AND** downloaded file SHALL contain the task's OCR results in requested format + +### Requirement: Results Page V2 Migration +The Results page SHALL use V2 task-based APIs instead of V1 batch APIs. + +#### Scenario: Load task results instead of batch +- **WHEN** Results page loads with a task ID in upload store +- **THEN** page SHALL call `apiClientV2.getTask(taskId)` to fetch task details +- **AND** page SHALL NOT call any V1 batch status endpoints +- **AND** task information SHALL display correctly + +#### Scenario: Handle missing task gracefully +- **WHEN** Results page loads without a task ID +- **THEN** page SHALL display helpful message directing user to upload page +- **AND** page SHALL provide button to navigate to `/upload` diff --git a/openspec/changes/archive/2025-11-17-fix-v2-api-ui-issues/tasks.md b/openspec/changes/archive/2025-11-17-fix-v2-api-ui-issues/tasks.md new file mode 100644 index 0000000..ec75548 --- /dev/null +++ b/openspec/changes/archive/2025-11-17-fix-v2-api-ui-issues/tasks.md @@ -0,0 +1,40 @@ +# Implementation Tasks + +## 1. Backend Fixes +- [x] 1.1 Fix markdown generation in OCR service to produce non-empty content +- [x] 1.2 Verify download endpoints (/tasks/{id}/download/json, markdown, pdf) work correctly +- [x] 1.3 Verify admin API endpoints (/admin/stats, /admin/users, /admin/users/top) exist and work +- [x] 1.4 Test markdown file generation with sample task + +## 2. Frontend - Results Page Migration +- [x] 2.1 Remove V1 API imports from ResultsPage.tsx +- [x] 2.2 Replace `getBatchStatus(batchId)` with V2 task API calls +- [x] 2.3 Update component to work with task data structure instead of batch +- [x] 2.4 Test Results page displays task information correctly + +## 3. Frontend - Task Detail Page +- [x] 3.1 Create TaskDetailPage.tsx component +- [x] 3.2 Add route `/tasks/:taskId` in App.tsx +- [x] 3.3 Implement task detail view with markdown preview +- [x] 3.4 Add download buttons for JSON, Markdown, PDF +- [x] 3.5 Test navigation from Task History page + +## 4. Frontend - Export Page Refactor +- [x] 4.1 Replace V1 `apiClient.exportResults` with V2 download endpoints +- [x] 4.2 Add task selection UI (replace single batch ID input) +- [x] 4.3 Implement multi-task download functionality +- [x] 4.4 Update export button handlers to use V2 APIs +- [x] 4.5 Test all export formats (TXT, JSON, Excel, Markdown, PDF) + +## 5. Admin Dashboard Verification +- [x] 5.1 Test admin page with admin user credentials +- [x] 5.2 Verify API calls return data successfully +- [x] 5.3 Check permission requirements in ProtectedRoute component +- [x] 5.4 Fix any permission or API issues discovered + +## 6. Testing +- [ ] 6.1 Test complete workflow: Upload → Process → View Results → Download +- [ ] 6.2 Verify markdown files contain actual OCR content +- [ ] 6.3 Test task detail navigation and display +- [ ] 6.4 Test multi-format exports from Export page +- [ ] 6.5 Test Admin Dashboard with admin account diff --git a/openspec/specs/result-export/spec.md b/openspec/specs/result-export/spec.md new file mode 100644 index 0000000..7bd3231 --- /dev/null +++ b/openspec/specs/result-export/spec.md @@ -0,0 +1,48 @@ +# result-export Specification + +## Purpose +TBD - created by archiving change fix-v2-api-ui-issues. Update Purpose after archive. +## Requirements +### Requirement: Export Interface +The Export page SHALL support downloading OCR results in multiple formats using V2 task APIs. + +#### Scenario: Export page uses V2 download endpoints +- **WHEN** user selects a format and clicks export button +- **THEN** frontend SHALL call V2 endpoint `/api/v2/tasks/{task_id}/download/{format}` +- **AND** frontend SHALL NOT call V1 `/api/v2/export` endpoint (which returns 404) +- **AND** file SHALL download successfully + +#### Scenario: Export supports multiple formats +- **WHEN** user exports a completed task +- **THEN** system SHALL support downloading as TXT, JSON, Excel, Markdown, and PDF +- **AND** each format SHALL use correct V2 download endpoint +- **AND** downloaded files SHALL contain task OCR results + +### Requirement: Multi-Task Export Selection +The Export page SHALL allow users to select and export multiple tasks. + +#### Scenario: Select multiple tasks for export +- **WHEN** Export page loads +- **THEN** page SHALL display list of user's completed tasks +- **AND** page SHALL provide checkboxes to select multiple tasks +- **AND** page SHALL NOT require batch ID from upload store (legacy V1 behavior) + +#### Scenario: Export selected tasks +- **WHEN** user selects multiple tasks and clicks export +- **THEN** system SHALL download each selected task's results in chosen format +- **AND** downloaded files SHALL be named distinctly (e.g., `{task_id}_result.{ext}`) +- **AND** system MAY provide option to download as ZIP archive for multiple files + +### Requirement: Export Configuration Persistence +Export settings (format, thresholds, templates) SHALL apply consistently to V2 task downloads. + +#### Scenario: Apply confidence threshold to export +- **WHEN** user sets confidence threshold to 0.7 and exports +- **THEN** downloaded results SHALL only include OCR text with confidence >= 0.7 +- **AND** threshold SHALL apply via V2 download endpoint query parameters + +#### Scenario: Apply CSS template to PDF export +- **WHEN** user selects CSS template for PDF format +- **THEN** downloaded PDF SHALL use selected styling +- **AND** template SHALL be passed to V2 `/tasks/{id}/download/pdf` endpoint + diff --git a/openspec/specs/task-management/spec.md b/openspec/specs/task-management/spec.md new file mode 100644 index 0000000..76363da --- /dev/null +++ b/openspec/specs/task-management/spec.md @@ -0,0 +1,53 @@ +# task-management Specification + +## Purpose +TBD - created by archiving change fix-v2-api-ui-issues. Update Purpose after archive. +## Requirements +### Requirement: Task Result Generation +The OCR service SHALL generate both JSON and Markdown result files for completed tasks with actual content. + +#### Scenario: Markdown file contains OCR results +- **WHEN** a task completes OCR processing successfully +- **THEN** the generated `.md` file SHALL contain the extracted text in markdown format +- **AND** the file size SHALL be greater than 0 bytes +- **AND** the markdown SHALL include headings, paragraphs, and formatting based on OCR layout detection + +#### Scenario: Result files stored in task directory +- **WHEN** OCR processing completes for task ID `88c6c2d2-37e1-48fd-a50f-406142987bdf` +- **THEN** result files SHALL be stored in `storage/results/88c6c2d2-37e1-48fd-a50f-406142987bdf/` +- **AND** both `_result.json` and `_result.md` SHALL exist +- **AND** both files SHALL contain valid OCR output data + +### Requirement: Task Detail View +The frontend SHALL provide a dedicated page for viewing individual task details. + +#### Scenario: Navigate to task detail page +- **WHEN** user clicks "View Details" button on task in Task History page +- **THEN** browser SHALL navigate to `/tasks/{task_id}` +- **AND** TaskDetailPage component SHALL render + +#### Scenario: Display task information +- **WHEN** TaskDetailPage loads for a valid task ID +- **THEN** page SHALL display task metadata (filename, status, processing time, confidence) +- **AND** page SHALL show markdown preview of OCR results +- **AND** page SHALL provide download buttons for JSON, Markdown, and PDF formats + +#### Scenario: Download from task detail page +- **WHEN** user clicks download button for a specific format +- **THEN** browser SHALL download the file using `/api/v2/tasks/{task_id}/download/{format}` endpoint +- **AND** downloaded file SHALL contain the task's OCR results in requested format + +### Requirement: Results Page V2 Migration +The Results page SHALL use V2 task-based APIs instead of V1 batch APIs. + +#### Scenario: Load task results instead of batch +- **WHEN** Results page loads with a task ID in upload store +- **THEN** page SHALL call `apiClientV2.getTask(taskId)` to fetch task details +- **AND** page SHALL NOT call any V1 batch status endpoints +- **AND** task information SHALL display correctly + +#### Scenario: Handle missing task gracefully +- **WHEN** Results page loads without a task ID +- **THEN** page SHALL display helpful message directing user to upload page +- **AND** page SHALL provide button to navigate to `/upload` +