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:
@@ -5,6 +5,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { apiClientV2 } from '@/services/apiV2'
|
||||
import type { SystemStats, UserWithStats, TopUser, TranslationStats } from '@/types/apiV2'
|
||||
import {
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [stats, setStats] = useState<SystemStats | null>(null)
|
||||
const [users, setUsers] = useState<UserWithStats[]>([])
|
||||
@@ -61,7 +63,7 @@ export default function AdminDashboardPage() {
|
||||
setTranslationStats(translationStatsData)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch admin data:', err)
|
||||
setError(err.response?.data?.detail || '載入管理員資料失敗')
|
||||
setError(err.response?.data?.detail || t('admin.loadFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -71,11 +73,11 @@ export default function AdminDashboardPage() {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
// Format date
|
||||
// Format date based on current locale
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-TW')
|
||||
return date.toLocaleString(i18n.language === 'zh-TW' ? 'zh-TW' : 'en-US')
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
@@ -83,7 +85,7 @@ export default function AdminDashboardPage() {
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-blue-600 mx-auto mb-4" />
|
||||
<p className="text-gray-600">載入管理員儀表板...</p>
|
||||
<p className="text-gray-600">{t('admin.loadingDashboard')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -95,7 +97,7 @@ export default function AdminDashboardPage() {
|
||||
<div className="flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<XCircle className="w-5 h-5 text-red-600" />
|
||||
<div>
|
||||
<p className="text-red-600 font-semibold">載入失敗</p>
|
||||
<p className="text-red-600 font-semibold">{t('errors.loadFailed')}</p>
|
||||
<p className="text-red-500 text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,18 +112,18 @@ export default function AdminDashboardPage() {
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-8 h-8 text-blue-600" />
|
||||
<h1 className="text-3xl font-bold text-gray-900">管理員儀表板</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{t('admin.title')}</h1>
|
||||
</div>
|
||||
<p className="text-gray-600 mt-1">系統統計與用戶管理</p>
|
||||
<p className="text-gray-600 mt-1">{t('admin.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => navigate('/admin/audit-logs')} variant="outline">
|
||||
<Activity className="w-4 h-4 mr-2" />
|
||||
審計日誌
|
||||
{t('admin.auditLogs')}
|
||||
</Button>
|
||||
<Button onClick={fetchData} variant="outline">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,13 +135,13 @@ export default function AdminDashboardPage() {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
總用戶數
|
||||
{t('admin.totalUsers')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.total_users}</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
活躍: {stats.active_users}
|
||||
{t('admin.activeUsers')}: {stats.active_users}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -148,7 +150,7 @@ export default function AdminDashboardPage() {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||
<ClipboardList className="w-4 h-4" />
|
||||
總任務數
|
||||
{t('admin.totalTasks')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -160,7 +162,7 @@ export default function AdminDashboardPage() {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
待處理
|
||||
{t('admin.pendingTasks')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -174,7 +176,7 @@ export default function AdminDashboardPage() {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4" />
|
||||
處理中
|
||||
{t('admin.processingTasks')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -188,7 +190,7 @@ export default function AdminDashboardPage() {
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
已完成
|
||||
{t('admin.completedTasks')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -196,7 +198,7 @@ export default function AdminDashboardPage() {
|
||||
{stats.task_stats.completed}
|
||||
</div>
|
||||
<p className="text-xs text-red-600 mt-1">
|
||||
失敗: {stats.task_stats.failed}
|
||||
{t('admin.failedTasks')}: {stats.task_stats.failed}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -209,42 +211,42 @@ export default function AdminDashboardPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Languages className="w-5 h-5" />
|
||||
翻譯統計
|
||||
{t('admin.translationStats.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>翻譯 API 使用量與計費追蹤</CardDescription>
|
||||
<CardDescription>{t('admin.translationStats.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="p-4 bg-blue-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-blue-600 mb-1">
|
||||
<Languages className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">總翻譯次數</span>
|
||||
<span className="text-sm font-medium">{t('admin.translationStats.totalTranslations')}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-blue-700">
|
||||
{translationStats.total_translations.toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-blue-500 mt-1">
|
||||
近30天: {translationStats.last_30_days.count}
|
||||
{t('admin.translationStats.last30Days')}: {translationStats.last_30_days.count}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-purple-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-purple-600 mb-1">
|
||||
<Activity className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">總 Token 數</span>
|
||||
<span className="text-sm font-medium">{t('admin.translationStats.totalTokens')}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-purple-700">
|
||||
{translationStats.total_tokens.toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-purple-500 mt-1">
|
||||
近30天: {translationStats.last_30_days.tokens.toLocaleString()}
|
||||
{t('admin.translationStats.last30Days')}: {translationStats.last_30_days.tokens.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-green-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-green-600 mb-1">
|
||||
<ClipboardList className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">總字元數</span>
|
||||
<span className="text-sm font-medium">{t('admin.translationStats.totalCharacters')}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-green-700">
|
||||
{translationStats.total_characters.toLocaleString()}
|
||||
@@ -254,7 +256,7 @@ export default function AdminDashboardPage() {
|
||||
<div className="p-4 bg-amber-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-amber-600 mb-1">
|
||||
<Coins className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">預估成本</span>
|
||||
<span className="text-sm font-medium">{t('admin.translationStats.estimatedCost')}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-amber-700">
|
||||
${translationStats.estimated_cost.toFixed(2)}
|
||||
@@ -266,11 +268,11 @@ export default function AdminDashboardPage() {
|
||||
{/* Language Breakdown */}
|
||||
{translationStats.by_language.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">語言分佈</h4>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">{t('admin.translationStats.languageBreakdown')}</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{translationStats.by_language.map((lang) => (
|
||||
<Badge key={lang.language} variant="outline" className="px-3 py-1">
|
||||
{lang.language}: {lang.count} 次 ({lang.tokens.toLocaleString()} tokens)
|
||||
{lang.language}: {lang.count} {t('admin.translationStats.count')} ({lang.tokens.toLocaleString()} {t('admin.translationStats.tokens')})
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -280,42 +282,42 @@ export default function AdminDashboardPage() {
|
||||
{/* Recent Translations */}
|
||||
{translationStats.recent_translations.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">最近翻譯記錄</h4>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">{t('admin.translationStats.recentTranslations')}</h4>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>任務 ID</TableHead>
|
||||
<TableHead>目標語言</TableHead>
|
||||
<TableHead className="text-right">Token 數</TableHead>
|
||||
<TableHead className="text-right">字元數</TableHead>
|
||||
<TableHead className="text-right">成本</TableHead>
|
||||
<TableHead className="text-right">處理時間</TableHead>
|
||||
<TableHead>時間</TableHead>
|
||||
<TableHead>{t('admin.table.taskId')}</TableHead>
|
||||
<TableHead>{t('admin.table.targetLang')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.table.tokenCount')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.table.charCount')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.table.cost')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.table.processingTime')}</TableHead>
|
||||
<TableHead>{t('admin.table.time')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{translationStats.recent_translations.slice(0, 10).map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
{translationStats.recent_translations.slice(0, 10).map((tr) => (
|
||||
<TableRow key={tr.id}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{t.task_id.substring(0, 8)}...
|
||||
{tr.task_id.substring(0, 8)}...
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{t.target_lang}</Badge>
|
||||
<Badge variant="secondary">{tr.target_lang}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{t.total_tokens.toLocaleString()}
|
||||
{tr.total_tokens.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{t.total_characters.toLocaleString()}
|
||||
{tr.total_characters.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium text-amber-600">
|
||||
${t.estimated_cost.toFixed(4)}
|
||||
${tr.estimated_cost.toFixed(4)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{t.processing_time_seconds.toFixed(1)}s
|
||||
{tr.processing_time_seconds.toFixed(1)}s
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-600">
|
||||
{new Date(t.created_at).toLocaleString('zh-TW')}
|
||||
{formatDate(tr.created_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -333,9 +335,9 @@ export default function AdminDashboardPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5" />
|
||||
活躍用戶排行
|
||||
{t('admin.topUsers.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>任務數量最多的用戶</CardDescription>
|
||||
<CardDescription>{t('admin.topUsers.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
@@ -343,9 +345,9 @@ export default function AdminDashboardPage() {
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>顯示名稱</TableHead>
|
||||
<TableHead className="text-right">總任務</TableHead>
|
||||
<TableHead className="text-right">已完成</TableHead>
|
||||
<TableHead>{t('admin.topUsers.displayName')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.topUsers.totalTasks')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.topUsers.completedTasks')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -377,26 +379,26 @@ export default function AdminDashboardPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5" />
|
||||
最近用戶
|
||||
{t('admin.recentUsers.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>最新註冊的用戶列表</CardDescription>
|
||||
<CardDescription>{t('admin.recentUsers.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{users.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Users className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>暫無用戶</p>
|
||||
<p>{t('admin.recentUsers.noUsers')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>顯示名稱</TableHead>
|
||||
<TableHead>註冊時間</TableHead>
|
||||
<TableHead>最後登入</TableHead>
|
||||
<TableHead>狀態</TableHead>
|
||||
<TableHead className="text-right">任務數</TableHead>
|
||||
<TableHead>{t('admin.recentUsers.displayName')}</TableHead>
|
||||
<TableHead>{t('admin.recentUsers.registeredAt')}</TableHead>
|
||||
<TableHead>{t('admin.recentUsers.lastLogin')}</TableHead>
|
||||
<TableHead>{t('admin.recentUsers.status')}</TableHead>
|
||||
<TableHead className="text-right">{t('admin.recentUsers.taskCount')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -412,14 +414,14 @@ export default function AdminDashboardPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={user.is_active ? 'default' : 'secondary'}>
|
||||
{user.is_active ? '活躍' : '停用'}
|
||||
{user.is_active ? t('common.active') : t('common.inactive')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div>
|
||||
<div className="font-semibold">{user.task_count}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
完成: {user.completed_tasks} | 失敗: {user.failed_tasks}
|
||||
{t('admin.recentUsers.completedCount')}: {user.completed_tasks} | {t('admin.recentUsers.failedCount')}: {user.failed_tasks}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { apiClientV2 } from '@/services/apiV2'
|
||||
import type { AuditLog } from '@/types/apiV2'
|
||||
import {
|
||||
@@ -33,6 +34,7 @@ import { NativeSelect } from '@/components/ui/select'
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
const navigate = useNavigate()
|
||||
const { t, i18n } = useTranslation()
|
||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@@ -71,7 +73,7 @@ export default function AuditLogsPage() {
|
||||
setHasMore(response.has_more)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch audit logs:', err)
|
||||
setError(err.response?.data?.detail || '載入審計日誌失敗')
|
||||
setError(err.response?.data?.detail || t('auditLogs.loadFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -89,22 +91,24 @@ export default function AuditLogsPage() {
|
||||
// Format date
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-TW')
|
||||
return date.toLocaleString(i18n.language === 'en' ? 'en-US' : 'zh-TW')
|
||||
}
|
||||
|
||||
// Get category badge
|
||||
const getCategoryBadge = (category: string) => {
|
||||
const variants: Record<string, { variant: any; label: string }> = {
|
||||
auth: { variant: 'default', label: '認證' },
|
||||
task: { variant: 'secondary', label: '任務' },
|
||||
file: { variant: 'secondary', label: '檔案' },
|
||||
admin: { variant: 'destructive', label: '管理' },
|
||||
system: { variant: 'secondary', label: '系統' },
|
||||
const variants: Record<string, { variant: any; labelKey: string }> = {
|
||||
auth: { variant: 'default', labelKey: 'auditLogs.category.auth' },
|
||||
task: { variant: 'secondary', labelKey: 'auditLogs.category.task' },
|
||||
file: { variant: 'secondary', labelKey: 'auditLogs.category.file' },
|
||||
admin: { variant: 'destructive', labelKey: 'auditLogs.category.admin' },
|
||||
system: { variant: 'secondary', labelKey: 'auditLogs.category.system' },
|
||||
}
|
||||
|
||||
const config = variants[category] || { variant: 'secondary', label: category }
|
||||
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>
|
||||
const config = variants[category]
|
||||
if (config) {
|
||||
return <Badge variant={config.variant}>{t(config.labelKey)}</Badge>
|
||||
}
|
||||
return <Badge variant="secondary">{category}</Badge>
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -120,16 +124,16 @@ export default function AuditLogsPage() {
|
||||
className="mr-2"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
返回
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<Shield className="w-8 h-8 text-blue-600" />
|
||||
<h1 className="text-3xl font-bold text-gray-900">審計日誌</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{t('auditLogs.title')}</h1>
|
||||
</div>
|
||||
<p className="text-gray-600 mt-1">系統操作記錄與審計追蹤</p>
|
||||
<p className="text-gray-600 mt-1">{t('auditLogs.subtitle')}</p>
|
||||
</div>
|
||||
<Button onClick={() => fetchLogs()} variant="outline">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -138,13 +142,13 @@ export default function AuditLogsPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Filter className="w-5 h-5" />
|
||||
篩選條件
|
||||
{t('auditLogs.filterConditions')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">類別</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('auditLogs.categoryFilter')}</label>
|
||||
<NativeSelect
|
||||
value={categoryFilter}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
@@ -152,18 +156,18 @@ export default function AuditLogsPage() {
|
||||
handleFilterChange()
|
||||
}}
|
||||
options={[
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'auth', label: '認證' },
|
||||
{ value: 'task', label: '任務' },
|
||||
{ value: 'file', label: '檔案' },
|
||||
{ value: 'admin', label: '管理' },
|
||||
{ value: 'system', label: '系統' },
|
||||
{ value: 'all', label: t('auditLogs.allCategories') },
|
||||
{ value: 'auth', label: t('auditLogs.category.auth') },
|
||||
{ value: 'task', label: t('auditLogs.category.task') },
|
||||
{ value: 'file', label: t('auditLogs.category.file') },
|
||||
{ value: 'admin', label: t('auditLogs.category.admin') },
|
||||
{ value: 'system', label: t('auditLogs.category.system') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">狀態</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('auditLogs.statusFilter')}</label>
|
||||
<NativeSelect
|
||||
value={successFilter}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
@@ -171,9 +175,9 @@ export default function AuditLogsPage() {
|
||||
handleFilterChange()
|
||||
}}
|
||||
options={[
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'true', label: '成功' },
|
||||
{ value: 'false', label: '失敗' },
|
||||
{ value: 'all', label: t('auditLogs.allStatuses') },
|
||||
{ value: 'true', label: t('auditLogs.status.success') },
|
||||
{ value: 'false', label: t('auditLogs.status.failed') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -190,7 +194,7 @@ export default function AuditLogsPage() {
|
||||
handleFilterChange()
|
||||
}}
|
||||
>
|
||||
清除篩選
|
||||
{t('auditLogs.clearFilter')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -208,9 +212,9 @@ export default function AuditLogsPage() {
|
||||
{/* Audit Logs List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">審計日誌記錄</CardTitle>
|
||||
<CardTitle className="text-lg">{t('auditLogs.logList')}</CardTitle>
|
||||
<CardDescription>
|
||||
共 {total} 筆記錄 {hasMore && `(顯示第 ${page} 頁)`}
|
||||
{t('auditLogs.logCountInfo', { total, page })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -221,20 +225,20 @@ export default function AuditLogsPage() {
|
||||
) : logs.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>暫無審計日誌</p>
|
||||
<p>{t('auditLogs.noLogs')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>時間</TableHead>
|
||||
<TableHead>用戶</TableHead>
|
||||
<TableHead>類別</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
<TableHead>資源</TableHead>
|
||||
<TableHead>狀態</TableHead>
|
||||
<TableHead>錯誤訊息</TableHead>
|
||||
<TableHead>{t('auditLogs.table.time')}</TableHead>
|
||||
<TableHead>{t('auditLogs.table.user')}</TableHead>
|
||||
<TableHead>{t('auditLogs.table.category')}</TableHead>
|
||||
<TableHead>{t('auditLogs.table.action')}</TableHead>
|
||||
<TableHead>{t('auditLogs.table.resource')}</TableHead>
|
||||
<TableHead>{t('auditLogs.table.status')}</TableHead>
|
||||
<TableHead>{t('auditLogs.table.errorMessage')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -269,12 +273,12 @@ export default function AuditLogsPage() {
|
||||
{log.success ? (
|
||||
<Badge variant="default" className="flex items-center gap-1 w-fit">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
成功
|
||||
{t('auditLogs.status.success')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="flex items-center gap-1 w-fit">
|
||||
<XCircle className="w-3 h-3" />
|
||||
失敗
|
||||
{t('auditLogs.status.failed')}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
@@ -293,8 +297,11 @@ export default function AuditLogsPage() {
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
顯示 {(page - 1) * pageSize + 1} - {Math.min(page * pageSize, total)} / 共{' '}
|
||||
{total} 筆
|
||||
{t('auditLogs.pagination.showing', {
|
||||
start: (page - 1) * pageSize + 1,
|
||||
end: Math.min(page * pageSize, total),
|
||||
total,
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -303,7 +310,7 @@ export default function AuditLogsPage() {
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
上一頁
|
||||
{t('auditLogs.pagination.previous')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -311,7 +318,7 @@ export default function AuditLogsPage() {
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={!hasMore}
|
||||
>
|
||||
下一頁
|
||||
{t('auditLogs.pagination.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,8 +124,8 @@ function SingleTaskProcessing() {
|
||||
updateTaskStatus(taskId, 'processing', forceTrack || undefined)
|
||||
}
|
||||
toast({
|
||||
title: '開始處理',
|
||||
description: 'OCR 處理已開始',
|
||||
title: t('processing.startProcessing'),
|
||||
description: t('processing.ocrStarted'),
|
||||
variant: 'success',
|
||||
})
|
||||
},
|
||||
@@ -200,7 +200,7 @@ function SingleTaskProcessing() {
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-primary mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">載入任務資訊...</p>
|
||||
<p className="text-muted-foreground">{t('processing.loadingTask')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -226,13 +226,13 @@ function SingleTaskProcessing() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
{t('processing.noBatchMessage', { defaultValue: '尚未選擇任何任務。請先上傳檔案以建立任務。' })}
|
||||
{t('processing.noBatchMessage')}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => navigate('/upload')}
|
||||
size="lg"
|
||||
>
|
||||
{t('processing.goToUpload', { defaultValue: '前往上傳頁面' })}
|
||||
{t('processing.goToUpload')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -252,7 +252,7 @@ function SingleTaskProcessing() {
|
||||
<div>
|
||||
<h1 className="page-title">{t('processing.title')}</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
任務 ID: <span className="font-mono text-primary">{taskId}</span>
|
||||
{t('taskDetail.taskId', { id: taskId })}
|
||||
{taskDetail?.filename && ` · ${taskDetail.filename}`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -260,13 +260,13 @@ function SingleTaskProcessing() {
|
||||
{isCompleted && (
|
||||
<div className="flex items-center gap-2 text-success">
|
||||
<CheckCircle className="w-6 h-6" />
|
||||
<span className="font-semibold">處理完成</span>
|
||||
<span className="font-semibold">{t('processing.completed')}</span>
|
||||
</div>
|
||||
)}
|
||||
{isProcessing && (
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
<span className="font-semibold">處理中</span>
|
||||
<span className="font-semibold">{t('processing.processing')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -311,9 +311,9 @@ function SingleTaskProcessing() {
|
||||
<FileText className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-0.5">檔案名稱</p>
|
||||
<p className="text-xs text-muted-foreground mb-0.5">{t('taskDetail.filename')}</p>
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{taskDetail.filename || '未知檔案'}
|
||||
{taskDetail.filename || t('common.unknownFile')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -325,7 +325,7 @@ function SingleTaskProcessing() {
|
||||
<Clock className="w-5 h-5 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-0.5">處理時間</p>
|
||||
<p className="text-xs text-muted-foreground mb-0.5">{t('taskDetail.processingTime')}</p>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{(taskDetail.processing_time_ms / 1000).toFixed(2)}s
|
||||
</p>
|
||||
@@ -342,7 +342,7 @@ function SingleTaskProcessing() {
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-destructive mb-1">處理失敗</p>
|
||||
<p className="text-sm font-medium text-destructive mb-1">{t('processing.failed')}</p>
|
||||
<p className="text-sm text-destructive/80">{taskDetail.error_message}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -380,7 +380,7 @@ function SingleTaskProcessing() {
|
||||
size="lg"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
查看任務歷史
|
||||
{t('results.viewTaskHistory')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -396,24 +396,24 @@ function SingleTaskProcessing() {
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<FileText className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<CardTitle>任務詳情</CardTitle>
|
||||
<CardTitle>{t('taskDetail.title')}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between py-2 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground">任務狀態</span>
|
||||
<span className="text-sm text-muted-foreground">{t('taskDetail.taskStatus')}</span>
|
||||
{getStatusBadge(taskDetail.status)}
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground">建立時間</span>
|
||||
<span className="text-sm text-muted-foreground">{t('taskDetail.createdAt')}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{new Date(taskDetail.created_at).toLocaleString('zh-TW')}
|
||||
</span>
|
||||
</div>
|
||||
{taskDetail.updated_at && (
|
||||
<div className="flex justify-between py-2 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground">更新時間</span>
|
||||
<span className="text-sm text-muted-foreground">{t('taskDetail.lastUpdated')}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{new Date(taskDetail.updated_at).toLocaleString('zh-TW')}
|
||||
</span>
|
||||
@@ -421,7 +421,7 @@ function SingleTaskProcessing() {
|
||||
)}
|
||||
{taskDetail.completed_at && (
|
||||
<div className="flex justify-between py-2 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground">完成時間</span>
|
||||
<span className="text-sm text-muted-foreground">{t('taskDetail.completedAt')}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{new Date(taskDetail.completed_at).toLocaleString('zh-TW')}
|
||||
</span>
|
||||
@@ -439,7 +439,7 @@ function SingleTaskProcessing() {
|
||||
{isAnalyzing && (
|
||||
<div className="flex items-center gap-2 p-4 bg-muted/30 rounded-lg border">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||
<span className="text-sm text-muted-foreground">分析文件類型中...</span>
|
||||
<span className="text-sm text-muted-foreground">{t('processing.analyzingDocument')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function ResultsPage() {
|
||||
await apiClientV2.downloadPDF(taskId)
|
||||
toast({
|
||||
title: t('export.exportSuccess'),
|
||||
description: 'PDF 已下載',
|
||||
description: t('results.pdfDownloaded'),
|
||||
variant: 'success',
|
||||
})
|
||||
} catch (error: any) {
|
||||
@@ -65,7 +65,7 @@ export default function ResultsPage() {
|
||||
await apiClientV2.downloadMarkdown(taskId)
|
||||
toast({
|
||||
title: t('export.exportSuccess'),
|
||||
description: 'Markdown 已下載',
|
||||
description: t('results.markdownDownloaded'),
|
||||
variant: 'success',
|
||||
})
|
||||
} catch (error: any) {
|
||||
@@ -83,7 +83,7 @@ export default function ResultsPage() {
|
||||
await apiClientV2.downloadJSON(taskId)
|
||||
toast({
|
||||
title: t('export.exportSuccess'),
|
||||
description: 'JSON 已下載',
|
||||
description: t('results.jsonDownloaded'),
|
||||
variant: 'success',
|
||||
})
|
||||
} catch (error: any) {
|
||||
@@ -98,13 +98,13 @@ export default function ResultsPage() {
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <Badge variant="default" className="bg-green-600">已完成</Badge>
|
||||
return <Badge variant="default" className="bg-green-600">{t('taskDetail.status.completed')}</Badge>
|
||||
case 'processing':
|
||||
return <Badge variant="default">處理中</Badge>
|
||||
return <Badge variant="default">{t('taskDetail.status.processing')}</Badge>
|
||||
case 'failed':
|
||||
return <Badge variant="destructive">失敗</Badge>
|
||||
return <Badge variant="destructive">{t('taskDetail.status.failed')}</Badge>
|
||||
default:
|
||||
return <Badge variant="secondary">待處理</Badge>
|
||||
return <Badge variant="secondary">{t('taskDetail.status.pending')}</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function ResultsPage() {
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-primary mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">載入任務結果...</p>
|
||||
<p className="text-muted-foreground">{t('results.loadingResults')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -140,10 +140,10 @@ export default function ResultsPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
{t('results.noBatchMessage', { defaultValue: '尚未選擇任何任務。請先上傳並處理檔案。' })}
|
||||
{t('results.noBatchMessage')}
|
||||
</p>
|
||||
<Button onClick={() => navigate('/upload')} size="lg">
|
||||
{t('results.goToUpload', { defaultValue: '前往上傳頁面' })}
|
||||
{t('results.goToUpload')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -157,11 +157,11 @@ export default function ResultsPage() {
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Card className="max-w-md text-center">
|
||||
<CardHeader>
|
||||
<CardTitle>任務不存在</CardTitle>
|
||||
<CardTitle>{t('taskDetail.taskNotFound')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={() => navigate('/tasks')}>
|
||||
查看任務歷史
|
||||
{t('results.viewTaskHistory')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -179,7 +179,7 @@ export default function ResultsPage() {
|
||||
<div>
|
||||
<h1 className="page-title">{t('results.title')}</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
任務 ID: <span className="font-mono text-primary">{taskId}</span>
|
||||
{t('taskDetail.taskId', { id: taskId })}
|
||||
{taskDetail.filename && ` · ${taskDetail.filename}`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -215,7 +215,7 @@ export default function ResultsPage() {
|
||||
<Clock className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">處理時間</p>
|
||||
<p className="text-sm text-muted-foreground">{t('results.processingTime')}</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{taskDetail.processing_time_ms ? (taskDetail.processing_time_ms / 1000).toFixed(2) : '0'}s
|
||||
</p>
|
||||
@@ -231,8 +231,8 @@ export default function ResultsPage() {
|
||||
<TrendingUp className="w-6 h-6 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">處理狀態</p>
|
||||
<p className="text-2xl font-bold text-success">成功</p>
|
||||
<p className="text-sm text-muted-foreground">{t('results.processingStatus')}</p>
|
||||
<p className="text-2xl font-bold text-success">{t('common.success')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -245,7 +245,7 @@ export default function ResultsPage() {
|
||||
<Layers className="w-6 h-6 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">任務類型</p>
|
||||
<p className="text-sm text-muted-foreground">{t('results.taskType')}</p>
|
||||
<p className="text-2xl font-bold">OCR</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -257,7 +257,7 @@ export default function ResultsPage() {
|
||||
{/* Results Preview */}
|
||||
{isCompleted ? (
|
||||
<PDFViewer
|
||||
title={`OCR 結果預覽 - ${taskDetail.filename || '未知檔案'}`}
|
||||
title={t('taskDetail.ocrPreview', { filename: taskDetail.filename || t('common.unknownFile') })}
|
||||
pdfUrl={pdfUrl}
|
||||
httpHeaders={pdfHttpHeaders}
|
||||
/>
|
||||
@@ -265,15 +265,15 @@ export default function ResultsPage() {
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Loader2 className="w-16 h-16 animate-spin text-primary mx-auto mb-4" />
|
||||
<p className="text-lg font-semibold">正在處理中...</p>
|
||||
<p className="text-muted-foreground mt-2">請稍候,OCR 處理需要一些時間</p>
|
||||
<p className="text-lg font-semibold">{t('results.processingInProgress')}</p>
|
||||
<p className="text-muted-foreground mt-2">{t('results.processingInProgressDesc')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : taskDetail.status === 'failed' ? (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<AlertCircle className="w-16 h-16 text-destructive mx-auto mb-4" />
|
||||
<p className="text-lg font-semibold text-destructive">處理失敗</p>
|
||||
<p className="text-lg font-semibold text-destructive">{t('processing.failed')}</p>
|
||||
{taskDetail.error_message && (
|
||||
<p className="text-muted-foreground mt-2">{taskDetail.error_message}</p>
|
||||
)}
|
||||
@@ -283,10 +283,10 @@ export default function ResultsPage() {
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Clock className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-lg font-semibold">等待處理</p>
|
||||
<p className="text-muted-foreground mt-2">請前往處理頁面啟動 OCR 處理</p>
|
||||
<p className="text-lg font-semibold">{t('results.waitingToProcess')}</p>
|
||||
<p className="text-muted-foreground mt-2">{t('results.waitingToProcessDesc')}</p>
|
||||
<Button onClick={() => navigate('/processing')} className="mt-4">
|
||||
前往處理頁面
|
||||
{t('results.goToProcessing')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -56,7 +56,7 @@ const LANGUAGE_OPTIONS = [
|
||||
|
||||
export default function TaskDetailPage() {
|
||||
const { taskId } = useParams<{ taskId: string }>()
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { toast } = useToast()
|
||||
|
||||
@@ -124,16 +124,16 @@ export default function TaskDetailPage() {
|
||||
setIsTranslating(false)
|
||||
setTranslationProgress(100)
|
||||
toast({
|
||||
title: '翻譯完成',
|
||||
description: `文件已翻譯為 ${LANGUAGE_OPTIONS.find(l => l.value === targetLang)?.label || targetLang}`,
|
||||
title: t('translation.translationComplete'),
|
||||
description: `${LANGUAGE_OPTIONS.find(l => l.value === targetLang)?.label || targetLang}`,
|
||||
variant: 'success',
|
||||
})
|
||||
refetchTranslations()
|
||||
} else if (status.status === 'failed') {
|
||||
setIsTranslating(false)
|
||||
toast({
|
||||
title: '翻譯失敗',
|
||||
description: status.error_message || '未知錯誤',
|
||||
title: t('translation.translationFailed'),
|
||||
description: status.error_message || t('common.unknownError'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
@@ -143,7 +143,7 @@ export default function TaskDetailPage() {
|
||||
}, 2000)
|
||||
|
||||
return () => clearInterval(pollInterval)
|
||||
}, [isTranslating, taskId, targetLang, toast, refetchTranslations])
|
||||
}, [isTranslating, taskId, targetLang, toast, refetchTranslations, t])
|
||||
|
||||
// Construct PDF URL for preview - memoize to prevent unnecessary reloads
|
||||
// Must be called unconditionally before any early returns (React hooks rule)
|
||||
@@ -162,24 +162,24 @@ export default function TaskDetailPage() {
|
||||
if (!track) return null
|
||||
switch (track) {
|
||||
case 'direct':
|
||||
return <Badge variant="default" className="bg-blue-600">直接提取</Badge>
|
||||
return <Badge variant="default" className="bg-blue-600">{t('taskDetail.track.direct')}</Badge>
|
||||
case 'ocr':
|
||||
return <Badge variant="default" className="bg-purple-600">OCR</Badge>
|
||||
case 'hybrid':
|
||||
return <Badge variant="default" className="bg-orange-600">混合</Badge>
|
||||
return <Badge variant="default" className="bg-orange-600">{t('taskDetail.track.hybrid')}</Badge>
|
||||
default:
|
||||
return <Badge variant="secondary">自動</Badge>
|
||||
return <Badge variant="secondary">{t('taskDetail.track.auto')}</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
const getTrackDescription = (track?: ProcessingTrack) => {
|
||||
switch (track) {
|
||||
case 'direct':
|
||||
return 'PyMuPDF 直接提取'
|
||||
return t('taskDetail.track.directDesc')
|
||||
case 'ocr':
|
||||
return 'PP-StructureV3 OCR'
|
||||
case 'hybrid':
|
||||
return '混合處理'
|
||||
return t('taskDetail.track.hybridDesc')
|
||||
default:
|
||||
return 'OCR'
|
||||
}
|
||||
@@ -191,7 +191,7 @@ export default function TaskDetailPage() {
|
||||
await apiClientV2.downloadPDF(taskId, 'layout')
|
||||
toast({
|
||||
title: t('export.exportSuccess'),
|
||||
description: '版面 PDF 已下載',
|
||||
description: t('taskDetail.layoutPdf'),
|
||||
variant: 'success',
|
||||
})
|
||||
} catch (error: any) {
|
||||
@@ -209,7 +209,7 @@ export default function TaskDetailPage() {
|
||||
await apiClientV2.downloadPDF(taskId, 'reflow')
|
||||
toast({
|
||||
title: t('export.exportSuccess'),
|
||||
description: '流式 PDF 已下載',
|
||||
description: t('taskDetail.reflowPdf'),
|
||||
variant: 'success',
|
||||
})
|
||||
} catch (error: any) {
|
||||
@@ -239,7 +239,7 @@ export default function TaskDetailPage() {
|
||||
setIsTranslating(false)
|
||||
setTranslationProgress(100)
|
||||
toast({
|
||||
title: '翻譯已存在',
|
||||
title: t('translation.translationExists'),
|
||||
description: response.message,
|
||||
variant: 'success',
|
||||
})
|
||||
@@ -247,15 +247,15 @@ export default function TaskDetailPage() {
|
||||
} else {
|
||||
setTranslationStatus(response.status)
|
||||
toast({
|
||||
title: '開始翻譯',
|
||||
description: '翻譯任務已啟動,請稍候...',
|
||||
title: t('translation.translationStarted'),
|
||||
description: t('translation.translationStartedDesc'),
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
setIsTranslating(false)
|
||||
setTranslationStatus(null)
|
||||
toast({
|
||||
title: '啟動翻譯失敗',
|
||||
title: t('errors.startFailed'),
|
||||
description: error.response?.data?.detail || t('errors.networkError'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -267,14 +267,14 @@ export default function TaskDetailPage() {
|
||||
try {
|
||||
await apiClientV2.deleteTranslation(taskId, lang)
|
||||
toast({
|
||||
title: '刪除成功',
|
||||
description: `翻譯結果 (${lang}) 已刪除`,
|
||||
title: t('translation.deleteSuccess'),
|
||||
description: t('translation.translationDeleted', { lang }),
|
||||
variant: 'success',
|
||||
})
|
||||
refetchTranslations()
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: '刪除失敗',
|
||||
title: t('errors.deleteFailed'),
|
||||
description: error.response?.data?.detail || t('errors.networkError'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -285,15 +285,15 @@ export default function TaskDetailPage() {
|
||||
if (!taskId) return
|
||||
try {
|
||||
await apiClientV2.downloadTranslatedPdf(taskId, lang, format)
|
||||
const formatLabel = format === 'layout' ? '版面' : '流式'
|
||||
const formatLabel = format === 'layout' ? t('taskDetail.layoutPdf') : t('taskDetail.reflowPdf')
|
||||
toast({
|
||||
title: '下載成功',
|
||||
description: `翻譯 ${formatLabel} PDF (${lang}) 已下載`,
|
||||
title: t('common.downloadSuccess'),
|
||||
description: `${formatLabel} (${lang})`,
|
||||
variant: 'success',
|
||||
})
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: '下載失敗',
|
||||
title: t('common.downloadFailed'),
|
||||
description: error.response?.data?.detail || t('errors.networkError'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -305,13 +305,13 @@ export default function TaskDetailPage() {
|
||||
try {
|
||||
await apiClientV2.downloadVisualization(taskId)
|
||||
toast({
|
||||
title: '下載成功',
|
||||
description: '辨識結果圖片已下載',
|
||||
title: t('common.downloadSuccess'),
|
||||
description: t('taskDetail.visualizationDownloaded'),
|
||||
variant: 'success',
|
||||
})
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: '下載失敗',
|
||||
title: t('common.downloadFailed'),
|
||||
description: error.response?.data?.detail || t('errors.networkError'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -321,28 +321,28 @@ export default function TaskDetailPage() {
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <Badge variant="default" className="bg-green-600">已完成</Badge>
|
||||
return <Badge variant="default" className="bg-green-600">{t('taskDetail.status.completed')}</Badge>
|
||||
case 'processing':
|
||||
return <Badge variant="default">處理中</Badge>
|
||||
return <Badge variant="default">{t('taskDetail.status.processing')}</Badge>
|
||||
case 'failed':
|
||||
return <Badge variant="destructive">失敗</Badge>
|
||||
return <Badge variant="destructive">{t('taskDetail.status.failed')}</Badge>
|
||||
default:
|
||||
return <Badge variant="secondary">待處理</Badge>
|
||||
return <Badge variant="secondary">{t('taskDetail.status.pending')}</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
const getTranslationStatusText = (status: TranslationStatus | null) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return '準備中...'
|
||||
return t('translation.status.preparing')
|
||||
case 'loading_model':
|
||||
return '載入翻譯模型...'
|
||||
return t('translation.status.loadingModel')
|
||||
case 'translating':
|
||||
return '翻譯中...'
|
||||
return t('translation.status.translating')
|
||||
case 'completed':
|
||||
return '完成'
|
||||
return t('translation.status.complete')
|
||||
case 'failed':
|
||||
return '失敗'
|
||||
return t('taskDetail.status.failed')
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
@@ -350,7 +350,7 @@ export default function TaskDetailPage() {
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-TW')
|
||||
return date.toLocaleString(i18n.language === 'en' ? 'en-US' : 'zh-TW')
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
@@ -358,7 +358,7 @@ export default function TaskDetailPage() {
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-primary mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">載入任務詳情...</p>
|
||||
<p className="text-muted-foreground">{t('taskDetail.loadingTask')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -372,12 +372,12 @@ export default function TaskDetailPage() {
|
||||
<div className="flex justify-center mb-4">
|
||||
<AlertCircle className="w-16 h-16 text-destructive" />
|
||||
</div>
|
||||
<CardTitle>任務不存在</CardTitle>
|
||||
<CardTitle>{t('taskDetail.taskNotFound')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-muted-foreground">找不到任務 ID: {taskId}</p>
|
||||
<p className="text-muted-foreground">{t('taskDetail.taskNotFoundDesc', { id: taskId })}</p>
|
||||
<Button onClick={() => navigate('/tasks')}>
|
||||
返回任務歷史
|
||||
{t('taskDetail.returnToHistory')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -397,19 +397,19 @@ export default function TaskDetailPage() {
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline" onClick={() => navigate('/tasks')} className="gap-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="page-title">任務詳情</h1>
|
||||
<h1 className="page-title">{t('taskDetail.title')}</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
任務 ID: <span className="font-mono text-primary">{taskId}</span>
|
||||
{t('taskDetail.taskId', { id: taskId })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<Button onClick={() => refetch()} variant="outline" size="sm" className="gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
刷新
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
{getStatusBadge(taskDetail.status)}
|
||||
</div>
|
||||
@@ -421,35 +421,35 @@ export default function TaskDetailPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
任務資訊
|
||||
{t('taskDetail.taskInfo')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">檔案名稱</p>
|
||||
<p className="font-medium">{taskDetail.filename || '未知檔案'}</p>
|
||||
<p className="text-sm text-muted-foreground mb-1">{t('taskDetail.filename')}</p>
|
||||
<p className="font-medium">{taskDetail.filename || t('common.unknownFile')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">建立時間</p>
|
||||
<p className="text-sm text-muted-foreground mb-1">{t('taskDetail.createdAt')}</p>
|
||||
<p className="font-medium">{formatDate(taskDetail.created_at)}</p>
|
||||
</div>
|
||||
{taskDetail.completed_at && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">完成時間</p>
|
||||
<p className="text-sm text-muted-foreground mb-1">{t('taskDetail.completedAt')}</p>
|
||||
<p className="font-medium">{formatDate(taskDetail.completed_at)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">任務狀態</p>
|
||||
<p className="text-sm text-muted-foreground mb-1">{t('taskDetail.taskStatus')}</p>
|
||||
{getStatusBadge(taskDetail.status)}
|
||||
</div>
|
||||
{(taskDetail.processing_track || processingMetadata?.processing_track) && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">處理軌道</p>
|
||||
<p className="text-sm text-muted-foreground mb-1">{t('taskDetail.processingTrack')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{getTrackBadge(taskDetail.processing_track || processingMetadata?.processing_track)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
@@ -460,13 +460,13 @@ export default function TaskDetailPage() {
|
||||
)}
|
||||
{taskDetail.processing_time_ms && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">處理時間</p>
|
||||
<p className="font-medium">{(taskDetail.processing_time_ms / 1000).toFixed(2)} 秒</p>
|
||||
<p className="text-sm text-muted-foreground mb-1">{t('taskDetail.processingTime')}</p>
|
||||
<p className="font-medium">{(taskDetail.processing_time_ms / 1000).toFixed(2)} {t('common.seconds')}</p>
|
||||
</div>
|
||||
)}
|
||||
{taskDetail.updated_at && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">最後更新</p>
|
||||
<p className="text-sm text-muted-foreground mb-1">{t('taskDetail.lastUpdated')}</p>
|
||||
<p className="font-medium">{formatDate(taskDetail.updated_at)}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -481,18 +481,18 @@ export default function TaskDetailPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Download className="w-5 h-5" />
|
||||
下載結果
|
||||
{t('taskDetail.downloadResults')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button onClick={handleDownloadLayoutPDF} className="gap-2 h-20 flex-col">
|
||||
<Download className="w-8 h-8" />
|
||||
<span>版面 PDF</span>
|
||||
<span>{t('taskDetail.layoutPdf')}</span>
|
||||
</Button>
|
||||
<Button onClick={handleDownloadReflowPDF} variant="outline" className="gap-2 h-20 flex-col">
|
||||
<Download className="w-8 h-8" />
|
||||
<span>流式 PDF</span>
|
||||
<span>{t('taskDetail.reflowPdf')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/* Visualization download for OCR Track */}
|
||||
@@ -504,7 +504,7 @@ export default function TaskDetailPage() {
|
||||
className="w-full gap-2"
|
||||
>
|
||||
<Image className="w-4 h-4" />
|
||||
下載辨識結果圖片 (ZIP)
|
||||
{t('taskDetail.downloadVisualization')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -518,7 +518,7 @@ export default function TaskDetailPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Languages className="w-5 h-5" />
|
||||
文件翻譯
|
||||
{t('translation.title')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -526,14 +526,14 @@ export default function TaskDetailPage() {
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">目標語言:</span>
|
||||
<span className="text-sm text-muted-foreground">{t('translation.targetLanguage')}</span>
|
||||
<Select
|
||||
value={targetLang}
|
||||
onValueChange={setTargetLang}
|
||||
disabled={isTranslating}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="選擇語言" />
|
||||
<SelectValue placeholder={t('translation.selectLanguage')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGE_OPTIONS.map(lang => (
|
||||
@@ -554,7 +554,7 @@ export default function TaskDetailPage() {
|
||||
) : (
|
||||
<Languages className="w-4 h-4" />
|
||||
)}
|
||||
{isTranslating ? getTranslationStatusText(translationStatus) : '開始翻譯'}
|
||||
{isTranslating ? getTranslationStatusText(translationStatus) : t('translation.startTranslation')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -572,7 +572,7 @@ export default function TaskDetailPage() {
|
||||
{/* Existing Translations */}
|
||||
{translationList && translationList.translations.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">已完成的翻譯:</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">{t('translation.completedTranslations')}</p>
|
||||
<div className="space-y-2">
|
||||
{translationList.translations.map((item: TranslationListItem) => (
|
||||
<div
|
||||
@@ -586,7 +586,7 @@ export default function TaskDetailPage() {
|
||||
{LANGUAGE_OPTIONS.find(l => l.value === item.target_lang)?.label || item.target_lang}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground ml-2">
|
||||
({item.statistics.translated_elements} 元素, {item.statistics.processing_time_seconds.toFixed(1)}s)
|
||||
({item.statistics.translated_elements} elements, {item.statistics.processing_time_seconds.toFixed(1)}s)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -598,7 +598,7 @@ export default function TaskDetailPage() {
|
||||
className="gap-1"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
流式 PDF
|
||||
{t('taskDetail.reflowPdf')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -616,7 +616,7 @@ export default function TaskDetailPage() {
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
使用雲端翻譯服務進行多語言翻譯,支援多種目標語言。
|
||||
{t('translation.description')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -628,7 +628,7 @@ export default function TaskDetailPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
錯誤訊息
|
||||
{t('taskDetail.errorMessage')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -642,8 +642,8 @@ export default function TaskDetailPage() {
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Loader2 className="w-16 h-16 animate-spin text-primary mx-auto mb-4" />
|
||||
<p className="text-lg font-semibold">正在處理中...</p>
|
||||
<p className="text-muted-foreground mt-2">請稍候,OCR 處理需要一些時間</p>
|
||||
<p className="text-lg font-semibold">{t('taskDetail.processingInProgress')}</p>
|
||||
<p className="text-muted-foreground mt-2">{t('taskDetail.processingInProgressDesc')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -658,7 +658,7 @@ export default function TaskDetailPage() {
|
||||
<Clock className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">處理時間</p>
|
||||
<p className="text-xs text-muted-foreground">{t('taskDetail.processingTime')}</p>
|
||||
<p className="text-lg font-bold">
|
||||
{processingMetadata?.processing_time_seconds?.toFixed(2) ||
|
||||
(taskDetail.processing_time_ms ? (taskDetail.processing_time_ms / 1000).toFixed(2) : '0')}s
|
||||
@@ -675,7 +675,7 @@ export default function TaskDetailPage() {
|
||||
<Layers className="w-5 h-5 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">頁數</p>
|
||||
<p className="text-xs text-muted-foreground">{t('taskDetail.stats.pageCount')}</p>
|
||||
<p className="text-lg font-bold">
|
||||
{processingMetadata?.page_count || '-'}
|
||||
</p>
|
||||
@@ -691,7 +691,7 @@ export default function TaskDetailPage() {
|
||||
<FileSearch className="w-5 h-5 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">文本區域</p>
|
||||
<p className="text-xs text-muted-foreground">{t('taskDetail.stats.textRegions')}</p>
|
||||
<p className="text-lg font-bold">
|
||||
{processingMetadata?.total_text_regions || '-'}
|
||||
</p>
|
||||
@@ -707,7 +707,7 @@ export default function TaskDetailPage() {
|
||||
<Table2 className="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">表格</p>
|
||||
<p className="text-xs text-muted-foreground">{t('taskDetail.stats.tables')}</p>
|
||||
<p className="text-lg font-bold">
|
||||
{processingMetadata?.total_tables || '-'}
|
||||
</p>
|
||||
@@ -723,7 +723,7 @@ export default function TaskDetailPage() {
|
||||
<Image className="w-5 h-5 text-orange-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">圖片</p>
|
||||
<p className="text-xs text-muted-foreground">{t('taskDetail.stats.images')}</p>
|
||||
<p className="text-lg font-bold">
|
||||
{processingMetadata?.total_images || '-'}
|
||||
</p>
|
||||
@@ -739,7 +739,7 @@ export default function TaskDetailPage() {
|
||||
<BarChart3 className="w-5 h-5 text-cyan-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">平均置信度</p>
|
||||
<p className="text-xs text-muted-foreground">{t('taskDetail.stats.avgConfidence')}</p>
|
||||
<p className="text-lg font-bold">
|
||||
{processingMetadata?.average_confidence
|
||||
? `${(processingMetadata.average_confidence * 100).toFixed(0)}%`
|
||||
@@ -755,7 +755,7 @@ export default function TaskDetailPage() {
|
||||
{/* Result Preview */}
|
||||
{isCompleted && (
|
||||
<PDFViewer
|
||||
title={`OCR 結果預覽 - ${taskDetail.filename || '未知檔案'}`}
|
||||
title={t('taskDetail.ocrPreview', { filename: taskDetail.filename || t('common.unknownFile') })}
|
||||
pdfUrl={pdfUrl}
|
||||
httpHeaders={pdfHttpHeaders}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { apiClientV2 } from '@/services/apiV2'
|
||||
import type { Task, TaskStats, TaskStatus } from '@/types/apiV2'
|
||||
import {
|
||||
@@ -33,6 +34,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
|
||||
export default function TaskHistoryPage() {
|
||||
const navigate = useNavigate()
|
||||
const { t, i18n } = useTranslation()
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [stats, setStats] = useState<TaskStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -69,7 +71,7 @@ export default function TaskHistoryPage() {
|
||||
setTotal(response.total)
|
||||
setHasMore(response.has_more)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || '載入任務失敗')
|
||||
setError(err.response?.data?.detail || t('taskHistory.loadFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -101,25 +103,25 @@ export default function TaskHistoryPage() {
|
||||
|
||||
// Delete task
|
||||
const handleDelete = async (taskId: string) => {
|
||||
if (!confirm('確定要刪除此任務嗎?')) return
|
||||
if (!confirm(t('taskHistory.deleteConfirm'))) return
|
||||
|
||||
try {
|
||||
await apiClientV2.deleteTask(taskId)
|
||||
fetchTasks()
|
||||
fetchStats()
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || '刪除任務失敗')
|
||||
alert(err.response?.data?.detail || t('taskHistory.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all tasks
|
||||
const handleDeleteAll = async () => {
|
||||
if (tasks.length === 0) {
|
||||
alert('沒有可刪除的任務')
|
||||
alert(t('taskHistory.noTasksToDelete'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(`確定要刪除所有 ${total} 個任務嗎?此操作無法復原!`)) return
|
||||
if (!confirm(t('taskHistory.deleteAllConfirm', { count: total }))) return
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
@@ -139,9 +141,9 @@ export default function TaskHistoryPage() {
|
||||
}
|
||||
fetchTasks()
|
||||
fetchStats()
|
||||
alert('所有任務已刪除')
|
||||
alert(t('taskHistory.allTasksDeleted'))
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || '刪除任務失敗')
|
||||
alert(err.response?.data?.detail || t('taskHistory.deleteFailed'))
|
||||
fetchTasks()
|
||||
fetchStats()
|
||||
} finally {
|
||||
@@ -159,7 +161,7 @@ export default function TaskHistoryPage() {
|
||||
try {
|
||||
await apiClientV2.downloadPDF(taskId, format)
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || `下載 PDF 檔案失敗`)
|
||||
alert(err.response?.data?.detail || t('taskHistory.downloadPdfFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,18 +171,18 @@ export default function TaskHistoryPage() {
|
||||
await apiClientV2.startTask(taskId)
|
||||
fetchTasks()
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || '啟動任務失敗')
|
||||
alert(err.response?.data?.detail || t('taskHistory.startTaskFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelTask = async (taskId: string) => {
|
||||
if (!confirm('確定要取消此任務嗎?')) return
|
||||
if (!confirm(t('taskHistory.cancelConfirm'))) return
|
||||
try {
|
||||
await apiClientV2.cancelTask(taskId)
|
||||
fetchTasks()
|
||||
fetchStats()
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || '取消任務失敗')
|
||||
alert(err.response?.data?.detail || t('taskHistory.cancelFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,14 +192,14 @@ export default function TaskHistoryPage() {
|
||||
fetchTasks()
|
||||
fetchStats()
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || '重試任務失敗')
|
||||
alert(err.response?.data?.detail || t('taskHistory.retryFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-TW')
|
||||
return date.toLocaleString(i18n.language)
|
||||
}
|
||||
|
||||
// Format processing time
|
||||
@@ -213,22 +215,22 @@ export default function TaskHistoryPage() {
|
||||
pending: {
|
||||
variant: 'secondary',
|
||||
icon: Clock,
|
||||
label: '待處理',
|
||||
label: t('taskHistory.status.pending'),
|
||||
},
|
||||
processing: {
|
||||
variant: 'default',
|
||||
icon: Loader2,
|
||||
label: '處理中',
|
||||
label: t('taskHistory.status.processing'),
|
||||
},
|
||||
completed: {
|
||||
variant: 'default',
|
||||
icon: CheckCircle2,
|
||||
label: '已完成',
|
||||
label: t('taskHistory.status.completed'),
|
||||
},
|
||||
failed: {
|
||||
variant: 'destructive',
|
||||
icon: XCircle,
|
||||
label: '失敗',
|
||||
label: t('taskHistory.status.failed'),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -248,17 +250,17 @@ export default function TaskHistoryPage() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">任務歷史</h1>
|
||||
<p className="text-gray-600 mt-1">查看和管理您的 OCR 任務</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{t('taskHistory.title')}</h1>
|
||||
<p className="text-gray-600 mt-1">{t('taskHistory.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => fetchTasks()} variant="outline">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
<Button onClick={handleDeleteAll} variant="destructive" disabled={loading || tasks.length === 0}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
刪除全部
|
||||
{t('taskHistory.deleteAll')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,7 +270,7 @@ export default function TaskHistoryPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">總計</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-gray-600">{t('common.total')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.total}</div>
|
||||
@@ -277,7 +279,7 @@ export default function TaskHistoryPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">待處理</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-gray-600">{t('taskHistory.status.pending')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-gray-600">{stats.pending}</div>
|
||||
@@ -286,7 +288,7 @@ export default function TaskHistoryPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">處理中</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-gray-600">{t('taskHistory.status.processing')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-600">{stats.processing}</div>
|
||||
@@ -295,7 +297,7 @@ export default function TaskHistoryPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">已完成</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-gray-600">{t('taskHistory.status.completed')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">{stats.completed}</div>
|
||||
@@ -304,7 +306,7 @@ export default function TaskHistoryPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">失敗</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-gray-600">{t('taskHistory.status.failed')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">{stats.failed}</div>
|
||||
@@ -318,13 +320,13 @@ export default function TaskHistoryPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Filter className="w-5 h-5" />
|
||||
篩選條件
|
||||
{t('taskHistory.filterConditions')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">狀態</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('taskHistory.statusFilter')}</label>
|
||||
<NativeSelect
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
@@ -332,17 +334,17 @@ export default function TaskHistoryPage() {
|
||||
handleFilterChange()
|
||||
}}
|
||||
options={[
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'pending', label: '待處理' },
|
||||
{ value: 'processing', label: '處理中' },
|
||||
{ value: 'completed', label: '已完成' },
|
||||
{ value: 'failed', label: '失敗' },
|
||||
{ value: 'all', label: t('taskHistory.status.all') },
|
||||
{ value: 'pending', label: t('taskHistory.status.pending') },
|
||||
{ value: 'processing', label: t('taskHistory.status.processing') },
|
||||
{ value: 'completed', label: t('taskHistory.status.completed') },
|
||||
{ value: 'failed', label: t('taskHistory.status.failed') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">檔案名稱</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('taskHistory.filenameFilter')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filenameSearch}
|
||||
@@ -350,13 +352,13 @@ export default function TaskHistoryPage() {
|
||||
setFilenameSearch(e.target.value)
|
||||
handleFilterChange()
|
||||
}}
|
||||
placeholder="搜尋檔案名稱"
|
||||
placeholder={t('taskHistory.searchFilename')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">開始日期</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('taskHistory.startDate')}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
@@ -369,7 +371,7 @@ export default function TaskHistoryPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">結束日期</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('taskHistory.endDate')}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
@@ -395,7 +397,7 @@ export default function TaskHistoryPage() {
|
||||
handleFilterChange()
|
||||
}}
|
||||
>
|
||||
清除篩選
|
||||
{t('taskHistory.clearFilter')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -413,9 +415,9 @@ export default function TaskHistoryPage() {
|
||||
{/* Task List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">任務列表</CardTitle>
|
||||
<CardTitle className="text-lg">{t('taskHistory.taskList')}</CardTitle>
|
||||
<CardDescription>
|
||||
共 {total} 個任務 {hasMore && `(顯示第 ${page} 頁)`}
|
||||
{t('common.total')} {total} {hasMore && `(${t('taskHistory.pagination.showing', { start: (page - 1) * pageSize + 1, end: Math.min(page * pageSize, total), total })})`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -426,26 +428,26 @@ export default function TaskHistoryPage() {
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>暫無任務</p>
|
||||
<p>{t('taskHistory.noTasks')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>檔案名稱</TableHead>
|
||||
<TableHead>狀態</TableHead>
|
||||
<TableHead>建立時間</TableHead>
|
||||
<TableHead>完成時間</TableHead>
|
||||
<TableHead>處理時間</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
<TableHead>{t('taskHistory.filenameFilter')}</TableHead>
|
||||
<TableHead>{t('taskHistory.statusFilter')}</TableHead>
|
||||
<TableHead>{t('taskHistory.table.createdAt')}</TableHead>
|
||||
<TableHead>{t('taskHistory.table.completedAt')}</TableHead>
|
||||
<TableHead>{t('taskHistory.table.processingTime')}</TableHead>
|
||||
<TableHead className="text-right">{t('taskHistory.table.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks.map((task) => (
|
||||
<TableRow key={task.id}>
|
||||
<TableCell className="font-medium">
|
||||
{task.filename || '未命名檔案'}
|
||||
{task.filename || t('taskHistory.unnamed')}
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(task.status)}</TableCell>
|
||||
<TableCell className="text-sm text-gray-600">
|
||||
@@ -466,7 +468,7 @@ export default function TaskHistoryPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleStartTask(task.task_id)}
|
||||
title="開始處理"
|
||||
title={t('taskHistory.actions.startProcessing')}
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -474,7 +476,7 @@ export default function TaskHistoryPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCancelTask(task.task_id)}
|
||||
title="取消"
|
||||
title={t('taskHistory.actions.cancel')}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -485,7 +487,7 @@ export default function TaskHistoryPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCancelTask(task.task_id)}
|
||||
title="取消"
|
||||
title={t('taskHistory.actions.cancel')}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -495,7 +497,7 @@ export default function TaskHistoryPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRetryTask(task.task_id)}
|
||||
title="重試"
|
||||
title={t('taskHistory.actions.retry')}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -507,25 +509,25 @@ export default function TaskHistoryPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadPDF(task.task_id, 'layout')}
|
||||
title="下載版面 PDF"
|
||||
title={t('taskHistory.actions.downloadLayoutPdf')}
|
||||
>
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
版面
|
||||
{t('taskHistory.actions.layoutPdf')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadPDF(task.task_id, 'reflow')}
|
||||
title="下載流式 PDF"
|
||||
title={t('taskHistory.actions.downloadReflowPdf')}
|
||||
>
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
流式
|
||||
{t('taskHistory.actions.reflowPdf')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleViewDetails(task.task_id)}
|
||||
title="查看詳情"
|
||||
title={t('taskHistory.actions.viewDetails')}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -536,7 +538,7 @@ export default function TaskHistoryPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(task.task_id)}
|
||||
title="刪除"
|
||||
title={t('taskHistory.actions.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
</Button>
|
||||
@@ -550,8 +552,7 @@ export default function TaskHistoryPage() {
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
顯示 {(page - 1) * pageSize + 1} - {Math.min(page * pageSize, total)} / 共{' '}
|
||||
{total} 個
|
||||
{t('taskHistory.pagination.showing', { start: (page - 1) * pageSize + 1, end: Math.min(page * pageSize, total), total })}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -560,7 +561,7 @@ export default function TaskHistoryPage() {
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
上一頁
|
||||
{t('taskHistory.pagination.previous')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -568,7 +569,7 @@ export default function TaskHistoryPage() {
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={!hasMore}
|
||||
>
|
||||
下一頁
|
||||
{t('taskHistory.pagination.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function UploadPage() {
|
||||
if (selectedFiles.length === 0) {
|
||||
toast({
|
||||
title: t('errors.validationError'),
|
||||
description: '請選擇至少一個檔案',
|
||||
description: t('upload.selectAtLeastOne'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
@@ -122,7 +122,7 @@ export default function UploadPage() {
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">{t('upload.title')}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
選擇要進行 OCR 處理的檔案,支援圖片、PDF 和 Office 文件
|
||||
{t('upload.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -135,8 +135,8 @@ export default function UploadPage() {
|
||||
{selectedFiles.length === 0 ? '1' : <CheckCircle2 className="w-5 h-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">選擇檔案</div>
|
||||
<div className="text-xs text-muted-foreground">上傳要處理的文件</div>
|
||||
<div className="text-sm font-medium text-foreground">{t('upload.steps.selectFiles')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('upload.steps.selectFilesDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -151,8 +151,8 @@ export default function UploadPage() {
|
||||
<div>
|
||||
<div className={`text-sm font-medium ${
|
||||
selectedFiles.length > 0 ? 'text-foreground' : 'text-muted-foreground'
|
||||
}`}>確認並上傳</div>
|
||||
<div className="text-xs text-muted-foreground">檢查並開始處理</div>
|
||||
}`}>{t('upload.steps.confirmUpload')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('upload.steps.confirmUploadDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -163,8 +163,8 @@ export default function UploadPage() {
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">處理完成</div>
|
||||
<div className="text-xs text-muted-foreground">查看結果並導出</div>
|
||||
<div className="text-sm font-medium text-muted-foreground">{t('upload.steps.processingComplete')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('upload.steps.processingCompleteDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,7 +193,7 @@ export default function UploadPage() {
|
||||
{t('upload.selectedFiles')}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
已選擇 {selectedFiles.length} 個檔案,總大小 {formatFileSize(totalSize)}
|
||||
{t('upload.fileList.summary', { count: selectedFiles.length, size: formatFileSize(totalSize) })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,7 +205,7 @@ export default function UploadPage() {
|
||||
className="gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
清空全部
|
||||
{t('upload.clearAll')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -228,13 +228,13 @@ export default function UploadPage() {
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(file.size)} · {file.type || '未知類型'}
|
||||
{formatFileSize(file.size)} · {file.type || t('upload.fileList.unknownType')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
<div className="status-badge-success">
|
||||
準備就緒
|
||||
{t('upload.fileList.ready')}
|
||||
</div>
|
||||
|
||||
{/* Remove button */}
|
||||
@@ -242,7 +242,7 @@ export default function UploadPage() {
|
||||
onClick={() => handleRemoveFile(index)}
|
||||
disabled={uploadMutation.isPending}
|
||||
className="p-2 rounded-lg text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="移除檔案"
|
||||
title={t('upload.fileList.removeFile')}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -255,7 +255,7 @@ export default function UploadPage() {
|
||||
{/* Action Bar */}
|
||||
<div className="flex items-center justify-between p-4 bg-card rounded-xl border border-border">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
請確認檔案無誤後點擊上傳按鈕開始處理
|
||||
{t('upload.fileList.confirmPrompt')}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
@@ -263,7 +263,7 @@ export default function UploadPage() {
|
||||
onClick={handleClearAll}
|
||||
disabled={uploadMutation.isPending}
|
||||
>
|
||||
取消
|
||||
{t('upload.fileList.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
@@ -278,7 +278,7 @@ export default function UploadPage() {
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
開始上傳並處理
|
||||
{t('upload.fileList.startUpload')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user