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>
This commit is contained in:
egg
2025-12-14 11:56:18 +08:00
parent 3876477bda
commit 81a0a3ab0f
15 changed files with 1111 additions and 351 deletions

View File

@@ -110,7 +110,7 @@ export default function FileUpload({
{t('upload.dragAndDrop')}
</p>
<p className="text-base text-muted-foreground">
{t('upload.orClickToSelect')}
</p>
{/* Supported formats */}
@@ -132,7 +132,7 @@ export default function FileUpload({
</div>
<p className="text-sm text-muted-foreground mt-4">
最大檔案大小: 50MB · {maxFiles}
{t('upload.maxFileSizeWithCount', { maxFiles })}
</p>
</>
)}

View File

@@ -35,11 +35,11 @@ export default function Layout() {
}
const navLinks = [
{ to: '/upload', label: t('nav.upload'), icon: Upload, description: '上傳檔案', adminOnly: false },
{ to: '/processing', label: t('nav.processing'), icon: Activity, description: '處理進度', adminOnly: false },
{ to: '/results', label: t('nav.results'), icon: FileText, description: '查看結果', adminOnly: false },
{ to: '/tasks', label: '任務歷史', icon: History, description: '查看任務記錄', adminOnly: false },
{ to: '/admin', label: '管理員儀表板', icon: Shield, description: '系統管理', adminOnly: true },
{ to: '/upload', label: t('nav.upload'), icon: Upload, description: t('upload.title'), adminOnly: false },
{ to: '/processing', label: t('nav.processing'), icon: Activity, description: t('processing.title'), adminOnly: false },
{ to: '/results', label: t('nav.results'), icon: FileText, description: t('results.title'), adminOnly: false },
{ to: '/tasks', label: t('nav.taskHistory'), icon: History, description: t('taskHistory.subtitle'), adminOnly: false },
{ to: '/admin', label: t('nav.adminDashboard'), icon: Shield, description: t('admin.subtitle'), adminOnly: true },
]
// Filter nav links based on admin status

View File

@@ -1,5 +1,6 @@
import { useState, useCallback, useMemo, useRef, useEffect } from 'react'
import { Document, Page, pdfjs } from 'react-pdf'
import { useTranslation } from 'react-i18next'
// Type alias for PDFDocumentProxy to avoid direct pdfjs-dist import issues
type PDFDocumentProxy = ReturnType<typeof pdfjs.getDocument> extends Promise<infer T> ? T : never
@@ -22,6 +23,7 @@ interface PDFViewerProps {
}
export default function PDFViewer({ title, pdfUrl, className, httpHeaders }: PDFViewerProps) {
const { t } = useTranslation()
const [numPages, setNumPages] = useState<number>(0)
const [pageNumber, setPageNumber] = useState<number>(1)
const [scale, setScale] = useState<number>(1.0)
@@ -55,10 +57,10 @@ export default function PDFViewer({ title, pdfUrl, className, httpHeaders }: PDF
const onDocumentLoadError = useCallback((err: Error) => {
console.error('Error loading PDF:', err)
setError('無法載入 PDF 檔案。請稍後再試。')
setError(t('pdfViewer.loadError'))
setDocumentLoaded(false)
pdfDocRef.current = null
}, [])
}, [t])
const goToPreviousPage = useCallback(() => {
setPageNumber((prev) => Math.max(prev - 1, 1))
@@ -97,7 +99,7 @@ export default function PDFViewer({ title, pdfUrl, className, httpHeaders }: PDF
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm whitespace-nowrap">
{pageNumber} / {numPages || '...'}
{t('pdfViewer.pageInfo', { current: pageNumber, total: numPages || '...' })}
</span>
<Button
variant="outline"
@@ -139,7 +141,7 @@ export default function PDFViewer({ title, pdfUrl, className, httpHeaders }: PDF
{error ? (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<p className="text-destructive font-semibold mb-2"></p>
<p className="text-destructive font-semibold mb-2">{t('pdfViewer.error')}</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
@@ -154,7 +156,7 @@ export default function PDFViewer({ title, pdfUrl, className, httpHeaders }: PDF
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<Loader2 className="w-12 h-12 animate-spin text-primary mx-auto mb-4" />
<p className="text-muted-foreground"> PDF ...</p>
<p className="text-muted-foreground">{t('pdfViewer.loading')}</p>
</div>
</div>
}
@@ -173,7 +175,7 @@ export default function PDFViewer({ title, pdfUrl, className, httpHeaders }: PDF
}
error={
<div className="text-center p-4 text-destructive">
{pageNumber}
{t('pdfViewer.pageLoadError', { page: pageNumber })}
</div>
}
/>

View File

@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Cpu, FileText, Sparkles, Info } from 'lucide-react'
@@ -16,13 +17,14 @@ export default function ProcessingTrackSelector({
documentAnalysis,
disabled = false,
}: ProcessingTrackSelectorProps) {
const { t } = useTranslation()
const recommendedTrack = documentAnalysis?.recommended_track
const tracks = [
{
id: null as ProcessingTrack | null,
name: '自動選擇',
description: '根據文件類型自動選擇最佳處理方式',
name: t('processingTrack.auto.label'),
description: t('processingTrack.auto.description'),
icon: Sparkles,
color: 'text-purple-600',
bgColor: 'bg-purple-50',
@@ -31,8 +33,8 @@ export default function ProcessingTrackSelector({
},
{
id: 'direct' as ProcessingTrack,
name: '直接提取 (DIRECT)',
description: '從 PDF 中直接提取文字圖層,適用於可編輯 PDF',
name: t('processingTrack.direct.label'),
description: t('processingTrack.direct.description'),
icon: FileText,
color: 'text-blue-600',
bgColor: 'bg-blue-50',
@@ -41,8 +43,8 @@ export default function ProcessingTrackSelector({
},
{
id: 'ocr' as ProcessingTrack,
name: 'OCR 識別',
description: '使用光學字元識別處理圖片或掃描文件',
name: t('processingTrack.ocr.label'),
description: t('processingTrack.ocr.description'),
icon: Cpu,
color: 'text-green-600',
bgColor: 'bg-green-50',
@@ -51,6 +53,10 @@ export default function ProcessingTrackSelector({
},
]
const getTrackLabel = (track: string) => {
return track === 'direct' ? t('processingTrack.direct.label') : t('processingTrack.ocr.label')
}
return (
<Card>
<CardHeader>
@@ -59,9 +65,9 @@ export default function ProcessingTrackSelector({
<Sparkles className="w-5 h-5 text-primary" />
</div>
<div>
<CardTitle></CardTitle>
<CardTitle>{t('processingTrack.title')}</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
{t('processingTrack.subtitle')}
</p>
</div>
</div>
@@ -72,7 +78,7 @@ export default function ProcessingTrackSelector({
<div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<Info className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-800">
使{recommendedTrack === 'direct' ? '直接提取' : 'OCR 識別'}
{t('processingTrack.overrideWarning', { track: getTrackLabel(recommendedTrack) })}
</p>
</div>
)}
@@ -109,12 +115,12 @@ export default function ProcessingTrackSelector({
</span>
{track.recommended && (
<Badge variant="outline" className="text-xs bg-white">
{t('processingTrack.recommended')}
</Badge>
)}
{isSelected && (
<Badge variant="default" className="text-xs">
{t('processingTrack.selected')}
</Badge>
)}
</div>
@@ -132,12 +138,12 @@ export default function ProcessingTrackSelector({
{documentAnalysis && (
<div className="pt-3 border-t border-border">
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>: {(documentAnalysis.confidence * 100).toFixed(0)}%</span>
<span>{t('processingTrack.analysisConfidence', { value: (documentAnalysis.confidence * 100).toFixed(0) })}</span>
{documentAnalysis.page_count && (
<span>: {documentAnalysis.page_count}</span>
<span>{t('processingTrack.pageCount', { value: documentAnalysis.page_count })}</span>
)}
{documentAnalysis.text_coverage !== null && (
<span>: {(documentAnalysis.text_coverage * 100).toFixed(1)}%</span>
<span>{t('processingTrack.textCoverage', { value: (documentAnalysis.text_coverage * 100).toFixed(1) })}</span>
)}
</div>
</div>

View File

@@ -5,6 +5,7 @@
import { useEffect, useState } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { apiClientV2 } from '@/services/apiV2'
interface ProtectedRouteProps {
@@ -13,6 +14,7 @@ interface ProtectedRouteProps {
}
export default function ProtectedRoute({ children, requireAdmin = false }: ProtectedRouteProps) {
const { t } = useTranslation()
const location = useLocation()
const [isChecking, setIsChecking] = useState(true)
const [isValid, setIsValid] = useState(false)
@@ -65,7 +67,7 @@ export default function ProtectedRoute({ children, requireAdmin = false }: Prote
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">...</p>
<p className="text-gray-600">{t('common.verifying')}</p>
</div>
</div>
)
@@ -78,9 +80,9 @@ export default function ProtectedRoute({ children, requireAdmin = false }: Prote
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h1 className="text-2xl font-bold text-red-600 mb-4"></h1>
<p className="text-gray-600 mb-4"></p>
<a href="/" className="text-blue-600 hover:underline"></a>
<h1 className="text-2xl font-bold text-red-600 mb-4">{t('common.accessDenied')}</h1>
<p className="text-gray-600 mb-4">{t('common.accessDeniedDesc')}</p>
<a href="/" className="text-blue-600 hover:underline">{t('common.backToHome')}</a>
</div>
</div>
)

View File

@@ -1,4 +1,5 @@
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Trash2 } from 'lucide-react'
@@ -9,6 +10,7 @@ interface TaskNotFoundProps {
}
export default function TaskNotFound({ taskId, onClearAndUpload }: TaskNotFoundProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const handleClick = () => {
@@ -25,19 +27,19 @@ export default function TaskNotFound({ taskId, onClearAndUpload }: TaskNotFoundP
<Trash2 className="w-8 h-8 text-destructive" />
</div>
</div>
<CardTitle className="text-xl"></CardTitle>
<CardTitle className="text-xl">{t('common.taskDeleted')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground">
{t('common.taskDeletedDesc')}
</p>
{taskId && (
<p className="text-xs text-muted-foreground font-mono">
ID: {taskId}
{t('common.taskIdLabel', { id: taskId })}
</p>
)}
<Button onClick={handleClick} size="lg">
{t('common.goToUpload')}
</Button>
</CardContent>
</Card>

View File

@@ -11,7 +11,8 @@
"settings": "Settings",
"logout": "Logout",
"taskHistory": "Task History",
"adminDashboard": "Admin Dashboard"
"adminDashboard": "Admin Dashboard",
"auditLogs": "Audit Logs"
},
"auth": {
"login": "Login",
@@ -24,15 +25,19 @@
"loggingIn": "Signing in...",
"usernamePlaceholder": "Enter your username",
"passwordPlaceholder": "Enter your password",
"supportedFormats": "Supported formats: PDF, Images, Office documents"
"supportedFormats": "Supported formats: PDF, Images, Office documents",
"sessionExpired": "Session expired. Please login again.",
"redirecting": "Redirecting to login page..."
},
"upload": {
"title": "Upload Files",
"subtitle": "Select files for OCR processing. Supports images, PDFs and Office documents",
"dragAndDrop": "Drag and drop files here, or click to select",
"dropFilesHere": "Drop files here to upload",
"invalidFiles": "Some file formats are not supported",
"supportedFormats": "Supported formats: PNG, JPG, JPEG, PDF, DOC, DOCX, PPT, PPTX",
"maxFileSize": "Maximum file size: 50MB",
"maxFileSizeWithCount": "Maximum file size: 50MB · Up to {{maxFiles}} files",
"uploadButton": "Start Upload",
"uploading": "Uploading...",
"uploadSuccess": "Upload successful",
@@ -41,7 +46,30 @@
"clearAll": "Clear All",
"removeFile": "Remove",
"selectedFiles": "Selected Files",
"filesUploaded": "Successfully uploaded {{count}} file(s)"
"filesUploaded": "Successfully uploaded {{count}} file(s)",
"orClickToSelect": "or click to select files",
"selectFilesButton": "Select Files",
"continueToProcess": "Continue to Process",
"goToProcessing": "Go to Processing",
"uploadMoreFiles": "Upload More Files",
"selectAtLeastOne": "Please select at least one file",
"steps": {
"selectFiles": "Select Files",
"selectFilesDesc": "Upload files to process",
"confirmUpload": "Confirm Upload",
"confirmUploadDesc": "Review and start processing",
"processingComplete": "Processing Complete",
"processingCompleteDesc": "View results and export"
},
"fileList": {
"summary": "{{count}} file(s) selected, total size {{size}}",
"unknownType": "Unknown type",
"ready": "Ready",
"removeFile": "Remove file",
"confirmPrompt": "Please confirm files are correct before clicking upload",
"cancel": "Cancel",
"startUpload": "Start Upload"
}
},
"processing": {
"title": "OCR Processing",
@@ -55,6 +83,11 @@
"failed": "Failed",
"pending": "Pending",
"estimatedTime": "Estimated Time Remaining",
"ocrStarted": "OCR processing started",
"loadingTask": "Loading task information...",
"analyzingDocument": "Analyzing document type...",
"noBatchMessage": "No task selected. Please upload files first.",
"goToUpload": "Go to Upload Page",
"settings": {
"title": "Processing Settings",
"language": "Recognition Language",
@@ -151,9 +184,23 @@
"viewJSON": "View JSON",
"downloadPDF": "Download PDF",
"preview": "Preview",
"noBatchMessage": "No task selected. Please upload and process files first.",
"goToUpload": "Go to Upload Page",
"noResults": "No results yet",
"textBlocks": "Text Blocks",
"layoutInfo": "Layout Info"
"layoutInfo": "Layout Info",
"pdfDownloaded": "PDF downloaded",
"markdownDownloaded": "Markdown downloaded",
"jsonDownloaded": "JSON downloaded",
"loadingResults": "Loading task results...",
"processingStatus": "Processing Status",
"taskType": "Task Type",
"processingInProgress": "Processing in progress...",
"processingInProgressDesc": "Please wait, OCR processing takes some time",
"waitingToProcess": "Waiting to process",
"waitingToProcessDesc": "Go to the processing page to start OCR",
"goToProcessing": "Go to Processing",
"viewTaskHistory": "View Task History"
},
"export": {
"title": "Export Results",
@@ -217,7 +264,37 @@
"back": "Back",
"next": "Next",
"previous": "Previous",
"submit": "Submit"
"submit": "Submit",
"retry": "Retry",
"viewDetails": "View Details",
"unknownFile": "Unknown file",
"unknownError": "Unknown error",
"seconds": "seconds",
"total": "Total",
"active": "Active",
"inactive": "Inactive",
"all": "All",
"none": "None",
"enabled": "Enabled",
"disabled": "Disabled",
"yes": "Yes",
"no": "No",
"or": "or",
"and": "and",
"show": "Show",
"hide": "Hide",
"more": "More",
"less": "Less",
"downloadFailed": "Download failed",
"downloadSuccess": "Download successful",
"taskDeleted": "Task Deleted",
"taskDeletedDesc": "This task has been deleted or does not exist. Please upload a new file to create a new task.",
"taskIdLabel": "Task ID: {{id}}",
"goToUpload": "Go to Upload Page",
"verifying": "Verifying...",
"accessDenied": "Access Denied",
"accessDeniedDesc": "You do not have permission to access this page",
"backToHome": "Back to Home"
},
"errors": {
"networkError": "Network error. Please try again later.",
@@ -229,12 +306,54 @@
"unsupportedFormat": "Unsupported format",
"uploadFailed": "Upload failed",
"processingFailed": "Processing failed",
"exportFailed": "Export failed"
"exportFailed": "Export failed",
"loadFailed": "Load failed",
"deleteFailed": "Delete failed",
"startFailed": "Start failed",
"cancelFailed": "Cancel failed",
"retryFailed": "Retry failed"
},
"translation": {
"title": "Translation",
"title": "Document Translation",
"comingSoon": "Coming Soon",
"description": "Document translation feature is under development"
"description": "Translate documents using cloud translation services, supporting multiple target languages.",
"targetLanguage": "Target Language",
"selectLanguage": "Select Language",
"startTranslation": "Start Translation",
"translating": "Translating...",
"translationComplete": "Translation complete",
"translationFailed": "Translation failed",
"translationExists": "Translation already exists",
"translationStarted": "Translation started",
"translationStartedDesc": "Translation task started, please wait...",
"completedTranslations": "Completed Translations",
"deleteTranslation": "Delete Translation",
"deleteSuccess": "Delete successful",
"translationDeleted": "Translation ({{lang}}) deleted",
"downloadTranslatedPdf": "Translated {{format}} PDF ({{lang}}) downloaded",
"status": {
"preparing": "Preparing...",
"loadingModel": "Loading translation model...",
"translating": "Translating...",
"complete": "Complete",
"failed": "Failed"
},
"stats": "{{elements}} elements, {{time}}s",
"languages": {
"en": "English",
"ja": "日本語",
"ko": "한국어",
"zh-TW": "繁體中文",
"zh-CN": "简体中文",
"de": "Deutsch",
"fr": "Français",
"es": "Español",
"pt": "Português",
"it": "Italiano",
"ru": "Русский",
"vi": "Tiếng Việt",
"th": "ภาษาไทย"
}
},
"batch": {
"title": "Batch Processing",
@@ -267,5 +386,255 @@
"processingError": "Batch Processing Error",
"processingCancelled": "Batch Processing Cancelled",
"concurrencyInfo": "Direct Track: max 5 parallel, OCR Track: sequential (GPU limitation)"
},
"admin": {
"title": "Admin Dashboard",
"subtitle": "System Statistics and User Management",
"loadingDashboard": "Loading admin dashboard...",
"loadFailed": "Failed to load admin data",
"auditLogs": "Audit Logs",
"totalUsers": "Total Users",
"totalTasks": "Total Tasks",
"pendingTasks": "Pending",
"processingTasks": "Processing",
"completedTasks": "Completed",
"failedTasks": "Failed",
"activeUsers": "Active",
"translationStats": {
"title": "Translation Statistics",
"description": "Translation API usage and billing tracking",
"totalTranslations": "Total Translations",
"totalTokens": "Total Tokens",
"totalCharacters": "Total Characters",
"estimatedCost": "Estimated Cost",
"last30Days": "Last 30 days",
"languageBreakdown": "Language Breakdown",
"recentTranslations": "Recent Translations",
"count": "times",
"tokens": "tokens"
},
"topUsers": {
"title": "Top Users",
"description": "Users with most tasks",
"displayName": "Display Name",
"totalTasks": "Total Tasks",
"completedTasks": "Completed"
},
"recentUsers": {
"title": "Recent Users",
"description": "Recently registered users",
"noUsers": "No users",
"displayName": "Display Name",
"registeredAt": "Registered At",
"lastLogin": "Last Login",
"status": "Status",
"taskCount": "Tasks",
"completedCount": "Completed",
"failedCount": "Failed"
},
"table": {
"taskId": "Task ID",
"targetLang": "Target Language",
"tokenCount": "Tokens",
"charCount": "Characters",
"cost": "Cost",
"processingTime": "Processing Time",
"time": "Time"
}
},
"taskHistory": {
"title": "Task History",
"subtitle": "View and manage your OCR tasks",
"loadFailed": "Failed to load tasks",
"deleteConfirm": "Are you sure you want to delete this task?",
"deleteFailed": "Failed to delete task",
"noTasksToDelete": "No tasks to delete",
"deleteAllConfirm": "Are you sure you want to delete all {{count}} tasks? This action cannot be undone!",
"allTasksDeleted": "All tasks deleted",
"downloadPdfFailed": "Failed to download PDF",
"startTaskFailed": "Failed to start task",
"cancelConfirm": "Are you sure you want to cancel this task?",
"cancelFailed": "Failed to cancel task",
"retryFailed": "Failed to retry task",
"deleteAll": "Delete All",
"filterConditions": "Filter",
"statusFilter": "Status",
"filenameFilter": "Filename",
"searchFilename": "Search filename",
"startDate": "Start Date",
"endDate": "End Date",
"clearFilter": "Clear Filter",
"taskList": "Task List",
"taskCountInfo": "{{total}} tasks (Page {{page}})",
"noTasks": "No tasks",
"table": {
"filename": "Filename",
"status": "Status",
"createdAt": "Created At",
"completedAt": "Completed At",
"processingTime": "Processing Time",
"actions": "Actions"
},
"actions": {
"startProcessing": "Start Processing",
"cancel": "Cancel",
"retry": "Retry",
"downloadLayoutPdf": "Download Layout PDF",
"layoutPdf": "Layout",
"downloadReflowPdf": "Download Reflow PDF",
"reflowPdf": "Reflow",
"viewDetails": "View Details",
"delete": "Delete"
},
"pagination": {
"showing": "Showing {{start}} - {{end}} of {{total}}",
"previous": "Previous",
"next": "Next"
},
"status": {
"all": "All",
"pending": "Pending",
"processing": "Processing",
"completed": "Completed",
"failed": "Failed"
},
"unnamed": "Unnamed file"
},
"taskDetail": {
"title": "Task Details",
"taskId": "Task ID: {{id}}",
"loadingTask": "Loading task details...",
"taskNotFound": "Task not found",
"taskNotFoundDesc": "Task ID not found: {{id}}",
"returnToHistory": "Return to Task History",
"taskInfo": "Task Information",
"filename": "Filename",
"createdAt": "Created At",
"completedAt": "Completed At",
"taskStatus": "Task Status",
"processingTrack": "Processing Track",
"processingTime": "Processing Time",
"lastUpdated": "Last Updated",
"downloadResults": "Download Results",
"layoutPdf": "Layout PDF",
"reflowPdf": "Reflow PDF",
"downloadVisualization": "Download Recognition Images (ZIP)",
"visualizationDownloaded": "Recognition images downloaded",
"errorMessage": "Error Message",
"processingInProgress": "Processing in progress...",
"processingInProgressDesc": "Please wait, OCR processing takes some time",
"ocrPreview": "OCR Result Preview - {{filename}}",
"stats": {
"processingTime": "Processing Time",
"pageCount": "Pages",
"textRegions": "Text Regions",
"tables": "Tables",
"images": "Images",
"avgConfidence": "Avg Confidence"
},
"track": {
"ocr": "OCR Scan",
"direct": "Direct Extract",
"hybrid": "Hybrid",
"auto": "Auto",
"ocrDesc": "PaddleOCR Text Recognition",
"directDesc": "PyMuPDF Direct Extract",
"hybridDesc": "Hybrid Processing"
},
"status": {
"completed": "Completed",
"processing": "Processing",
"failed": "Failed",
"pending": "Pending"
}
},
"auditLogs": {
"title": "Audit Logs",
"subtitle": "System operation records and security audit",
"loadFailed": "Failed to load audit logs",
"loadingLogs": "Loading audit logs...",
"filterConditions": "Filter",
"actionFilter": "Action Type",
"userFilter": "User",
"allActions": "All Actions",
"allUsers": "All Users",
"categoryFilter": "Category",
"allCategories": "All",
"statusFilter": "Status",
"allStatuses": "All",
"startDate": "Start Date",
"endDate": "End Date",
"clearFilter": "Clear Filter",
"logList": "Log List",
"logCountInfo": "{{total}} records (Page {{page}})",
"noLogs": "No logs",
"category": {
"auth": "Authentication",
"task": "Task",
"file": "File",
"admin": "Admin",
"system": "System"
},
"table": {
"time": "Time",
"user": "User",
"action": "Action",
"target": "Target",
"ip": "IP Address",
"status": "Status",
"category": "Category",
"resource": "Resource",
"errorMessage": "Error Message"
},
"status": {
"success": "Success",
"failed": "Failed"
},
"pagination": {
"showing": "Showing {{start}} - {{end}} of {{total}}",
"previous": "Previous",
"next": "Next"
}
},
"processingTrack": {
"title": "Processing Mode",
"subtitle": "Select processing method or let the system decide",
"auto": {
"label": "Auto Detect",
"description": "System automatically analyzes document type and chooses the best processing method"
},
"direct": {
"label": "Direct Extract (DIRECT)",
"description": "Extract text directly from PDF text layer, suitable for editable PDFs"
},
"ocr": {
"label": "OCR Recognition",
"description": "Use optical character recognition for scanned documents or images"
},
"recommended": "Recommended",
"selected": "Selected",
"overrideWarning": "You have overridden system recommendation. System originally recommended using \"{{track}}\" for this document.",
"analysisConfidence": "Analysis confidence: {{value}}%",
"pageCount": "Pages: {{value}}",
"textCoverage": "Text coverage: {{value}}%",
"note": "Auto mode analyzes PDF content: uses OCR for scanned documents, direct extraction for digital PDFs."
},
"pdfViewer": {
"loading": "Loading PDF...",
"loadError": "Failed to load PDF",
"noPreview": "Cannot preview",
"page": "Page",
"of": "/",
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"fitWidth": "Fit Width",
"fitPage": "Fit Page",
"pageInfo": "Page {{current}} / {{total}}",
"error": "Error",
"pageLoadError": "Failed to load page {{page}}"
},
"languageSwitcher": {
"zhTW": "繁體中文",
"enUS": "English"
}
}

View File

@@ -11,7 +11,8 @@
"settings": "設定",
"logout": "登出",
"taskHistory": "任務歷史",
"adminDashboard": "管理員儀表板"
"adminDashboard": "管理員儀表板",
"auditLogs": "審計日誌"
},
"auth": {
"login": "登入",
@@ -24,15 +25,19 @@
"loggingIn": "登入中...",
"usernamePlaceholder": "輸入您的使用者名稱",
"passwordPlaceholder": "輸入您的密碼",
"supportedFormats": "支援格式PDF、圖片、Office 文件"
"supportedFormats": "支援格式PDF、圖片、Office 文件",
"sessionExpired": "登入已過期,請重新登入",
"redirecting": "正在跳轉至登入頁面..."
},
"upload": {
"title": "上傳檔案",
"subtitle": "選擇要進行 OCR 處理的檔案支援圖片、PDF 和 Office 文件",
"dragAndDrop": "拖曳檔案至此,或點擊選擇檔案",
"dropFilesHere": "放開以上傳檔案",
"invalidFiles": "部分檔案格式不支援",
"supportedFormats": "支援格式PNG, JPG, JPEG, PDF, DOC, DOCX, PPT, PPTX",
"maxFileSize": "單檔最大 50MB",
"maxFileSizeWithCount": "最大檔案大小: 50MB · 最多 {{maxFiles}} 個檔案",
"uploadButton": "開始上傳",
"uploading": "上傳中...",
"uploadSuccess": "上傳成功",
@@ -41,7 +46,30 @@
"clearAll": "清除全部",
"removeFile": "移除",
"selectedFiles": "已選擇的檔案",
"filesUploaded": "成功上傳 {{count}} 個檔案"
"filesUploaded": "成功上傳 {{count}} 個檔案",
"orClickToSelect": "或點擊選擇檔案",
"selectFilesButton": "選擇檔案",
"continueToProcess": "繼續處理",
"goToProcessing": "前往處理頁面",
"uploadMoreFiles": "上傳更多檔案",
"selectAtLeastOne": "請選擇至少一個檔案",
"steps": {
"selectFiles": "選擇檔案",
"selectFilesDesc": "上傳要處理的文件",
"confirmUpload": "確認並上傳",
"confirmUploadDesc": "檢查並開始處理",
"processingComplete": "處理完成",
"processingCompleteDesc": "查看結果並導出"
},
"fileList": {
"summary": "已選擇 {{count}} 個檔案,總大小 {{size}}",
"unknownType": "未知類型",
"ready": "準備就緒",
"removeFile": "移除檔案",
"confirmPrompt": "請確認檔案無誤後點擊上傳按鈕開始處理",
"cancel": "取消",
"startUpload": "開始上傳並處理"
}
},
"processing": {
"title": "OCR 處理中",
@@ -55,6 +83,11 @@
"failed": "處理失敗",
"pending": "等待中",
"estimatedTime": "預計剩餘時間",
"ocrStarted": "OCR 處理已開始",
"loadingTask": "載入任務資訊...",
"analyzingDocument": "分析文件類型中...",
"noBatchMessage": "尚未選擇任何任務。請先上傳檔案以建立任務。",
"goToUpload": "前往上傳頁面",
"settings": {
"title": "處理設定",
"language": "識別語言",
@@ -151,9 +184,23 @@
"viewJSON": "檢視 JSON",
"downloadPDF": "下載 PDF",
"preview": "預覽",
"noBatchMessage": "尚未選擇任何任務。請先上傳並處理檔案。",
"goToUpload": "前往上傳頁面",
"noResults": "尚無處理結果",
"textBlocks": "文字區塊",
"layoutInfo": "版面資訊"
"layoutInfo": "版面資訊",
"pdfDownloaded": "PDF 已下載",
"markdownDownloaded": "Markdown 已下載",
"jsonDownloaded": "JSON 已下載",
"loadingResults": "載入任務結果...",
"processingStatus": "處理狀態",
"taskType": "任務類型",
"processingInProgress": "正在處理中...",
"processingInProgressDesc": "請稍候OCR 處理需要一些時間",
"waitingToProcess": "等待處理",
"waitingToProcessDesc": "請前往處理頁面啟動 OCR 處理",
"goToProcessing": "前往處理頁面",
"viewTaskHistory": "查看任務歷史"
},
"export": {
"title": "匯出結果",
@@ -217,7 +264,37 @@
"back": "返回",
"next": "下一步",
"previous": "上一步",
"submit": "提交"
"submit": "提交",
"retry": "重試",
"viewDetails": "查看詳情",
"unknownFile": "未知檔案",
"unknownError": "未知錯誤",
"seconds": "秒",
"total": "總計",
"active": "活躍",
"inactive": "停用",
"all": "全部",
"none": "無",
"enabled": "啟用",
"disabled": "停用",
"yes": "是",
"no": "否",
"or": "或",
"and": "和",
"show": "顯示",
"hide": "隱藏",
"more": "更多",
"less": "更少",
"downloadFailed": "下載失敗",
"downloadSuccess": "下載成功",
"taskDeleted": "任務已刪除",
"taskDeletedDesc": "此任務已被刪除或不存在。請上傳新檔案以建立新任務。",
"taskIdLabel": "任務 ID: {{id}}",
"goToUpload": "前往上傳頁面",
"verifying": "驗證中...",
"accessDenied": "訪問被拒絕",
"accessDeniedDesc": "您沒有權限訪問此頁面",
"backToHome": "返回首頁"
},
"errors": {
"networkError": "網路錯誤,請稍後再試",
@@ -229,12 +306,54 @@
"unsupportedFormat": "不支援的格式",
"uploadFailed": "上傳失敗",
"processingFailed": "處理失敗",
"exportFailed": "匯出失敗"
"exportFailed": "匯出失敗",
"loadFailed": "載入失敗",
"deleteFailed": "刪除失敗",
"startFailed": "啟動失敗",
"cancelFailed": "取消失敗",
"retryFailed": "重試失敗"
},
"translation": {
"title": "翻譯功能",
"title": "文件翻譯",
"comingSoon": "即將推出",
"description": "文件翻譯功能正在開發中,敬請期待"
"description": "使用雲端翻譯服務進行多語言翻譯,支援多種目標語言。",
"targetLanguage": "目標語言",
"selectLanguage": "選擇語言",
"startTranslation": "開始翻譯",
"translating": "翻譯中...",
"translationComplete": "翻譯完成",
"translationFailed": "翻譯失敗",
"translationExists": "翻譯已存在",
"translationStarted": "開始翻譯",
"translationStartedDesc": "翻譯任務已啟動,請稍候...",
"completedTranslations": "已完成的翻譯",
"deleteTranslation": "刪除翻譯",
"deleteSuccess": "刪除成功",
"translationDeleted": "翻譯結果 ({{lang}}) 已刪除",
"downloadTranslatedPdf": "翻譯 {{format}} PDF ({{lang}}) 已下載",
"status": {
"preparing": "準備中...",
"loadingModel": "載入翻譯模型...",
"translating": "翻譯中...",
"complete": "完成",
"failed": "失敗"
},
"stats": "{{elements}} 元素, {{time}}s",
"languages": {
"en": "English",
"ja": "日本語",
"ko": "한국어",
"zh-TW": "繁體中文",
"zh-CN": "简体中文",
"de": "Deutsch",
"fr": "Français",
"es": "Español",
"pt": "Português",
"it": "Italiano",
"ru": "Русский",
"vi": "Tiếng Việt",
"th": "ภาษาไทย"
}
},
"batch": {
"title": "批次處理",
@@ -267,5 +386,255 @@
"processingError": "批次處理錯誤",
"processingCancelled": "批次處理已取消",
"concurrencyInfo": "Direct Track 最多 5 並行處理OCR Track 依序處理 (GPU 限制)"
},
"admin": {
"title": "管理員儀表板",
"subtitle": "系統統計與用戶管理",
"loadingDashboard": "載入管理員儀表板...",
"loadFailed": "載入管理員資料失敗",
"auditLogs": "審計日誌",
"totalUsers": "總用戶數",
"totalTasks": "總任務數",
"pendingTasks": "待處理",
"processingTasks": "處理中",
"completedTasks": "已完成",
"failedTasks": "失敗",
"activeUsers": "活躍",
"translationStats": {
"title": "翻譯統計",
"description": "翻譯 API 使用量與計費追蹤",
"totalTranslations": "總翻譯次數",
"totalTokens": "總 Token 數",
"totalCharacters": "總字元數",
"estimatedCost": "預估成本",
"last30Days": "近30天",
"languageBreakdown": "語言分佈",
"recentTranslations": "最近翻譯記錄",
"count": "次",
"tokens": "tokens"
},
"topUsers": {
"title": "活躍用戶排行",
"description": "任務數量最多的用戶",
"displayName": "顯示名稱",
"totalTasks": "總任務",
"completedTasks": "已完成"
},
"recentUsers": {
"title": "最近用戶",
"description": "最新註冊的用戶列表",
"noUsers": "暫無用戶",
"displayName": "顯示名稱",
"registeredAt": "註冊時間",
"lastLogin": "最後登入",
"status": "狀態",
"taskCount": "任務數",
"completedCount": "完成",
"failedCount": "失敗"
},
"table": {
"taskId": "任務 ID",
"targetLang": "目標語言",
"tokenCount": "Token 數",
"charCount": "字元數",
"cost": "成本",
"processingTime": "處理時間",
"time": "時間"
}
},
"taskHistory": {
"title": "任務歷史",
"subtitle": "查看和管理您的 OCR 任務",
"loadFailed": "載入任務失敗",
"deleteConfirm": "確定要刪除此任務嗎?",
"deleteFailed": "刪除任務失敗",
"noTasksToDelete": "沒有可刪除的任務",
"deleteAllConfirm": "確定要刪除所有 {{count}} 個任務嗎?此操作無法復原!",
"allTasksDeleted": "所有任務已刪除",
"downloadPdfFailed": "下載 PDF 檔案失敗",
"startTaskFailed": "啟動任務失敗",
"cancelConfirm": "確定要取消此任務嗎?",
"cancelFailed": "取消任務失敗",
"retryFailed": "重試任務失敗",
"deleteAll": "刪除全部",
"filterConditions": "篩選條件",
"statusFilter": "狀態",
"filenameFilter": "檔案名稱",
"searchFilename": "搜尋檔案名稱",
"startDate": "開始日期",
"endDate": "結束日期",
"clearFilter": "清除篩選",
"taskList": "任務列表",
"taskCountInfo": "共 {{total}} 個任務 (顯示第 {{page}} 頁)",
"noTasks": "暫無任務",
"table": {
"filename": "檔案名稱",
"status": "狀態",
"createdAt": "建立時間",
"completedAt": "完成時間",
"processingTime": "處理時間",
"actions": "操作"
},
"actions": {
"startProcessing": "開始處理",
"cancel": "取消",
"retry": "重試",
"downloadLayoutPdf": "下載版面 PDF",
"layoutPdf": "版面",
"downloadReflowPdf": "下載流式 PDF",
"reflowPdf": "流式",
"viewDetails": "查看詳情",
"delete": "刪除"
},
"pagination": {
"showing": "顯示 {{start}} - {{end}} / 共 {{total}} 個",
"previous": "上一頁",
"next": "下一頁"
},
"status": {
"all": "全部",
"pending": "待處理",
"processing": "處理中",
"completed": "已完成",
"failed": "失敗"
},
"unnamed": "未命名檔案"
},
"taskDetail": {
"title": "任務詳情",
"taskId": "任務 ID: {{id}}",
"loadingTask": "載入任務詳情...",
"taskNotFound": "任務不存在",
"taskNotFoundDesc": "找不到任務 ID: {{id}}",
"returnToHistory": "返回任務歷史",
"taskInfo": "任務資訊",
"filename": "檔案名稱",
"createdAt": "建立時間",
"completedAt": "完成時間",
"taskStatus": "任務狀態",
"processingTrack": "處理軌道",
"processingTime": "處理時間",
"lastUpdated": "最後更新",
"downloadResults": "下載結果",
"layoutPdf": "版面 PDF",
"reflowPdf": "流式 PDF",
"downloadVisualization": "下載辨識結果圖片 (ZIP)",
"visualizationDownloaded": "辨識結果圖片已下載",
"errorMessage": "錯誤訊息",
"processingInProgress": "正在處理中...",
"processingInProgressDesc": "請稍候OCR 處理需要一些時間",
"ocrPreview": "OCR 結果預覽 - {{filename}}",
"stats": {
"processingTime": "處理時間",
"pageCount": "頁數",
"textRegions": "文本區域",
"tables": "表格",
"images": "圖片",
"avgConfidence": "平均置信度"
},
"track": {
"ocr": "OCR 掃描",
"direct": "直接提取",
"hybrid": "混合",
"auto": "自動",
"ocrDesc": "PaddleOCR 文字識別",
"directDesc": "PyMuPDF 直接提取",
"hybridDesc": "混合處理"
},
"status": {
"completed": "已完成",
"processing": "處理中",
"failed": "失敗",
"pending": "等待中"
}
},
"auditLogs": {
"title": "審計日誌",
"subtitle": "系統操作記錄與安全審計",
"loadFailed": "載入審計日誌失敗",
"loadingLogs": "載入審計日誌...",
"filterConditions": "篩選條件",
"actionFilter": "操作類型",
"userFilter": "用戶",
"allActions": "所有操作",
"allUsers": "所有用戶",
"categoryFilter": "類別",
"allCategories": "全部",
"statusFilter": "狀態",
"allStatuses": "全部",
"startDate": "開始日期",
"endDate": "結束日期",
"clearFilter": "清除篩選",
"logList": "日誌列表",
"logCountInfo": "共 {{total}} 筆記錄 (顯示第 {{page}} 頁)",
"noLogs": "暫無日誌記錄",
"category": {
"auth": "認證",
"task": "任務",
"file": "檔案",
"admin": "管理",
"system": "系統"
},
"table": {
"time": "時間",
"user": "用戶",
"action": "操作",
"target": "目標",
"ip": "IP 位址",
"status": "狀態",
"category": "類別",
"resource": "資源",
"errorMessage": "錯誤訊息"
},
"status": {
"success": "成功",
"failed": "失敗"
},
"pagination": {
"showing": "顯示 {{start}} - {{end}} / 共 {{total}} 筆",
"previous": "上一頁",
"next": "下一頁"
}
},
"processingTrack": {
"title": "處理方式選擇",
"subtitle": "選擇文件的處理方式,或讓系統自動判斷",
"auto": {
"label": "自動選擇",
"description": "根據文件類型自動選擇最佳處理方式"
},
"direct": {
"label": "直接提取 (DIRECT)",
"description": "從 PDF 中直接提取文字圖層,適用於可編輯 PDF"
},
"ocr": {
"label": "OCR 識別",
"description": "使用光學字元識別處理圖片或掃描文件"
},
"recommended": "系統建議",
"selected": "已選擇",
"overrideWarning": "您已覆蓋系統建議。系統原本建議使用「{{track}}」方式處理此文件。",
"analysisConfidence": "文件分析信心度: {{value}}%",
"pageCount": "頁數: {{value}}",
"textCoverage": "文字覆蓋率: {{value}}%",
"note": "自動模式會根據 PDF 內容判斷:若為掃描件則使用 OCR若為數位 PDF 則直接擷取。"
},
"pdfViewer": {
"loading": "載入 PDF 中...",
"loadError": "載入 PDF 失敗",
"noPreview": "無法預覽",
"page": "頁",
"of": "/",
"zoomIn": "放大",
"zoomOut": "縮小",
"fitWidth": "符合寬度",
"fitPage": "符合頁面",
"pageInfo": "第 {{current}} 頁 / 共 {{total}} 頁",
"error": "錯誤",
"pageLoadError": "無法載入第 {{page}} 頁"
},
"languageSwitcher": {
"zhTW": "繁體中文",
"enUS": "English"
}
}

View File

@@ -5,6 +5,7 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { apiClientV2 } from '@/services/apiV2'
import type { SystemStats, UserWithStats, TopUser, TranslationStats } from '@/types/apiV2'
import {
@@ -34,6 +35,7 @@ import {
import { Badge } from '@/components/ui/badge'
export default function AdminDashboardPage() {
const { t, i18n } = useTranslation()
const navigate = useNavigate()
const [stats, setStats] = useState<SystemStats | null>(null)
const [users, setUsers] = useState<UserWithStats[]>([])
@@ -61,7 +63,7 @@ export default function AdminDashboardPage() {
setTranslationStats(translationStatsData)
} catch (err: any) {
console.error('Failed to fetch admin data:', err)
setError(err.response?.data?.detail || '載入管理員資料失敗')
setError(err.response?.data?.detail || t('admin.loadFailed'))
} finally {
setLoading(false)
}
@@ -71,11 +73,11 @@ export default function AdminDashboardPage() {
fetchData()
}, [])
// Format date
// Format date based on current locale
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-TW')
return date.toLocaleString(i18n.language === 'zh-TW' ? 'zh-TW' : 'en-US')
}
if (loading) {
@@ -83,7 +85,7 @@ export default function AdminDashboardPage() {
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<Loader2 className="w-12 h-12 animate-spin text-blue-600 mx-auto mb-4" />
<p className="text-gray-600">...</p>
<p className="text-gray-600">{t('admin.loadingDashboard')}</p>
</div>
</div>
)
@@ -95,7 +97,7 @@ export default function AdminDashboardPage() {
<div className="flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-lg">
<XCircle className="w-5 h-5 text-red-600" />
<div>
<p className="text-red-600 font-semibold"></p>
<p className="text-red-600 font-semibold">{t('errors.loadFailed')}</p>
<p className="text-red-500 text-sm">{error}</p>
</div>
</div>
@@ -110,18 +112,18 @@ export default function AdminDashboardPage() {
<div>
<div className="flex items-center gap-2">
<Shield className="w-8 h-8 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900"></h1>
<h1 className="text-3xl font-bold text-gray-900">{t('admin.title')}</h1>
</div>
<p className="text-gray-600 mt-1"></p>
<p className="text-gray-600 mt-1">{t('admin.subtitle')}</p>
</div>
<div className="flex gap-2">
<Button onClick={() => navigate('/admin/audit-logs')} variant="outline">
<Activity className="w-4 h-4 mr-2" />
{t('admin.auditLogs')}
</Button>
<Button onClick={fetchData} variant="outline">
<RefreshCw className="w-4 h-4 mr-2" />
{t('common.refresh')}
</Button>
</div>
</div>
@@ -133,13 +135,13 @@ export default function AdminDashboardPage() {
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
<Users className="w-4 h-4" />
{t('admin.totalUsers')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total_users}</div>
<p className="text-xs text-gray-500 mt-1">
: {stats.active_users}
{t('admin.activeUsers')}: {stats.active_users}
</p>
</CardContent>
</Card>
@@ -148,7 +150,7 @@ export default function AdminDashboardPage() {
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
<ClipboardList className="w-4 h-4" />
{t('admin.totalTasks')}
</CardTitle>
</CardHeader>
<CardContent>
@@ -160,7 +162,7 @@ export default function AdminDashboardPage() {
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
<Clock className="w-4 h-4" />
{t('admin.pendingTasks')}
</CardTitle>
</CardHeader>
<CardContent>
@@ -174,7 +176,7 @@ export default function AdminDashboardPage() {
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
<Loader2 className="w-4 h-4" />
{t('admin.processingTasks')}
</CardTitle>
</CardHeader>
<CardContent>
@@ -188,7 +190,7 @@ export default function AdminDashboardPage() {
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" />
{t('admin.completedTasks')}
</CardTitle>
</CardHeader>
<CardContent>
@@ -196,7 +198,7 @@ export default function AdminDashboardPage() {
{stats.task_stats.completed}
</div>
<p className="text-xs text-red-600 mt-1">
: {stats.task_stats.failed}
{t('admin.failedTasks')}: {stats.task_stats.failed}
</p>
</CardContent>
</Card>
@@ -209,42 +211,42 @@ export default function AdminDashboardPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Languages className="w-5 h-5" />
{t('admin.translationStats.title')}
</CardTitle>
<CardDescription> API 使</CardDescription>
<CardDescription>{t('admin.translationStats.description')}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="p-4 bg-blue-50 rounded-lg">
<div className="flex items-center gap-2 text-blue-600 mb-1">
<Languages className="w-4 h-4" />
<span className="text-sm font-medium"></span>
<span className="text-sm font-medium">{t('admin.translationStats.totalTranslations')}</span>
</div>
<div className="text-2xl font-bold text-blue-700">
{translationStats.total_translations.toLocaleString()}
</div>
<p className="text-xs text-blue-500 mt-1">
30: {translationStats.last_30_days.count}
{t('admin.translationStats.last30Days')}: {translationStats.last_30_days.count}
</p>
</div>
<div className="p-4 bg-purple-50 rounded-lg">
<div className="flex items-center gap-2 text-purple-600 mb-1">
<Activity className="w-4 h-4" />
<span className="text-sm font-medium"> Token </span>
<span className="text-sm font-medium">{t('admin.translationStats.totalTokens')}</span>
</div>
<div className="text-2xl font-bold text-purple-700">
{translationStats.total_tokens.toLocaleString()}
</div>
<p className="text-xs text-purple-500 mt-1">
30: {translationStats.last_30_days.tokens.toLocaleString()}
{t('admin.translationStats.last30Days')}: {translationStats.last_30_days.tokens.toLocaleString()}
</p>
</div>
<div className="p-4 bg-green-50 rounded-lg">
<div className="flex items-center gap-2 text-green-600 mb-1">
<ClipboardList className="w-4 h-4" />
<span className="text-sm font-medium"></span>
<span className="text-sm font-medium">{t('admin.translationStats.totalCharacters')}</span>
</div>
<div className="text-2xl font-bold text-green-700">
{translationStats.total_characters.toLocaleString()}
@@ -254,7 +256,7 @@ export default function AdminDashboardPage() {
<div className="p-4 bg-amber-50 rounded-lg">
<div className="flex items-center gap-2 text-amber-600 mb-1">
<Coins className="w-4 h-4" />
<span className="text-sm font-medium"></span>
<span className="text-sm font-medium">{t('admin.translationStats.estimatedCost')}</span>
</div>
<div className="text-2xl font-bold text-amber-700">
${translationStats.estimated_cost.toFixed(2)}
@@ -266,11 +268,11 @@ export default function AdminDashboardPage() {
{/* Language Breakdown */}
{translationStats.by_language.length > 0 && (
<div className="mt-4">
<h4 className="text-sm font-medium text-gray-700 mb-2"></h4>
<h4 className="text-sm font-medium text-gray-700 mb-2">{t('admin.translationStats.languageBreakdown')}</h4>
<div className="flex flex-wrap gap-2">
{translationStats.by_language.map((lang) => (
<Badge key={lang.language} variant="outline" className="px-3 py-1">
{lang.language}: {lang.count} ({lang.tokens.toLocaleString()} tokens)
{lang.language}: {lang.count} {t('admin.translationStats.count')} ({lang.tokens.toLocaleString()} {t('admin.translationStats.tokens')})
</Badge>
))}
</div>
@@ -280,42 +282,42 @@ export default function AdminDashboardPage() {
{/* Recent Translations */}
{translationStats.recent_translations.length > 0 && (
<div className="mt-6">
<h4 className="text-sm font-medium text-gray-700 mb-2"></h4>
<h4 className="text-sm font-medium text-gray-700 mb-2">{t('admin.translationStats.recentTranslations')}</h4>
<Table>
<TableHeader>
<TableRow>
<TableHead> ID</TableHead>
<TableHead></TableHead>
<TableHead className="text-right">Token </TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead>{t('admin.table.taskId')}</TableHead>
<TableHead>{t('admin.table.targetLang')}</TableHead>
<TableHead className="text-right">{t('admin.table.tokenCount')}</TableHead>
<TableHead className="text-right">{t('admin.table.charCount')}</TableHead>
<TableHead className="text-right">{t('admin.table.cost')}</TableHead>
<TableHead className="text-right">{t('admin.table.processingTime')}</TableHead>
<TableHead>{t('admin.table.time')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{translationStats.recent_translations.slice(0, 10).map((t) => (
<TableRow key={t.id}>
{translationStats.recent_translations.slice(0, 10).map((tr) => (
<TableRow key={tr.id}>
<TableCell className="font-mono text-xs">
{t.task_id.substring(0, 8)}...
{tr.task_id.substring(0, 8)}...
</TableCell>
<TableCell>
<Badge variant="secondary">{t.target_lang}</Badge>
<Badge variant="secondary">{tr.target_lang}</Badge>
</TableCell>
<TableCell className="text-right">
{t.total_tokens.toLocaleString()}
{tr.total_tokens.toLocaleString()}
</TableCell>
<TableCell className="text-right">
{t.total_characters.toLocaleString()}
{tr.total_characters.toLocaleString()}
</TableCell>
<TableCell className="text-right font-medium text-amber-600">
${t.estimated_cost.toFixed(4)}
${tr.estimated_cost.toFixed(4)}
</TableCell>
<TableCell className="text-right">
{t.processing_time_seconds.toFixed(1)}s
{tr.processing_time_seconds.toFixed(1)}s
</TableCell>
<TableCell className="text-sm text-gray-600">
{new Date(t.created_at).toLocaleString('zh-TW')}
{formatDate(tr.created_at)}
</TableCell>
</TableRow>
))}
@@ -333,9 +335,9 @@ export default function AdminDashboardPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5" />
{t('admin.topUsers.title')}
</CardTitle>
<CardDescription></CardDescription>
<CardDescription>{t('admin.topUsers.description')}</CardDescription>
</CardHeader>
<CardContent>
<Table>
@@ -343,9 +345,9 @@ export default function AdminDashboardPage() {
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Email</TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead>{t('admin.topUsers.displayName')}</TableHead>
<TableHead className="text-right">{t('admin.topUsers.totalTasks')}</TableHead>
<TableHead className="text-right">{t('admin.topUsers.completedTasks')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -377,26 +379,26 @@ export default function AdminDashboardPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="w-5 h-5" />
{t('admin.recentUsers.title')}
</CardTitle>
<CardDescription></CardDescription>
<CardDescription>{t('admin.recentUsers.description')}</CardDescription>
</CardHeader>
<CardContent>
{users.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Users className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p></p>
<p>{t('admin.recentUsers.noUsers')}</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead>{t('admin.recentUsers.displayName')}</TableHead>
<TableHead>{t('admin.recentUsers.registeredAt')}</TableHead>
<TableHead>{t('admin.recentUsers.lastLogin')}</TableHead>
<TableHead>{t('admin.recentUsers.status')}</TableHead>
<TableHead className="text-right">{t('admin.recentUsers.taskCount')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -412,14 +414,14 @@ export default function AdminDashboardPage() {
</TableCell>
<TableCell>
<Badge variant={user.is_active ? 'default' : 'secondary'}>
{user.is_active ? '活躍' : '停用'}
{user.is_active ? t('common.active') : t('common.inactive')}
</Badge>
</TableCell>
<TableCell className="text-right">
<div>
<div className="font-semibold">{user.task_count}</div>
<div className="text-xs text-gray-500">
: {user.completed_tasks} | : {user.failed_tasks}
{t('admin.recentUsers.completedCount')}: {user.completed_tasks} | {t('admin.recentUsers.failedCount')}: {user.failed_tasks}
</div>
</div>
</TableCell>

View File

@@ -5,6 +5,7 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { apiClientV2 } from '@/services/apiV2'
import type { AuditLog } from '@/types/apiV2'
import {
@@ -33,6 +34,7 @@ import { NativeSelect } from '@/components/ui/select'
export default function AuditLogsPage() {
const navigate = useNavigate()
const { t, i18n } = useTranslation()
const [logs, setLogs] = useState<AuditLog[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -71,7 +73,7 @@ export default function AuditLogsPage() {
setHasMore(response.has_more)
} catch (err: any) {
console.error('Failed to fetch audit logs:', err)
setError(err.response?.data?.detail || '載入審計日誌失敗')
setError(err.response?.data?.detail || t('auditLogs.loadFailed'))
} finally {
setLoading(false)
}
@@ -89,22 +91,24 @@ export default function AuditLogsPage() {
// Format date
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleString('zh-TW')
return date.toLocaleString(i18n.language === 'en' ? 'en-US' : 'zh-TW')
}
// Get category badge
const getCategoryBadge = (category: string) => {
const variants: Record<string, { variant: any; label: string }> = {
auth: { variant: 'default', label: '認證' },
task: { variant: 'secondary', label: '任務' },
file: { variant: 'secondary', label: '檔案' },
admin: { variant: 'destructive', label: '管理' },
system: { variant: 'secondary', label: '系統' },
const variants: Record<string, { variant: any; labelKey: string }> = {
auth: { variant: 'default', labelKey: 'auditLogs.category.auth' },
task: { variant: 'secondary', labelKey: 'auditLogs.category.task' },
file: { variant: 'secondary', labelKey: 'auditLogs.category.file' },
admin: { variant: 'destructive', labelKey: 'auditLogs.category.admin' },
system: { variant: 'secondary', labelKey: 'auditLogs.category.system' },
}
const config = variants[category] || { variant: 'secondary', label: category }
return <Badge variant={config.variant}>{config.label}</Badge>
const config = variants[category]
if (config) {
return <Badge variant={config.variant}>{t(config.labelKey)}</Badge>
}
return <Badge variant="secondary">{category}</Badge>
}
return (
@@ -120,16 +124,16 @@ export default function AuditLogsPage() {
className="mr-2"
>
<ChevronLeft className="w-4 h-4 mr-1" />
{t('common.back')}
</Button>
<Shield className="w-8 h-8 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900"></h1>
<h1 className="text-3xl font-bold text-gray-900">{t('auditLogs.title')}</h1>
</div>
<p className="text-gray-600 mt-1"></p>
<p className="text-gray-600 mt-1">{t('auditLogs.subtitle')}</p>
</div>
<Button onClick={() => fetchLogs()} variant="outline">
<RefreshCw className="w-4 h-4 mr-2" />
{t('common.refresh')}
</Button>
</div>
@@ -138,13 +142,13 @@ export default function AuditLogsPage() {
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Filter className="w-5 h-5" />
{t('auditLogs.filterConditions')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('auditLogs.categoryFilter')}</label>
<NativeSelect
value={categoryFilter}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
@@ -152,18 +156,18 @@ export default function AuditLogsPage() {
handleFilterChange()
}}
options={[
{ value: 'all', label: '全部' },
{ value: 'auth', label: '認證' },
{ value: 'task', label: '任務' },
{ value: 'file', label: '檔案' },
{ value: 'admin', label: '管理' },
{ value: 'system', label: '系統' },
{ value: 'all', label: t('auditLogs.allCategories') },
{ value: 'auth', label: t('auditLogs.category.auth') },
{ value: 'task', label: t('auditLogs.category.task') },
{ value: 'file', label: t('auditLogs.category.file') },
{ value: 'admin', label: t('auditLogs.category.admin') },
{ value: 'system', label: t('auditLogs.category.system') },
]}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('auditLogs.statusFilter')}</label>
<NativeSelect
value={successFilter}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
@@ -171,9 +175,9 @@ export default function AuditLogsPage() {
handleFilterChange()
}}
options={[
{ value: 'all', label: '全部' },
{ value: 'true', label: '成功' },
{ value: 'false', label: '失敗' },
{ value: 'all', label: t('auditLogs.allStatuses') },
{ value: 'true', label: t('auditLogs.status.success') },
{ value: 'false', label: t('auditLogs.status.failed') },
]}
/>
</div>
@@ -190,7 +194,7 @@ export default function AuditLogsPage() {
handleFilterChange()
}}
>
{t('auditLogs.clearFilter')}
</Button>
</div>
)}
@@ -208,9 +212,9 @@ export default function AuditLogsPage() {
{/* Audit Logs List */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardTitle className="text-lg">{t('auditLogs.logList')}</CardTitle>
<CardDescription>
{total} {hasMore && `(顯示第 ${page} 頁)`}
{t('auditLogs.logCountInfo', { total, page })}
</CardDescription>
</CardHeader>
<CardContent>
@@ -221,20 +225,20 @@ export default function AuditLogsPage() {
) : logs.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p></p>
<p>{t('auditLogs.noLogs')}</p>
</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>{t('auditLogs.table.time')}</TableHead>
<TableHead>{t('auditLogs.table.user')}</TableHead>
<TableHead>{t('auditLogs.table.category')}</TableHead>
<TableHead>{t('auditLogs.table.action')}</TableHead>
<TableHead>{t('auditLogs.table.resource')}</TableHead>
<TableHead>{t('auditLogs.table.status')}</TableHead>
<TableHead>{t('auditLogs.table.errorMessage')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -269,12 +273,12 @@ export default function AuditLogsPage() {
{log.success ? (
<Badge variant="default" className="flex items-center gap-1 w-fit">
<CheckCircle2 className="w-3 h-3" />
{t('auditLogs.status.success')}
</Badge>
) : (
<Badge variant="destructive" className="flex items-center gap-1 w-fit">
<XCircle className="w-3 h-3" />
{t('auditLogs.status.failed')}
</Badge>
)}
</TableCell>
@@ -293,8 +297,11 @@ export default function AuditLogsPage() {
{/* Pagination */}
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-gray-600">
{(page - 1) * pageSize + 1} - {Math.min(page * pageSize, total)} / {' '}
{total}
{t('auditLogs.pagination.showing', {
start: (page - 1) * pageSize + 1,
end: Math.min(page * pageSize, total),
total,
})}
</div>
<div className="flex gap-2">
<Button
@@ -303,7 +310,7 @@ export default function AuditLogsPage() {
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
{t('auditLogs.pagination.previous')}
</Button>
<Button
variant="outline"
@@ -311,7 +318,7 @@ export default function AuditLogsPage() {
onClick={() => setPage((p) => p + 1)}
disabled={!hasMore}
>
{t('auditLogs.pagination.next')}
</Button>
</div>
</div>

View File

@@ -124,8 +124,8 @@ function SingleTaskProcessing() {
updateTaskStatus(taskId, 'processing', forceTrack || undefined)
}
toast({
title: '開始處理',
description: 'OCR 處理已開始',
title: t('processing.startProcessing'),
description: t('processing.ocrStarted'),
variant: 'success',
})
},
@@ -200,7 +200,7 @@ function SingleTaskProcessing() {
<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>
<p className="text-muted-foreground">{t('processing.loadingTask')}</p>
</div>
</div>
)
@@ -226,13 +226,13 @@ function SingleTaskProcessing() {
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground">
{t('processing.noBatchMessage', { defaultValue: '尚未選擇任何任務。請先上傳檔案以建立任務。' })}
{t('processing.noBatchMessage')}
</p>
<Button
onClick={() => navigate('/upload')}
size="lg"
>
{t('processing.goToUpload', { defaultValue: '前往上傳頁面' })}
{t('processing.goToUpload')}
</Button>
</CardContent>
</Card>
@@ -252,7 +252,7 @@ function SingleTaskProcessing() {
<div>
<h1 className="page-title">{t('processing.title')}</h1>
<p className="text-muted-foreground mt-1">
ID: <span className="font-mono text-primary">{taskId}</span>
{t('taskDetail.taskId', { id: taskId })}
{taskDetail?.filename && ` · ${taskDetail.filename}`}
</p>
</div>
@@ -260,13 +260,13 @@ function SingleTaskProcessing() {
{isCompleted && (
<div className="flex items-center gap-2 text-success">
<CheckCircle className="w-6 h-6" />
<span className="font-semibold"></span>
<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"></span>
<span className="font-semibold">{t('processing.processing')}</span>
</div>
)}
</div>
@@ -311,9 +311,9 @@ function SingleTaskProcessing() {
<FileText className="w-5 h-5 text-primary" />
</div>
<div>
<p className="text-xs text-muted-foreground mb-0.5"></p>
<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 || '未知檔案'}
{taskDetail.filename || t('common.unknownFile')}
</p>
</div>
</div>
@@ -325,7 +325,7 @@ function SingleTaskProcessing() {
<Clock className="w-5 h-5 text-success" />
</div>
<div>
<p className="text-xs text-muted-foreground mb-0.5"></p>
<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>
@@ -342,7 +342,7 @@ function SingleTaskProcessing() {
<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"></p>
<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>
@@ -380,7 +380,7 @@ function SingleTaskProcessing() {
size="lg"
>
<CheckCircle className="w-4 h-4" />
{t('results.viewTaskHistory')}
</Button>
)}
</div>
@@ -396,24 +396,24 @@ function SingleTaskProcessing() {
<div className="p-2 bg-primary/10 rounded-lg">
<FileText className="w-5 h-5 text-primary" />
</div>
<CardTitle></CardTitle>
<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"></span>
<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"></span>
<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"></span>
<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>
@@ -421,7 +421,7 @@ function SingleTaskProcessing() {
)}
{taskDetail.completed_at && (
<div className="flex justify-between py-2 border-b border-border">
<span className="text-sm text-muted-foreground"></span>
<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>
@@ -439,7 +439,7 @@ function SingleTaskProcessing() {
{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">...</span>
<span className="text-sm text-muted-foreground">{t('processing.analyzingDocument')}</span>
</div>
)}

View File

@@ -47,7 +47,7 @@ export default function ResultsPage() {
await apiClientV2.downloadPDF(taskId)
toast({
title: t('export.exportSuccess'),
description: 'PDF 已下載',
description: t('results.pdfDownloaded'),
variant: 'success',
})
} catch (error: any) {
@@ -65,7 +65,7 @@ export default function ResultsPage() {
await apiClientV2.downloadMarkdown(taskId)
toast({
title: t('export.exportSuccess'),
description: 'Markdown 已下載',
description: t('results.markdownDownloaded'),
variant: 'success',
})
} catch (error: any) {
@@ -83,7 +83,7 @@ export default function ResultsPage() {
await apiClientV2.downloadJSON(taskId)
toast({
title: t('export.exportSuccess'),
description: 'JSON 已下載',
description: t('results.jsonDownloaded'),
variant: 'success',
})
} catch (error: any) {
@@ -98,13 +98,13 @@ export default function ResultsPage() {
const getStatusBadge = (status: string) => {
switch (status) {
case 'completed':
return <Badge variant="default" className="bg-green-600"></Badge>
return <Badge variant="default" className="bg-green-600">{t('taskDetail.status.completed')}</Badge>
case 'processing':
return <Badge variant="default"></Badge>
return <Badge variant="default">{t('taskDetail.status.processing')}</Badge>
case 'failed':
return <Badge variant="destructive"></Badge>
return <Badge variant="destructive">{t('taskDetail.status.failed')}</Badge>
default:
return <Badge variant="secondary"></Badge>
return <Badge variant="secondary">{t('taskDetail.status.pending')}</Badge>
}
}
@@ -114,7 +114,7 @@ export default function ResultsPage() {
<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>
<p className="text-muted-foreground">{t('results.loadingResults')}</p>
</div>
</div>
)
@@ -140,10 +140,10 @@ export default function ResultsPage() {
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground">
{t('results.noBatchMessage', { defaultValue: '尚未選擇任何任務。請先上傳並處理檔案。' })}
{t('results.noBatchMessage')}
</p>
<Button onClick={() => navigate('/upload')} size="lg">
{t('results.goToUpload', { defaultValue: '前往上傳頁面' })}
{t('results.goToUpload')}
</Button>
</CardContent>
</Card>
@@ -157,11 +157,11 @@ export default function ResultsPage() {
<div className="flex items-center justify-center min-h-[60vh]">
<Card className="max-w-md text-center">
<CardHeader>
<CardTitle></CardTitle>
<CardTitle>{t('taskDetail.taskNotFound')}</CardTitle>
</CardHeader>
<CardContent>
<Button onClick={() => navigate('/tasks')}>
{t('results.viewTaskHistory')}
</Button>
</CardContent>
</Card>
@@ -179,7 +179,7 @@ export default function ResultsPage() {
<div>
<h1 className="page-title">{t('results.title')}</h1>
<p className="text-muted-foreground mt-1">
ID: <span className="font-mono text-primary">{taskId}</span>
{t('taskDetail.taskId', { id: taskId })}
{taskDetail.filename && ` · ${taskDetail.filename}`}
</p>
</div>
@@ -215,7 +215,7 @@ export default function ResultsPage() {
<Clock className="w-6 h-6 text-primary" />
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t('results.processingTime')}</p>
<p className="text-2xl font-bold">
{taskDetail.processing_time_ms ? (taskDetail.processing_time_ms / 1000).toFixed(2) : '0'}s
</p>
@@ -231,8 +231,8 @@ export default function ResultsPage() {
<TrendingUp className="w-6 h-6 text-success" />
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold text-success"></p>
<p className="text-sm text-muted-foreground">{t('results.processingStatus')}</p>
<p className="text-2xl font-bold text-success">{t('common.success')}</p>
</div>
</div>
</CardContent>
@@ -245,7 +245,7 @@ export default function ResultsPage() {
<Layers className="w-6 h-6 text-accent" />
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t('results.taskType')}</p>
<p className="text-2xl font-bold">OCR</p>
</div>
</div>
@@ -257,7 +257,7 @@ export default function ResultsPage() {
{/* Results Preview */}
{isCompleted ? (
<PDFViewer
title={`OCR 結果預覽 - ${taskDetail.filename || '未知檔案'}`}
title={t('taskDetail.ocrPreview', { filename: taskDetail.filename || t('common.unknownFile') })}
pdfUrl={pdfUrl}
httpHeaders={pdfHttpHeaders}
/>
@@ -265,15 +265,15 @@ export default function ResultsPage() {
<Card>
<CardContent className="p-12 text-center">
<Loader2 className="w-16 h-16 animate-spin text-primary mx-auto mb-4" />
<p className="text-lg font-semibold">...</p>
<p className="text-muted-foreground mt-2">OCR </p>
<p className="text-lg font-semibold">{t('results.processingInProgress')}</p>
<p className="text-muted-foreground mt-2">{t('results.processingInProgressDesc')}</p>
</CardContent>
</Card>
) : taskDetail.status === 'failed' ? (
<Card>
<CardContent className="p-12 text-center">
<AlertCircle className="w-16 h-16 text-destructive mx-auto mb-4" />
<p className="text-lg font-semibold text-destructive"></p>
<p className="text-lg font-semibold text-destructive">{t('processing.failed')}</p>
{taskDetail.error_message && (
<p className="text-muted-foreground mt-2">{taskDetail.error_message}</p>
)}
@@ -283,10 +283,10 @@ export default function ResultsPage() {
<Card>
<CardContent className="p-12 text-center">
<Clock className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
<p className="text-lg font-semibold"></p>
<p className="text-muted-foreground mt-2"> OCR </p>
<p className="text-lg font-semibold">{t('results.waitingToProcess')}</p>
<p className="text-muted-foreground mt-2">{t('results.waitingToProcessDesc')}</p>
<Button onClick={() => navigate('/processing')} className="mt-4">
{t('results.goToProcessing')}
</Button>
</CardContent>
</Card>

View File

@@ -56,7 +56,7 @@ const LANGUAGE_OPTIONS = [
export default function TaskDetailPage() {
const { taskId } = useParams<{ taskId: string }>()
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const navigate = useNavigate()
const { toast } = useToast()
@@ -124,16 +124,16 @@ export default function TaskDetailPage() {
setIsTranslating(false)
setTranslationProgress(100)
toast({
title: '翻譯完成',
description: `文件已翻譯為 ${LANGUAGE_OPTIONS.find(l => l.value === targetLang)?.label || targetLang}`,
title: t('translation.translationComplete'),
description: `${LANGUAGE_OPTIONS.find(l => l.value === targetLang)?.label || targetLang}`,
variant: 'success',
})
refetchTranslations()
} else if (status.status === 'failed') {
setIsTranslating(false)
toast({
title: '翻譯失敗',
description: status.error_message || '未知錯誤',
title: t('translation.translationFailed'),
description: status.error_message || t('common.unknownError'),
variant: 'destructive',
})
}
@@ -143,7 +143,7 @@ export default function TaskDetailPage() {
}, 2000)
return () => clearInterval(pollInterval)
}, [isTranslating, taskId, targetLang, toast, refetchTranslations])
}, [isTranslating, taskId, targetLang, toast, refetchTranslations, t])
// Construct PDF URL for preview - memoize to prevent unnecessary reloads
// Must be called unconditionally before any early returns (React hooks rule)
@@ -162,24 +162,24 @@ export default function TaskDetailPage() {
if (!track) return null
switch (track) {
case 'direct':
return <Badge variant="default" className="bg-blue-600"></Badge>
return <Badge variant="default" className="bg-blue-600">{t('taskDetail.track.direct')}</Badge>
case 'ocr':
return <Badge variant="default" className="bg-purple-600">OCR</Badge>
case 'hybrid':
return <Badge variant="default" className="bg-orange-600"></Badge>
return <Badge variant="default" className="bg-orange-600">{t('taskDetail.track.hybrid')}</Badge>
default:
return <Badge variant="secondary"></Badge>
return <Badge variant="secondary">{t('taskDetail.track.auto')}</Badge>
}
}
const getTrackDescription = (track?: ProcessingTrack) => {
switch (track) {
case 'direct':
return 'PyMuPDF 直接提取'
return t('taskDetail.track.directDesc')
case 'ocr':
return 'PP-StructureV3 OCR'
case 'hybrid':
return '混合處理'
return t('taskDetail.track.hybridDesc')
default:
return 'OCR'
}
@@ -191,7 +191,7 @@ export default function TaskDetailPage() {
await apiClientV2.downloadPDF(taskId, 'layout')
toast({
title: t('export.exportSuccess'),
description: '版面 PDF 已下載',
description: t('taskDetail.layoutPdf'),
variant: 'success',
})
} catch (error: any) {
@@ -209,7 +209,7 @@ export default function TaskDetailPage() {
await apiClientV2.downloadPDF(taskId, 'reflow')
toast({
title: t('export.exportSuccess'),
description: '流式 PDF 已下載',
description: t('taskDetail.reflowPdf'),
variant: 'success',
})
} catch (error: any) {
@@ -239,7 +239,7 @@ export default function TaskDetailPage() {
setIsTranslating(false)
setTranslationProgress(100)
toast({
title: '翻譯已存在',
title: t('translation.translationExists'),
description: response.message,
variant: 'success',
})
@@ -247,15 +247,15 @@ export default function TaskDetailPage() {
} else {
setTranslationStatus(response.status)
toast({
title: '開始翻譯',
description: '翻譯任務已啟動,請稍候...',
title: t('translation.translationStarted'),
description: t('translation.translationStartedDesc'),
})
}
} catch (error: any) {
setIsTranslating(false)
setTranslationStatus(null)
toast({
title: '啟動翻譯失敗',
title: t('errors.startFailed'),
description: error.response?.data?.detail || t('errors.networkError'),
variant: 'destructive',
})
@@ -267,14 +267,14 @@ export default function TaskDetailPage() {
try {
await apiClientV2.deleteTranslation(taskId, lang)
toast({
title: '刪除成功',
description: `翻譯結果 (${lang}) 已刪除`,
title: t('translation.deleteSuccess'),
description: t('translation.translationDeleted', { lang }),
variant: 'success',
})
refetchTranslations()
} catch (error: any) {
toast({
title: '刪除失敗',
title: t('errors.deleteFailed'),
description: error.response?.data?.detail || t('errors.networkError'),
variant: 'destructive',
})
@@ -285,15 +285,15 @@ export default function TaskDetailPage() {
if (!taskId) return
try {
await apiClientV2.downloadTranslatedPdf(taskId, lang, format)
const formatLabel = format === 'layout' ? '版面' : '流式'
const formatLabel = format === 'layout' ? t('taskDetail.layoutPdf') : t('taskDetail.reflowPdf')
toast({
title: '下載成功',
description: `翻譯 ${formatLabel} PDF (${lang}) 已下載`,
title: t('common.downloadSuccess'),
description: `${formatLabel} (${lang})`,
variant: 'success',
})
} catch (error: any) {
toast({
title: '下載失敗',
title: t('common.downloadFailed'),
description: error.response?.data?.detail || t('errors.networkError'),
variant: 'destructive',
})
@@ -305,13 +305,13 @@ export default function TaskDetailPage() {
try {
await apiClientV2.downloadVisualization(taskId)
toast({
title: '下載成功',
description: '辨識結果圖片已下載',
title: t('common.downloadSuccess'),
description: t('taskDetail.visualizationDownloaded'),
variant: 'success',
})
} catch (error: any) {
toast({
title: '下載失敗',
title: t('common.downloadFailed'),
description: error.response?.data?.detail || t('errors.networkError'),
variant: 'destructive',
})
@@ -321,28 +321,28 @@ export default function TaskDetailPage() {
const getStatusBadge = (status: string) => {
switch (status) {
case 'completed':
return <Badge variant="default" className="bg-green-600"></Badge>
return <Badge variant="default" className="bg-green-600">{t('taskDetail.status.completed')}</Badge>
case 'processing':
return <Badge variant="default"></Badge>
return <Badge variant="default">{t('taskDetail.status.processing')}</Badge>
case 'failed':
return <Badge variant="destructive"></Badge>
return <Badge variant="destructive">{t('taskDetail.status.failed')}</Badge>
default:
return <Badge variant="secondary"></Badge>
return <Badge variant="secondary">{t('taskDetail.status.pending')}</Badge>
}
}
const getTranslationStatusText = (status: TranslationStatus | null) => {
switch (status) {
case 'pending':
return '準備中...'
return t('translation.status.preparing')
case 'loading_model':
return '載入翻譯模型...'
return t('translation.status.loadingModel')
case 'translating':
return '翻譯中...'
return t('translation.status.translating')
case 'completed':
return '完成'
return t('translation.status.complete')
case 'failed':
return '失敗'
return t('taskDetail.status.failed')
default:
return ''
}
@@ -350,7 +350,7 @@ export default function TaskDetailPage() {
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleString('zh-TW')
return date.toLocaleString(i18n.language === 'en' ? 'en-US' : 'zh-TW')
}
if (isLoading) {
@@ -358,7 +358,7 @@ export default function TaskDetailPage() {
<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>
<p className="text-muted-foreground">{t('taskDetail.loadingTask')}</p>
</div>
</div>
)
@@ -372,12 +372,12 @@ export default function TaskDetailPage() {
<div className="flex justify-center mb-4">
<AlertCircle className="w-16 h-16 text-destructive" />
</div>
<CardTitle></CardTitle>
<CardTitle>{t('taskDetail.taskNotFound')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground"> ID: {taskId}</p>
<p className="text-muted-foreground">{t('taskDetail.taskNotFoundDesc', { id: taskId })}</p>
<Button onClick={() => navigate('/tasks')}>
{t('taskDetail.returnToHistory')}
</Button>
</CardContent>
</Card>
@@ -397,19 +397,19 @@ export default function TaskDetailPage() {
<div className="flex items-center gap-4">
<Button variant="outline" onClick={() => navigate('/tasks')} className="gap-2">
<ArrowLeft className="w-4 h-4" />
{t('common.back')}
</Button>
<div>
<h1 className="page-title"></h1>
<h1 className="page-title">{t('taskDetail.title')}</h1>
<p className="text-muted-foreground mt-1">
ID: <span className="font-mono text-primary">{taskId}</span>
{t('taskDetail.taskId', { id: taskId })}
</p>
</div>
</div>
<div className="flex gap-3 items-center">
<Button onClick={() => refetch()} variant="outline" size="sm" className="gap-2">
<RefreshCw className="w-4 h-4" />
{t('common.refresh')}
</Button>
{getStatusBadge(taskDetail.status)}
</div>
@@ -421,35 +421,35 @@ export default function TaskDetailPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
{t('taskDetail.taskInfo')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-3">
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{taskDetail.filename || '未知檔案'}</p>
<p className="text-sm text-muted-foreground mb-1">{t('taskDetail.filename')}</p>
<p className="font-medium">{taskDetail.filename || t('common.unknownFile')}</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="text-sm text-muted-foreground mb-1">{t('taskDetail.createdAt')}</p>
<p className="font-medium">{formatDate(taskDetail.created_at)}</p>
</div>
{taskDetail.completed_at && (
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="text-sm text-muted-foreground mb-1">{t('taskDetail.completedAt')}</p>
<p className="font-medium">{formatDate(taskDetail.completed_at)}</p>
</div>
)}
</div>
<div className="space-y-3">
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="text-sm text-muted-foreground mb-1">{t('taskDetail.taskStatus')}</p>
{getStatusBadge(taskDetail.status)}
</div>
{(taskDetail.processing_track || processingMetadata?.processing_track) && (
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="text-sm text-muted-foreground mb-1">{t('taskDetail.processingTrack')}</p>
<div className="flex items-center gap-2">
{getTrackBadge(taskDetail.processing_track || processingMetadata?.processing_track)}
<span className="text-sm text-muted-foreground">
@@ -460,13 +460,13 @@ export default function TaskDetailPage() {
)}
{taskDetail.processing_time_ms && (
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="font-medium">{(taskDetail.processing_time_ms / 1000).toFixed(2)} </p>
<p className="text-sm text-muted-foreground mb-1">{t('taskDetail.processingTime')}</p>
<p className="font-medium">{(taskDetail.processing_time_ms / 1000).toFixed(2)} {t('common.seconds')}</p>
</div>
)}
{taskDetail.updated_at && (
<div>
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="text-sm text-muted-foreground mb-1">{t('taskDetail.lastUpdated')}</p>
<p className="font-medium">{formatDate(taskDetail.updated_at)}</p>
</div>
)}
@@ -481,18 +481,18 @@ export default function TaskDetailPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Download className="w-5 h-5" />
{t('taskDetail.downloadResults')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3">
<Button onClick={handleDownloadLayoutPDF} className="gap-2 h-20 flex-col">
<Download className="w-8 h-8" />
<span> PDF</span>
<span>{t('taskDetail.layoutPdf')}</span>
</Button>
<Button onClick={handleDownloadReflowPDF} variant="outline" className="gap-2 h-20 flex-col">
<Download className="w-8 h-8" />
<span> PDF</span>
<span>{t('taskDetail.reflowPdf')}</span>
</Button>
</div>
{/* Visualization download for OCR Track */}
@@ -504,7 +504,7 @@ export default function TaskDetailPage() {
className="w-full gap-2"
>
<Image className="w-4 h-4" />
(ZIP)
{t('taskDetail.downloadVisualization')}
</Button>
</div>
)}
@@ -518,7 +518,7 @@ export default function TaskDetailPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Languages className="w-5 h-5" />
{t('translation.title')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
@@ -526,14 +526,14 @@ export default function TaskDetailPage() {
<div className="flex flex-col md:flex-row items-start md:items-center gap-4">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground"></span>
<span className="text-sm text-muted-foreground">{t('translation.targetLanguage')}</span>
<Select
value={targetLang}
onValueChange={setTargetLang}
disabled={isTranslating}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="選擇語言" />
<SelectValue placeholder={t('translation.selectLanguage')} />
</SelectTrigger>
<SelectContent>
{LANGUAGE_OPTIONS.map(lang => (
@@ -554,7 +554,7 @@ export default function TaskDetailPage() {
) : (
<Languages className="w-4 h-4" />
)}
{isTranslating ? getTranslationStatusText(translationStatus) : '開始翻譯'}
{isTranslating ? getTranslationStatusText(translationStatus) : t('translation.startTranslation')}
</Button>
</div>
@@ -572,7 +572,7 @@ export default function TaskDetailPage() {
{/* Existing Translations */}
{translationList && translationList.translations.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground"></p>
<p className="text-sm font-medium text-muted-foreground">{t('translation.completedTranslations')}</p>
<div className="space-y-2">
{translationList.translations.map((item: TranslationListItem) => (
<div
@@ -586,7 +586,7 @@ export default function TaskDetailPage() {
{LANGUAGE_OPTIONS.find(l => l.value === item.target_lang)?.label || item.target_lang}
</span>
<span className="text-sm text-muted-foreground ml-2">
({item.statistics.translated_elements} , {item.statistics.processing_time_seconds.toFixed(1)}s)
({item.statistics.translated_elements} elements, {item.statistics.processing_time_seconds.toFixed(1)}s)
</span>
</div>
</div>
@@ -598,7 +598,7 @@ export default function TaskDetailPage() {
className="gap-1"
>
<Download className="w-3 h-3" />
PDF
{t('taskDetail.reflowPdf')}
</Button>
<Button
variant="ghost"
@@ -616,7 +616,7 @@ export default function TaskDetailPage() {
)}
<p className="text-xs text-muted-foreground">
使
{t('translation.description')}
</p>
</CardContent>
</Card>
@@ -628,7 +628,7 @@ export default function TaskDetailPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertCircle className="w-5 h-5" />
{t('taskDetail.errorMessage')}
</CardTitle>
</CardHeader>
<CardContent>
@@ -642,8 +642,8 @@ export default function TaskDetailPage() {
<Card>
<CardContent className="p-12 text-center">
<Loader2 className="w-16 h-16 animate-spin text-primary mx-auto mb-4" />
<p className="text-lg font-semibold">...</p>
<p className="text-muted-foreground mt-2">OCR </p>
<p className="text-lg font-semibold">{t('taskDetail.processingInProgress')}</p>
<p className="text-muted-foreground mt-2">{t('taskDetail.processingInProgressDesc')}</p>
</CardContent>
</Card>
)}
@@ -658,7 +658,7 @@ export default function TaskDetailPage() {
<Clock className="w-5 h-5 text-primary" />
</div>
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-xs text-muted-foreground">{t('taskDetail.processingTime')}</p>
<p className="text-lg font-bold">
{processingMetadata?.processing_time_seconds?.toFixed(2) ||
(taskDetail.processing_time_ms ? (taskDetail.processing_time_ms / 1000).toFixed(2) : '0')}s
@@ -675,7 +675,7 @@ export default function TaskDetailPage() {
<Layers className="w-5 h-5 text-blue-500" />
</div>
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-xs text-muted-foreground">{t('taskDetail.stats.pageCount')}</p>
<p className="text-lg font-bold">
{processingMetadata?.page_count || '-'}
</p>
@@ -691,7 +691,7 @@ export default function TaskDetailPage() {
<FileSearch className="w-5 h-5 text-purple-500" />
</div>
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-xs text-muted-foreground">{t('taskDetail.stats.textRegions')}</p>
<p className="text-lg font-bold">
{processingMetadata?.total_text_regions || '-'}
</p>
@@ -707,7 +707,7 @@ export default function TaskDetailPage() {
<Table2 className="w-5 h-5 text-green-500" />
</div>
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-xs text-muted-foreground">{t('taskDetail.stats.tables')}</p>
<p className="text-lg font-bold">
{processingMetadata?.total_tables || '-'}
</p>
@@ -723,7 +723,7 @@ export default function TaskDetailPage() {
<Image className="w-5 h-5 text-orange-500" />
</div>
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-xs text-muted-foreground">{t('taskDetail.stats.images')}</p>
<p className="text-lg font-bold">
{processingMetadata?.total_images || '-'}
</p>
@@ -739,7 +739,7 @@ export default function TaskDetailPage() {
<BarChart3 className="w-5 h-5 text-cyan-500" />
</div>
<div>
<p className="text-xs text-muted-foreground"></p>
<p className="text-xs text-muted-foreground">{t('taskDetail.stats.avgConfidence')}</p>
<p className="text-lg font-bold">
{processingMetadata?.average_confidence
? `${(processingMetadata.average_confidence * 100).toFixed(0)}%`
@@ -755,7 +755,7 @@ export default function TaskDetailPage() {
{/* Result Preview */}
{isCompleted && (
<PDFViewer
title={`OCR 結果預覽 - ${taskDetail.filename || '未知檔案'}`}
title={t('taskDetail.ocrPreview', { filename: taskDetail.filename || t('common.unknownFile') })}
pdfUrl={pdfUrl}
httpHeaders={pdfHttpHeaders}
/>

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { apiClientV2 } from '@/services/apiV2'
import type { Task, TaskStats, TaskStatus } from '@/types/apiV2'
import {
@@ -33,6 +34,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
export default function TaskHistoryPage() {
const navigate = useNavigate()
const { t, i18n } = useTranslation()
const [tasks, setTasks] = useState<Task[]>([])
const [stats, setStats] = useState<TaskStats | null>(null)
const [loading, setLoading] = useState(true)
@@ -69,7 +71,7 @@ export default function TaskHistoryPage() {
setTotal(response.total)
setHasMore(response.has_more)
} catch (err: any) {
setError(err.response?.data?.detail || '載入任務失敗')
setError(err.response?.data?.detail || t('taskHistory.loadFailed'))
} finally {
setLoading(false)
}
@@ -101,25 +103,25 @@ export default function TaskHistoryPage() {
// Delete task
const handleDelete = async (taskId: string) => {
if (!confirm('確定要刪除此任務嗎?')) return
if (!confirm(t('taskHistory.deleteConfirm'))) return
try {
await apiClientV2.deleteTask(taskId)
fetchTasks()
fetchStats()
} catch (err: any) {
alert(err.response?.data?.detail || '刪除任務失敗')
alert(err.response?.data?.detail || t('taskHistory.deleteFailed'))
}
}
// Delete all tasks
const handleDeleteAll = async () => {
if (tasks.length === 0) {
alert('沒有可刪除的任務')
alert(t('taskHistory.noTasksToDelete'))
return
}
if (!confirm(`確定要刪除所有 ${total} 個任務嗎?此操作無法復原!`)) return
if (!confirm(t('taskHistory.deleteAllConfirm', { count: total }))) return
try {
setLoading(true)
@@ -139,9 +141,9 @@ export default function TaskHistoryPage() {
}
fetchTasks()
fetchStats()
alert('所有任務已刪除')
alert(t('taskHistory.allTasksDeleted'))
} catch (err: any) {
alert(err.response?.data?.detail || '刪除任務失敗')
alert(err.response?.data?.detail || t('taskHistory.deleteFailed'))
fetchTasks()
fetchStats()
} finally {
@@ -159,7 +161,7 @@ export default function TaskHistoryPage() {
try {
await apiClientV2.downloadPDF(taskId, format)
} catch (err: any) {
alert(err.response?.data?.detail || `下載 PDF 檔案失敗`)
alert(err.response?.data?.detail || t('taskHistory.downloadPdfFailed'))
}
}
@@ -169,18 +171,18 @@ export default function TaskHistoryPage() {
await apiClientV2.startTask(taskId)
fetchTasks()
} catch (err: any) {
alert(err.response?.data?.detail || '啟動任務失敗')
alert(err.response?.data?.detail || t('taskHistory.startTaskFailed'))
}
}
const handleCancelTask = async (taskId: string) => {
if (!confirm('確定要取消此任務嗎?')) return
if (!confirm(t('taskHistory.cancelConfirm'))) return
try {
await apiClientV2.cancelTask(taskId)
fetchTasks()
fetchStats()
} catch (err: any) {
alert(err.response?.data?.detail || '取消任務失敗')
alert(err.response?.data?.detail || t('taskHistory.cancelFailed'))
}
}
@@ -190,14 +192,14 @@ export default function TaskHistoryPage() {
fetchTasks()
fetchStats()
} catch (err: any) {
alert(err.response?.data?.detail || '重試任務失敗')
alert(err.response?.data?.detail || t('taskHistory.retryFailed'))
}
}
// Format date
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleString('zh-TW')
return date.toLocaleString(i18n.language)
}
// Format processing time
@@ -213,22 +215,22 @@ export default function TaskHistoryPage() {
pending: {
variant: 'secondary',
icon: Clock,
label: '待處理',
label: t('taskHistory.status.pending'),
},
processing: {
variant: 'default',
icon: Loader2,
label: '處理中',
label: t('taskHistory.status.processing'),
},
completed: {
variant: 'default',
icon: CheckCircle2,
label: '已完成',
label: t('taskHistory.status.completed'),
},
failed: {
variant: 'destructive',
icon: XCircle,
label: '失敗',
label: t('taskHistory.status.failed'),
},
}
@@ -248,17 +250,17 @@ export default function TaskHistoryPage() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="text-gray-600 mt-1"> OCR </p>
<h1 className="text-3xl font-bold text-gray-900">{t('taskHistory.title')}</h1>
<p className="text-gray-600 mt-1">{t('taskHistory.subtitle')}</p>
</div>
<div className="flex gap-2">
<Button onClick={() => fetchTasks()} variant="outline">
<RefreshCw className="w-4 h-4 mr-2" />
{t('common.refresh')}
</Button>
<Button onClick={handleDeleteAll} variant="destructive" disabled={loading || tasks.length === 0}>
<Trash2 className="w-4 h-4 mr-2" />
{t('taskHistory.deleteAll')}
</Button>
</div>
</div>
@@ -268,7 +270,7 @@ export default function TaskHistoryPage() {
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600"></CardTitle>
<CardTitle className="text-sm font-medium text-gray-600">{t('common.total')}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
@@ -277,7 +279,7 @@ export default function TaskHistoryPage() {
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600"></CardTitle>
<CardTitle className="text-sm font-medium text-gray-600">{t('taskHistory.status.pending')}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-600">{stats.pending}</div>
@@ -286,7 +288,7 @@ export default function TaskHistoryPage() {
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600"></CardTitle>
<CardTitle className="text-sm font-medium text-gray-600">{t('taskHistory.status.processing')}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">{stats.processing}</div>
@@ -295,7 +297,7 @@ export default function TaskHistoryPage() {
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600"></CardTitle>
<CardTitle className="text-sm font-medium text-gray-600">{t('taskHistory.status.completed')}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{stats.completed}</div>
@@ -304,7 +306,7 @@ export default function TaskHistoryPage() {
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600"></CardTitle>
<CardTitle className="text-sm font-medium text-gray-600">{t('taskHistory.status.failed')}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{stats.failed}</div>
@@ -318,13 +320,13 @@ export default function TaskHistoryPage() {
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Filter className="w-5 h-5" />
{t('taskHistory.filterConditions')}
</CardTitle>
</CardHeader>
<CardContent>
<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>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('taskHistory.statusFilter')}</label>
<NativeSelect
value={statusFilter}
onChange={(e) => {
@@ -332,17 +334,17 @@ export default function TaskHistoryPage() {
handleFilterChange()
}}
options={[
{ value: 'all', label: '全部' },
{ value: 'pending', label: '待處理' },
{ value: 'processing', label: '處理中' },
{ value: 'completed', label: '已完成' },
{ value: 'failed', label: '失敗' },
{ value: 'all', label: t('taskHistory.status.all') },
{ value: 'pending', label: t('taskHistory.status.pending') },
{ value: 'processing', label: t('taskHistory.status.processing') },
{ value: 'completed', label: t('taskHistory.status.completed') },
{ value: 'failed', label: t('taskHistory.status.failed') },
]}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('taskHistory.filenameFilter')}</label>
<input
type="text"
value={filenameSearch}
@@ -350,13 +352,13 @@ export default function TaskHistoryPage() {
setFilenameSearch(e.target.value)
handleFilterChange()
}}
placeholder="搜尋檔案名稱"
placeholder={t('taskHistory.searchFilename')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('taskHistory.startDate')}</label>
<input
type="date"
value={dateFrom}
@@ -369,7 +371,7 @@ export default function TaskHistoryPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2"></label>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('taskHistory.endDate')}</label>
<input
type="date"
value={dateTo}
@@ -395,7 +397,7 @@ export default function TaskHistoryPage() {
handleFilterChange()
}}
>
{t('taskHistory.clearFilter')}
</Button>
</div>
)}
@@ -413,9 +415,9 @@ export default function TaskHistoryPage() {
{/* Task List */}
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardTitle className="text-lg">{t('taskHistory.taskList')}</CardTitle>
<CardDescription>
{total} {hasMore && `(顯示第 ${page} 頁)`}
{t('common.total')} {total} {hasMore && `(${t('taskHistory.pagination.showing', { start: (page - 1) * pageSize + 1, end: Math.min(page * pageSize, total), total })})`}
</CardDescription>
</CardHeader>
<CardContent>
@@ -426,26 +428,26 @@ export default function TaskHistoryPage() {
) : tasks.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p></p>
<p>{t('taskHistory.noTasks')}</p>
</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead>{t('taskHistory.filenameFilter')}</TableHead>
<TableHead>{t('taskHistory.statusFilter')}</TableHead>
<TableHead>{t('taskHistory.table.createdAt')}</TableHead>
<TableHead>{t('taskHistory.table.completedAt')}</TableHead>
<TableHead>{t('taskHistory.table.processingTime')}</TableHead>
<TableHead className="text-right">{t('taskHistory.table.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tasks.map((task) => (
<TableRow key={task.id}>
<TableCell className="font-medium">
{task.filename || '未命名檔案'}
{task.filename || t('taskHistory.unnamed')}
</TableCell>
<TableCell>{getStatusBadge(task.status)}</TableCell>
<TableCell className="text-sm text-gray-600">
@@ -466,7 +468,7 @@ export default function TaskHistoryPage() {
variant="outline"
size="sm"
onClick={() => handleStartTask(task.task_id)}
title="開始處理"
title={t('taskHistory.actions.startProcessing')}
>
<Play className="w-4 h-4" />
</Button>
@@ -474,7 +476,7 @@ export default function TaskHistoryPage() {
variant="outline"
size="sm"
onClick={() => handleCancelTask(task.task_id)}
title="取消"
title={t('taskHistory.actions.cancel')}
>
<X className="w-4 h-4" />
</Button>
@@ -485,7 +487,7 @@ export default function TaskHistoryPage() {
variant="outline"
size="sm"
onClick={() => handleCancelTask(task.task_id)}
title="取消"
title={t('taskHistory.actions.cancel')}
>
<X className="w-4 h-4" />
</Button>
@@ -495,7 +497,7 @@ export default function TaskHistoryPage() {
variant="outline"
size="sm"
onClick={() => handleRetryTask(task.task_id)}
title="重試"
title={t('taskHistory.actions.retry')}
>
<RotateCcw className="w-4 h-4" />
</Button>
@@ -507,25 +509,25 @@ export default function TaskHistoryPage() {
variant="outline"
size="sm"
onClick={() => handleDownloadPDF(task.task_id, 'layout')}
title="下載版面 PDF"
title={t('taskHistory.actions.downloadLayoutPdf')}
>
<Download className="w-3 h-3 mr-1" />
{t('taskHistory.actions.layoutPdf')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadPDF(task.task_id, 'reflow')}
title="下載流式 PDF"
title={t('taskHistory.actions.downloadReflowPdf')}
>
<Download className="w-3 h-3 mr-1" />
{t('taskHistory.actions.reflowPdf')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleViewDetails(task.task_id)}
title="查看詳情"
title={t('taskHistory.actions.viewDetails')}
>
<Eye className="w-4 h-4" />
</Button>
@@ -536,7 +538,7 @@ export default function TaskHistoryPage() {
variant="outline"
size="sm"
onClick={() => handleDelete(task.task_id)}
title="刪除"
title={t('taskHistory.actions.delete')}
>
<Trash2 className="w-4 h-4 text-red-600" />
</Button>
@@ -550,8 +552,7 @@ export default function TaskHistoryPage() {
{/* Pagination */}
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-gray-600">
{(page - 1) * pageSize + 1} - {Math.min(page * pageSize, total)} / {' '}
{total}
{t('taskHistory.pagination.showing', { start: (page - 1) * pageSize + 1, end: Math.min(page * pageSize, total), total })}
</div>
<div className="flex gap-2">
<Button
@@ -560,7 +561,7 @@ export default function TaskHistoryPage() {
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
{t('taskHistory.pagination.previous')}
</Button>
<Button
variant="outline"
@@ -568,7 +569,7 @@ export default function TaskHistoryPage() {
onClick={() => setPage((p) => p + 1)}
disabled={!hasMore}
>
{t('taskHistory.pagination.next')}
</Button>
</div>
</div>

View File

@@ -82,7 +82,7 @@ export default function UploadPage() {
if (selectedFiles.length === 0) {
toast({
title: t('errors.validationError'),
description: '請選擇至少一個檔案',
description: t('upload.selectAtLeastOne'),
variant: 'destructive',
})
return
@@ -122,7 +122,7 @@ export default function UploadPage() {
<div className="page-header">
<h1 className="page-title">{t('upload.title')}</h1>
<p className="text-muted-foreground">
OCR PDF Office
{t('upload.subtitle')}
</p>
</div>
@@ -135,8 +135,8 @@ export default function UploadPage() {
{selectedFiles.length === 0 ? '1' : <CheckCircle2 className="w-5 h-5" />}
</div>
<div>
<div className="text-sm font-medium text-foreground"></div>
<div className="text-xs text-muted-foreground"></div>
<div className="text-sm font-medium text-foreground">{t('upload.steps.selectFiles')}</div>
<div className="text-xs text-muted-foreground">{t('upload.steps.selectFilesDesc')}</div>
</div>
</div>
@@ -151,8 +151,8 @@ export default function UploadPage() {
<div>
<div className={`text-sm font-medium ${
selectedFiles.length > 0 ? 'text-foreground' : 'text-muted-foreground'
}`}></div>
<div className="text-xs text-muted-foreground"></div>
}`}>{t('upload.steps.confirmUpload')}</div>
<div className="text-xs text-muted-foreground">{t('upload.steps.confirmUploadDesc')}</div>
</div>
</div>
@@ -163,8 +163,8 @@ export default function UploadPage() {
3
</div>
<div>
<div className="text-sm font-medium text-muted-foreground"></div>
<div className="text-xs text-muted-foreground"></div>
<div className="text-sm font-medium text-muted-foreground">{t('upload.steps.processingComplete')}</div>
<div className="text-xs text-muted-foreground">{t('upload.steps.processingCompleteDesc')}</div>
</div>
</div>
</div>
@@ -193,7 +193,7 @@ export default function UploadPage() {
{t('upload.selectedFiles')}
</CardTitle>
<p className="text-sm text-muted-foreground mt-0.5">
{selectedFiles.length} {formatFileSize(totalSize)}
{t('upload.fileList.summary', { count: selectedFiles.length, size: formatFileSize(totalSize) })}
</p>
</div>
</div>
@@ -205,7 +205,7 @@ export default function UploadPage() {
className="gap-2"
>
<Trash2 className="w-4 h-4" />
{t('upload.clearAll')}
</Button>
</div>
</CardHeader>
@@ -228,13 +228,13 @@ export default function UploadPage() {
{file.name}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(file.size)} · {file.type || '未知類型'}
{formatFileSize(file.size)} · {file.type || t('upload.fileList.unknownType')}
</p>
</div>
{/* Status badge */}
<div className="status-badge-success">
{t('upload.fileList.ready')}
</div>
{/* Remove button */}
@@ -242,7 +242,7 @@ export default function UploadPage() {
onClick={() => handleRemoveFile(index)}
disabled={uploadMutation.isPending}
className="p-2 rounded-lg text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="移除檔案"
title={t('upload.fileList.removeFile')}
>
<X className="w-4 h-4" />
</button>
@@ -255,7 +255,7 @@ export default function UploadPage() {
{/* Action Bar */}
<div className="flex items-center justify-between p-4 bg-card rounded-xl border border-border">
<div className="text-sm text-muted-foreground">
{t('upload.fileList.confirmPrompt')}
</div>
<div className="flex gap-3">
<Button
@@ -263,7 +263,7 @@ export default function UploadPage() {
onClick={handleClearAll}
disabled={uploadMutation.isPending}
>
{t('upload.fileList.cancel')}
</Button>
<Button
onClick={handleUpload}
@@ -278,7 +278,7 @@ export default function UploadPage() {
) : (
<>
<Upload className="w-4 h-4" />
{t('upload.fileList.startUpload')}
</>
)}
</Button>