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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,8 @@
"settings": "Settings", "settings": "Settings",
"logout": "Logout", "logout": "Logout",
"taskHistory": "Task History", "taskHistory": "Task History",
"adminDashboard": "Admin Dashboard" "adminDashboard": "Admin Dashboard",
"auditLogs": "Audit Logs"
}, },
"auth": { "auth": {
"login": "Login", "login": "Login",
@@ -24,15 +25,19 @@
"loggingIn": "Signing in...", "loggingIn": "Signing in...",
"usernamePlaceholder": "Enter your username", "usernamePlaceholder": "Enter your username",
"passwordPlaceholder": "Enter your password", "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": { "upload": {
"title": "Upload Files", "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", "dragAndDrop": "Drag and drop files here, or click to select",
"dropFilesHere": "Drop files here to upload", "dropFilesHere": "Drop files here to upload",
"invalidFiles": "Some file formats are not supported", "invalidFiles": "Some file formats are not supported",
"supportedFormats": "Supported formats: PNG, JPG, JPEG, PDF, DOC, DOCX, PPT, PPTX", "supportedFormats": "Supported formats: PNG, JPG, JPEG, PDF, DOC, DOCX, PPT, PPTX",
"maxFileSize": "Maximum file size: 50MB", "maxFileSize": "Maximum file size: 50MB",
"maxFileSizeWithCount": "Maximum file size: 50MB · Up to {{maxFiles}} files",
"uploadButton": "Start Upload", "uploadButton": "Start Upload",
"uploading": "Uploading...", "uploading": "Uploading...",
"uploadSuccess": "Upload successful", "uploadSuccess": "Upload successful",
@@ -41,7 +46,30 @@
"clearAll": "Clear All", "clearAll": "Clear All",
"removeFile": "Remove", "removeFile": "Remove",
"selectedFiles": "Selected Files", "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": { "processing": {
"title": "OCR Processing", "title": "OCR Processing",
@@ -55,6 +83,11 @@
"failed": "Failed", "failed": "Failed",
"pending": "Pending", "pending": "Pending",
"estimatedTime": "Estimated Time Remaining", "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": { "settings": {
"title": "Processing Settings", "title": "Processing Settings",
"language": "Recognition Language", "language": "Recognition Language",
@@ -151,9 +184,23 @@
"viewJSON": "View JSON", "viewJSON": "View JSON",
"downloadPDF": "Download PDF", "downloadPDF": "Download PDF",
"preview": "Preview", "preview": "Preview",
"noBatchMessage": "No task selected. Please upload and process files first.",
"goToUpload": "Go to Upload Page",
"noResults": "No results yet", "noResults": "No results yet",
"textBlocks": "Text Blocks", "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": { "export": {
"title": "Export Results", "title": "Export Results",
@@ -217,7 +264,37 @@
"back": "Back", "back": "Back",
"next": "Next", "next": "Next",
"previous": "Previous", "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": { "errors": {
"networkError": "Network error. Please try again later.", "networkError": "Network error. Please try again later.",
@@ -229,12 +306,54 @@
"unsupportedFormat": "Unsupported format", "unsupportedFormat": "Unsupported format",
"uploadFailed": "Upload failed", "uploadFailed": "Upload failed",
"processingFailed": "Processing 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": { "translation": {
"title": "Translation", "title": "Document Translation",
"comingSoon": "Coming Soon", "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": { "batch": {
"title": "Batch Processing", "title": "Batch Processing",
@@ -267,5 +386,255 @@
"processingError": "Batch Processing Error", "processingError": "Batch Processing Error",
"processingCancelled": "Batch Processing Cancelled", "processingCancelled": "Batch Processing Cancelled",
"concurrencyInfo": "Direct Track: max 5 parallel, OCR Track: sequential (GPU limitation)" "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": "設定", "settings": "設定",
"logout": "登出", "logout": "登出",
"taskHistory": "任務歷史", "taskHistory": "任務歷史",
"adminDashboard": "管理員儀表板" "adminDashboard": "管理員儀表板",
"auditLogs": "審計日誌"
}, },
"auth": { "auth": {
"login": "登入", "login": "登入",
@@ -24,15 +25,19 @@
"loggingIn": "登入中...", "loggingIn": "登入中...",
"usernamePlaceholder": "輸入您的使用者名稱", "usernamePlaceholder": "輸入您的使用者名稱",
"passwordPlaceholder": "輸入您的密碼", "passwordPlaceholder": "輸入您的密碼",
"supportedFormats": "支援格式PDF、圖片、Office 文件" "supportedFormats": "支援格式PDF、圖片、Office 文件",
"sessionExpired": "登入已過期,請重新登入",
"redirecting": "正在跳轉至登入頁面..."
}, },
"upload": { "upload": {
"title": "上傳檔案", "title": "上傳檔案",
"subtitle": "選擇要進行 OCR 處理的檔案支援圖片、PDF 和 Office 文件",
"dragAndDrop": "拖曳檔案至此,或點擊選擇檔案", "dragAndDrop": "拖曳檔案至此,或點擊選擇檔案",
"dropFilesHere": "放開以上傳檔案", "dropFilesHere": "放開以上傳檔案",
"invalidFiles": "部分檔案格式不支援", "invalidFiles": "部分檔案格式不支援",
"supportedFormats": "支援格式PNG, JPG, JPEG, PDF, DOC, DOCX, PPT, PPTX", "supportedFormats": "支援格式PNG, JPG, JPEG, PDF, DOC, DOCX, PPT, PPTX",
"maxFileSize": "單檔最大 50MB", "maxFileSize": "單檔最大 50MB",
"maxFileSizeWithCount": "最大檔案大小: 50MB · 最多 {{maxFiles}} 個檔案",
"uploadButton": "開始上傳", "uploadButton": "開始上傳",
"uploading": "上傳中...", "uploading": "上傳中...",
"uploadSuccess": "上傳成功", "uploadSuccess": "上傳成功",
@@ -41,7 +46,30 @@
"clearAll": "清除全部", "clearAll": "清除全部",
"removeFile": "移除", "removeFile": "移除",
"selectedFiles": "已選擇的檔案", "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": { "processing": {
"title": "OCR 處理中", "title": "OCR 處理中",
@@ -55,6 +83,11 @@
"failed": "處理失敗", "failed": "處理失敗",
"pending": "等待中", "pending": "等待中",
"estimatedTime": "預計剩餘時間", "estimatedTime": "預計剩餘時間",
"ocrStarted": "OCR 處理已開始",
"loadingTask": "載入任務資訊...",
"analyzingDocument": "分析文件類型中...",
"noBatchMessage": "尚未選擇任何任務。請先上傳檔案以建立任務。",
"goToUpload": "前往上傳頁面",
"settings": { "settings": {
"title": "處理設定", "title": "處理設定",
"language": "識別語言", "language": "識別語言",
@@ -151,9 +184,23 @@
"viewJSON": "檢視 JSON", "viewJSON": "檢視 JSON",
"downloadPDF": "下載 PDF", "downloadPDF": "下載 PDF",
"preview": "預覽", "preview": "預覽",
"noBatchMessage": "尚未選擇任何任務。請先上傳並處理檔案。",
"goToUpload": "前往上傳頁面",
"noResults": "尚無處理結果", "noResults": "尚無處理結果",
"textBlocks": "文字區塊", "textBlocks": "文字區塊",
"layoutInfo": "版面資訊" "layoutInfo": "版面資訊",
"pdfDownloaded": "PDF 已下載",
"markdownDownloaded": "Markdown 已下載",
"jsonDownloaded": "JSON 已下載",
"loadingResults": "載入任務結果...",
"processingStatus": "處理狀態",
"taskType": "任務類型",
"processingInProgress": "正在處理中...",
"processingInProgressDesc": "請稍候OCR 處理需要一些時間",
"waitingToProcess": "等待處理",
"waitingToProcessDesc": "請前往處理頁面啟動 OCR 處理",
"goToProcessing": "前往處理頁面",
"viewTaskHistory": "查看任務歷史"
}, },
"export": { "export": {
"title": "匯出結果", "title": "匯出結果",
@@ -217,7 +264,37 @@
"back": "返回", "back": "返回",
"next": "下一步", "next": "下一步",
"previous": "上一步", "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": { "errors": {
"networkError": "網路錯誤,請稍後再試", "networkError": "網路錯誤,請稍後再試",
@@ -229,12 +306,54 @@
"unsupportedFormat": "不支援的格式", "unsupportedFormat": "不支援的格式",
"uploadFailed": "上傳失敗", "uploadFailed": "上傳失敗",
"processingFailed": "處理失敗", "processingFailed": "處理失敗",
"exportFailed": "匯出失敗" "exportFailed": "匯出失敗",
"loadFailed": "載入失敗",
"deleteFailed": "刪除失敗",
"startFailed": "啟動失敗",
"cancelFailed": "取消失敗",
"retryFailed": "重試失敗"
}, },
"translation": { "translation": {
"title": "翻譯功能", "title": "文件翻譯",
"comingSoon": "即將推出", "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": { "batch": {
"title": "批次處理", "title": "批次處理",
@@ -267,5 +386,255 @@
"processingError": "批次處理錯誤", "processingError": "批次處理錯誤",
"processingCancelled": "批次處理已取消", "processingCancelled": "批次處理已取消",
"concurrencyInfo": "Direct Track 最多 5 並行處理OCR Track 依序處理 (GPU 限制)" "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 { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { apiClientV2 } from '@/services/apiV2' import { apiClientV2 } from '@/services/apiV2'
import type { SystemStats, UserWithStats, TopUser, TranslationStats } from '@/types/apiV2' import type { SystemStats, UserWithStats, TopUser, TranslationStats } from '@/types/apiV2'
import { import {
@@ -34,6 +35,7 @@ import {
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
export default function AdminDashboardPage() { export default function AdminDashboardPage() {
const { t, i18n } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const [stats, setStats] = useState<SystemStats | null>(null) const [stats, setStats] = useState<SystemStats | null>(null)
const [users, setUsers] = useState<UserWithStats[]>([]) const [users, setUsers] = useState<UserWithStats[]>([])
@@ -61,7 +63,7 @@ export default function AdminDashboardPage() {
setTranslationStats(translationStatsData) setTranslationStats(translationStatsData)
} catch (err: any) { } catch (err: any) {
console.error('Failed to fetch admin data:', err) console.error('Failed to fetch admin data:', err)
setError(err.response?.data?.detail || '載入管理員資料失敗') setError(err.response?.data?.detail || t('admin.loadFailed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -71,11 +73,11 @@ export default function AdminDashboardPage() {
fetchData() fetchData()
}, []) }, [])
// Format date // Format date based on current locale
const formatDate = (dateStr: string | null) => { const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-' if (!dateStr) return '-'
const date = new Date(dateStr) const date = new Date(dateStr)
return date.toLocaleString('zh-TW') return date.toLocaleString(i18n.language === 'zh-TW' ? 'zh-TW' : 'en-US')
} }
if (loading) { if (loading) {
@@ -83,7 +85,7 @@ export default function AdminDashboardPage() {
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
<div className="text-center"> <div className="text-center">
<Loader2 className="w-12 h-12 animate-spin text-blue-600 mx-auto mb-4" /> <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>
</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"> <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" /> <XCircle className="w-5 h-5 text-red-600" />
<div> <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> <p className="text-red-500 text-sm">{error}</p>
</div> </div>
</div> </div>
@@ -110,18 +112,18 @@ export default function AdminDashboardPage() {
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Shield className="w-8 h-8 text-blue-600" /> <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> </div>
<p className="text-gray-600 mt-1"></p> <p className="text-gray-600 mt-1">{t('admin.subtitle')}</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={() => navigate('/admin/audit-logs')} variant="outline"> <Button onClick={() => navigate('/admin/audit-logs')} variant="outline">
<Activity className="w-4 h-4 mr-2" /> <Activity className="w-4 h-4 mr-2" />
{t('admin.auditLogs')}
</Button> </Button>
<Button onClick={fetchData} variant="outline"> <Button onClick={fetchData} variant="outline">
<RefreshCw className="w-4 h-4 mr-2" /> <RefreshCw className="w-4 h-4 mr-2" />
{t('common.refresh')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -133,13 +135,13 @@ export default function AdminDashboardPage() {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2"> <CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
<Users className="w-4 h-4" /> <Users className="w-4 h-4" />
{t('admin.totalUsers')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{stats.total_users}</div> <div className="text-2xl font-bold">{stats.total_users}</div>
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
: {stats.active_users} {t('admin.activeUsers')}: {stats.active_users}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -148,7 +150,7 @@ export default function AdminDashboardPage() {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2"> <CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
<ClipboardList className="w-4 h-4" /> <ClipboardList className="w-4 h-4" />
{t('admin.totalTasks')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -160,7 +162,7 @@ export default function AdminDashboardPage() {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2"> <CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
{t('admin.pendingTasks')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -174,7 +176,7 @@ export default function AdminDashboardPage() {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2"> <CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
<Loader2 className="w-4 h-4" /> <Loader2 className="w-4 h-4" />
{t('admin.processingTasks')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -188,7 +190,7 @@ export default function AdminDashboardPage() {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2"> <CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" /> <CheckCircle2 className="w-4 h-4" />
{t('admin.completedTasks')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -196,7 +198,7 @@ export default function AdminDashboardPage() {
{stats.task_stats.completed} {stats.task_stats.completed}
</div> </div>
<p className="text-xs text-red-600 mt-1"> <p className="text-xs text-red-600 mt-1">
: {stats.task_stats.failed} {t('admin.failedTasks')}: {stats.task_stats.failed}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -209,42 +211,42 @@ export default function AdminDashboardPage() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Languages className="w-5 h-5" /> <Languages className="w-5 h-5" />
{t('admin.translationStats.title')}
</CardTitle> </CardTitle>
<CardDescription> API 使</CardDescription> <CardDescription>{t('admin.translationStats.description')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6"> <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="p-4 bg-blue-50 rounded-lg">
<div className="flex items-center gap-2 text-blue-600 mb-1"> <div className="flex items-center gap-2 text-blue-600 mb-1">
<Languages className="w-4 h-4" /> <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>
<div className="text-2xl font-bold text-blue-700"> <div className="text-2xl font-bold text-blue-700">
{translationStats.total_translations.toLocaleString()} {translationStats.total_translations.toLocaleString()}
</div> </div>
<p className="text-xs text-blue-500 mt-1"> <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> </p>
</div> </div>
<div className="p-4 bg-purple-50 rounded-lg"> <div className="p-4 bg-purple-50 rounded-lg">
<div className="flex items-center gap-2 text-purple-600 mb-1"> <div className="flex items-center gap-2 text-purple-600 mb-1">
<Activity className="w-4 h-4" /> <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>
<div className="text-2xl font-bold text-purple-700"> <div className="text-2xl font-bold text-purple-700">
{translationStats.total_tokens.toLocaleString()} {translationStats.total_tokens.toLocaleString()}
</div> </div>
<p className="text-xs text-purple-500 mt-1"> <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> </p>
</div> </div>
<div className="p-4 bg-green-50 rounded-lg"> <div className="p-4 bg-green-50 rounded-lg">
<div className="flex items-center gap-2 text-green-600 mb-1"> <div className="flex items-center gap-2 text-green-600 mb-1">
<ClipboardList className="w-4 h-4" /> <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>
<div className="text-2xl font-bold text-green-700"> <div className="text-2xl font-bold text-green-700">
{translationStats.total_characters.toLocaleString()} {translationStats.total_characters.toLocaleString()}
@@ -254,7 +256,7 @@ export default function AdminDashboardPage() {
<div className="p-4 bg-amber-50 rounded-lg"> <div className="p-4 bg-amber-50 rounded-lg">
<div className="flex items-center gap-2 text-amber-600 mb-1"> <div className="flex items-center gap-2 text-amber-600 mb-1">
<Coins className="w-4 h-4" /> <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>
<div className="text-2xl font-bold text-amber-700"> <div className="text-2xl font-bold text-amber-700">
${translationStats.estimated_cost.toFixed(2)} ${translationStats.estimated_cost.toFixed(2)}
@@ -266,11 +268,11 @@ export default function AdminDashboardPage() {
{/* Language Breakdown */} {/* Language Breakdown */}
{translationStats.by_language.length > 0 && ( {translationStats.by_language.length > 0 && (
<div className="mt-4"> <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"> <div className="flex flex-wrap gap-2">
{translationStats.by_language.map((lang) => ( {translationStats.by_language.map((lang) => (
<Badge key={lang.language} variant="outline" className="px-3 py-1"> <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> </Badge>
))} ))}
</div> </div>
@@ -280,42 +282,42 @@ export default function AdminDashboardPage() {
{/* Recent Translations */} {/* Recent Translations */}
{translationStats.recent_translations.length > 0 && ( {translationStats.recent_translations.length > 0 && (
<div className="mt-6"> <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> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead> ID</TableHead> <TableHead>{t('admin.table.taskId')}</TableHead>
<TableHead></TableHead> <TableHead>{t('admin.table.targetLang')}</TableHead>
<TableHead className="text-right">Token </TableHead> <TableHead className="text-right">{t('admin.table.tokenCount')}</TableHead>
<TableHead className="text-right"></TableHead> <TableHead className="text-right">{t('admin.table.charCount')}</TableHead>
<TableHead className="text-right"></TableHead> <TableHead className="text-right">{t('admin.table.cost')}</TableHead>
<TableHead className="text-right"></TableHead> <TableHead className="text-right">{t('admin.table.processingTime')}</TableHead>
<TableHead></TableHead> <TableHead>{t('admin.table.time')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{translationStats.recent_translations.slice(0, 10).map((t) => ( {translationStats.recent_translations.slice(0, 10).map((tr) => (
<TableRow key={t.id}> <TableRow key={tr.id}>
<TableCell className="font-mono text-xs"> <TableCell className="font-mono text-xs">
{t.task_id.substring(0, 8)}... {tr.task_id.substring(0, 8)}...
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant="secondary">{t.target_lang}</Badge> <Badge variant="secondary">{tr.target_lang}</Badge>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{t.total_tokens.toLocaleString()} {tr.total_tokens.toLocaleString()}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{t.total_characters.toLocaleString()} {tr.total_characters.toLocaleString()}
</TableCell> </TableCell>
<TableCell className="text-right font-medium text-amber-600"> <TableCell className="text-right font-medium text-amber-600">
${t.estimated_cost.toFixed(4)} ${tr.estimated_cost.toFixed(4)}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{t.processing_time_seconds.toFixed(1)}s {tr.processing_time_seconds.toFixed(1)}s
</TableCell> </TableCell>
<TableCell className="text-sm text-gray-600"> <TableCell className="text-sm text-gray-600">
{new Date(t.created_at).toLocaleString('zh-TW')} {formatDate(tr.created_at)}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -333,9 +335,9 @@ export default function AdminDashboardPage() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5" /> <TrendingUp className="w-5 h-5" />
{t('admin.topUsers.title')}
</CardTitle> </CardTitle>
<CardDescription></CardDescription> <CardDescription>{t('admin.topUsers.description')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <Table>
@@ -343,9 +345,9 @@ export default function AdminDashboardPage() {
<TableRow> <TableRow>
<TableHead className="w-12">#</TableHead> <TableHead className="w-12">#</TableHead>
<TableHead>Email</TableHead> <TableHead>Email</TableHead>
<TableHead></TableHead> <TableHead>{t('admin.topUsers.displayName')}</TableHead>
<TableHead className="text-right"></TableHead> <TableHead className="text-right">{t('admin.topUsers.totalTasks')}</TableHead>
<TableHead className="text-right"></TableHead> <TableHead className="text-right">{t('admin.topUsers.completedTasks')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -377,26 +379,26 @@ export default function AdminDashboardPage() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Users className="w-5 h-5" /> <Users className="w-5 h-5" />
{t('admin.recentUsers.title')}
</CardTitle> </CardTitle>
<CardDescription></CardDescription> <CardDescription>{t('admin.recentUsers.description')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{users.length === 0 ? ( {users.length === 0 ? (
<div className="text-center py-8 text-gray-500"> <div className="text-center py-8 text-gray-500">
<Users className="w-12 h-12 mx-auto mb-4 opacity-50" /> <Users className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p></p> <p>{t('admin.recentUsers.noUsers')}</p>
</div> </div>
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Email</TableHead> <TableHead>Email</TableHead>
<TableHead></TableHead> <TableHead>{t('admin.recentUsers.displayName')}</TableHead>
<TableHead></TableHead> <TableHead>{t('admin.recentUsers.registeredAt')}</TableHead>
<TableHead></TableHead> <TableHead>{t('admin.recentUsers.lastLogin')}</TableHead>
<TableHead></TableHead> <TableHead>{t('admin.recentUsers.status')}</TableHead>
<TableHead className="text-right"></TableHead> <TableHead className="text-right">{t('admin.recentUsers.taskCount')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -412,14 +414,14 @@ export default function AdminDashboardPage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={user.is_active ? 'default' : 'secondary'}> <Badge variant={user.is_active ? 'default' : 'secondary'}>
{user.is_active ? '活躍' : '停用'} {user.is_active ? t('common.active') : t('common.inactive')}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div> <div>
<div className="font-semibold">{user.task_count}</div> <div className="font-semibold">{user.task_count}</div>
<div className="text-xs text-gray-500"> <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>
</div> </div>
</TableCell> </TableCell>

View File

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

View File

@@ -124,8 +124,8 @@ function SingleTaskProcessing() {
updateTaskStatus(taskId, 'processing', forceTrack || undefined) updateTaskStatus(taskId, 'processing', forceTrack || undefined)
} }
toast({ toast({
title: '開始處理', title: t('processing.startProcessing'),
description: 'OCR 處理已開始', description: t('processing.ocrStarted'),
variant: 'success', variant: 'success',
}) })
}, },
@@ -200,7 +200,7 @@ function SingleTaskProcessing() {
<div className="flex items-center justify-center min-h-[60vh]"> <div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center"> <div className="text-center">
<Loader2 className="w-12 h-12 animate-spin text-primary mx-auto mb-4" /> <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>
</div> </div>
) )
@@ -226,13 +226,13 @@ function SingleTaskProcessing() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t('processing.noBatchMessage', { defaultValue: '尚未選擇任何任務。請先上傳檔案以建立任務。' })} {t('processing.noBatchMessage')}
</p> </p>
<Button <Button
onClick={() => navigate('/upload')} onClick={() => navigate('/upload')}
size="lg" size="lg"
> >
{t('processing.goToUpload', { defaultValue: '前往上傳頁面' })} {t('processing.goToUpload')}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -252,7 +252,7 @@ function SingleTaskProcessing() {
<div> <div>
<h1 className="page-title">{t('processing.title')}</h1> <h1 className="page-title">{t('processing.title')}</h1>
<p className="text-muted-foreground mt-1"> <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}`} {taskDetail?.filename && ` · ${taskDetail.filename}`}
</p> </p>
</div> </div>
@@ -260,13 +260,13 @@ function SingleTaskProcessing() {
{isCompleted && ( {isCompleted && (
<div className="flex items-center gap-2 text-success"> <div className="flex items-center gap-2 text-success">
<CheckCircle className="w-6 h-6" /> <CheckCircle className="w-6 h-6" />
<span className="font-semibold"></span> <span className="font-semibold">{t('processing.completed')}</span>
</div> </div>
)} )}
{isProcessing && ( {isProcessing && (
<div className="flex items-center gap-2 text-primary"> <div className="flex items-center gap-2 text-primary">
<Loader2 className="w-6 h-6 animate-spin" /> <Loader2 className="w-6 h-6 animate-spin" />
<span className="font-semibold"></span> <span className="font-semibold">{t('processing.processing')}</span>
</div> </div>
)} )}
</div> </div>
@@ -311,9 +311,9 @@ function SingleTaskProcessing() {
<FileText className="w-5 h-5 text-primary" /> <FileText className="w-5 h-5 text-primary" />
</div> </div>
<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"> <p className="text-sm font-medium text-foreground truncate">
{taskDetail.filename || '未知檔案'} {taskDetail.filename || t('common.unknownFile')}
</p> </p>
</div> </div>
</div> </div>
@@ -325,7 +325,7 @@ function SingleTaskProcessing() {
<Clock className="w-5 h-5 text-success" /> <Clock className="w-5 h-5 text-success" />
</div> </div>
<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"> <p className="text-sm font-medium text-foreground">
{(taskDetail.processing_time_ms / 1000).toFixed(2)}s {(taskDetail.processing_time_ms / 1000).toFixed(2)}s
</p> </p>
@@ -342,7 +342,7 @@ function SingleTaskProcessing() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" /> <AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
<div> <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> <p className="text-sm text-destructive/80">{taskDetail.error_message}</p>
</div> </div>
</div> </div>
@@ -380,7 +380,7 @@ function SingleTaskProcessing() {
size="lg" size="lg"
> >
<CheckCircle className="w-4 h-4" /> <CheckCircle className="w-4 h-4" />
{t('results.viewTaskHistory')}
</Button> </Button>
)} )}
</div> </div>
@@ -396,24 +396,24 @@ function SingleTaskProcessing() {
<div className="p-2 bg-primary/10 rounded-lg"> <div className="p-2 bg-primary/10 rounded-lg">
<FileText className="w-5 h-5 text-primary" /> <FileText className="w-5 h-5 text-primary" />
</div> </div>
<CardTitle></CardTitle> <CardTitle>{t('taskDetail.title')}</CardTitle>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between py-2 border-b border-border"> <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)} {getStatusBadge(taskDetail.status)}
</div> </div>
<div className="flex justify-between py-2 border-b border-border"> <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"> <span className="text-sm font-medium">
{new Date(taskDetail.created_at).toLocaleString('zh-TW')} {new Date(taskDetail.created_at).toLocaleString('zh-TW')}
</span> </span>
</div> </div>
{taskDetail.updated_at && ( {taskDetail.updated_at && (
<div className="flex justify-between py-2 border-b border-border"> <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"> <span className="text-sm font-medium">
{new Date(taskDetail.updated_at).toLocaleString('zh-TW')} {new Date(taskDetail.updated_at).toLocaleString('zh-TW')}
</span> </span>
@@ -421,7 +421,7 @@ function SingleTaskProcessing() {
)} )}
{taskDetail.completed_at && ( {taskDetail.completed_at && (
<div className="flex justify-between py-2 border-b border-border"> <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"> <span className="text-sm font-medium">
{new Date(taskDetail.completed_at).toLocaleString('zh-TW')} {new Date(taskDetail.completed_at).toLocaleString('zh-TW')}
</span> </span>
@@ -439,7 +439,7 @@ function SingleTaskProcessing() {
{isAnalyzing && ( {isAnalyzing && (
<div className="flex items-center gap-2 p-4 bg-muted/30 rounded-lg border"> <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" /> <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> </div>
)} )}

View File

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

View File

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

View File

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

View File

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