feat: add translation billing stats and remove Export/Settings pages
- Add TranslationLog model to track translation API usage per task - Integrate Dify API actual price (total_price) into translation stats - Display translation statistics in admin dashboard with per-task costs - Remove unused Export and Settings pages to simplify frontend - Add GET /api/v2/admin/translation-stats endpoint 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,8 +3,6 @@ import LoginPage from '@/pages/LoginPage'
|
||||
import UploadPage from '@/pages/UploadPage'
|
||||
import ProcessingPage from '@/pages/ProcessingPage'
|
||||
import ResultsPage from '@/pages/ResultsPage'
|
||||
import ExportPage from '@/pages/ExportPage'
|
||||
import SettingsPage from '@/pages/SettingsPage'
|
||||
import TaskHistoryPage from '@/pages/TaskHistoryPage'
|
||||
import TaskDetailPage from '@/pages/TaskDetailPage'
|
||||
import AdminDashboardPage from '@/pages/AdminDashboardPage'
|
||||
@@ -31,10 +29,8 @@ function App() {
|
||||
<Route path="upload" element={<UploadPage />} />
|
||||
<Route path="processing" element={<ProcessingPage />} />
|
||||
<Route path="results" element={<ResultsPage />} />
|
||||
<Route path="export" element={<ExportPage />} />
|
||||
<Route path="tasks" element={<TaskHistoryPage />} />
|
||||
<Route path="tasks/:taskId" element={<TaskDetailPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
|
||||
{/* Admin routes - require admin privileges */}
|
||||
<Route
|
||||
|
||||
@@ -5,9 +5,7 @@ import { apiClientV2 } from '@/services/apiV2'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher'
|
||||
import {
|
||||
Upload,
|
||||
Settings,
|
||||
FileText,
|
||||
Download,
|
||||
Activity,
|
||||
LogOut,
|
||||
LayoutDashboard,
|
||||
@@ -41,8 +39,6 @@ export default function Layout() {
|
||||
{ to: '/processing', label: t('nav.processing'), icon: Activity, description: '處理進度', adminOnly: false },
|
||||
{ to: '/results', label: t('nav.results'), icon: FileText, description: '查看結果', adminOnly: false },
|
||||
{ to: '/tasks', label: '任務歷史', icon: History, description: '查看任務記錄', adminOnly: false },
|
||||
{ to: '/export', label: t('nav.export'), icon: Download, description: '導出文件', adminOnly: false },
|
||||
{ to: '/settings', label: t('nav.settings'), icon: Settings, description: '系統設定', adminOnly: false },
|
||||
{ to: '/admin', label: '管理員儀表板', icon: Shield, description: '系統管理', adminOnly: true },
|
||||
]
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { apiClientV2 } from '@/services/apiV2'
|
||||
import type { SystemStats, UserWithStats, TopUser } from '@/types/apiV2'
|
||||
import type { SystemStats, UserWithStats, TopUser, TranslationStats } from '@/types/apiV2'
|
||||
import {
|
||||
Users,
|
||||
ClipboardList,
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
XCircle,
|
||||
Clock,
|
||||
Loader2,
|
||||
Languages,
|
||||
Coins,
|
||||
} from 'lucide-react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -36,6 +38,7 @@ export default function AdminDashboardPage() {
|
||||
const [stats, setStats] = useState<SystemStats | null>(null)
|
||||
const [users, setUsers] = useState<UserWithStats[]>([])
|
||||
const [topUsers, setTopUsers] = useState<TopUser[]>([])
|
||||
const [translationStats, setTranslationStats] = useState<TranslationStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
@@ -45,15 +48,17 @@ export default function AdminDashboardPage() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
const [statsData, usersData, topUsersData] = await Promise.all([
|
||||
const [statsData, usersData, topUsersData, translationStatsData] = await Promise.all([
|
||||
apiClientV2.getSystemStats(),
|
||||
apiClientV2.listUsers({ page: 1, page_size: 10 }),
|
||||
apiClientV2.getTopUsers({ metric: 'tasks', limit: 5 }),
|
||||
apiClientV2.getTranslationStats(),
|
||||
])
|
||||
|
||||
setStats(statsData)
|
||||
setUsers(usersData.users)
|
||||
setTopUsers(topUsersData)
|
||||
setTranslationStats(translationStatsData)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch admin data:', err)
|
||||
setError(err.response?.data?.detail || '載入管理員資料失敗')
|
||||
@@ -198,6 +203,130 @@ export default function AdminDashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Translation Statistics */}
|
||||
{translationStats && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Languages className="w-5 h-5" />
|
||||
翻譯統計
|
||||
</CardTitle>
|
||||
<CardDescription>翻譯 API 使用量與計費追蹤</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>
|
||||
</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}
|
||||
</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>
|
||||
</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()}
|
||||
</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>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-green-700">
|
||||
{translationStats.total_characters.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-amber-700">
|
||||
${translationStats.estimated_cost.toFixed(2)}
|
||||
</div>
|
||||
<p className="text-xs text-amber-500 mt-1">USD</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language Breakdown */}
|
||||
{translationStats.by_language.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">語言分佈</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)
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Translations */}
|
||||
{translationStats.recent_translations.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">最近翻譯記錄</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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{translationStats.recent_translations.slice(0, 10).map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{t.task_id.substring(0, 8)}...
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{t.target_lang}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{t.total_tokens.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{t.total_characters.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium text-amber-600">
|
||||
${t.estimated_cost.toFixed(4)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{t.processing_time_seconds.toFixed(1)}s
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-600">
|
||||
{new Date(t.created_at).toLocaleString('zh-TW')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Top Users */}
|
||||
{topUsers.length > 0 && (
|
||||
<Card>
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
import { apiClientV2 } from '@/services/apiV2'
|
||||
import {
|
||||
Download,
|
||||
FileText,
|
||||
FileJson,
|
||||
FileType,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ArrowLeft,
|
||||
Loader2
|
||||
} from 'lucide-react'
|
||||
|
||||
type ExportFormat = 'json' | 'markdown' | 'pdf'
|
||||
|
||||
export default function ExportPage() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { toast } = useToast()
|
||||
|
||||
const [selectedTasks, setSelectedTasks] = useState<Set<string>>(new Set())
|
||||
const [exportFormat, setExportFormat] = useState<ExportFormat>('markdown')
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
|
||||
// Fetch completed tasks
|
||||
const { data: tasksData, isLoading } = useQuery({
|
||||
queryKey: ['tasks', 'completed'],
|
||||
queryFn: () => apiClientV2.listTasks({
|
||||
status: 'completed',
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
order_by: 'completed_at',
|
||||
order_desc: true
|
||||
}),
|
||||
})
|
||||
|
||||
const completedTasks = tasksData?.tasks || []
|
||||
|
||||
// Select/Deselect all
|
||||
const handleSelectAll = () => {
|
||||
if (selectedTasks.size === completedTasks.length) {
|
||||
setSelectedTasks(new Set())
|
||||
} else {
|
||||
setSelectedTasks(new Set(completedTasks.map(t => t.task_id)))
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle task selection
|
||||
const handleToggleTask = (taskId: string) => {
|
||||
const newSelected = new Set(selectedTasks)
|
||||
if (newSelected.has(taskId)) {
|
||||
newSelected.delete(taskId)
|
||||
} else {
|
||||
newSelected.add(taskId)
|
||||
}
|
||||
setSelectedTasks(newSelected)
|
||||
}
|
||||
|
||||
// Export selected tasks
|
||||
const handleExport = async () => {
|
||||
if (selectedTasks.size === 0) {
|
||||
toast({
|
||||
title: '請選擇任務',
|
||||
description: '請至少選擇一個任務進行匯出',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsExporting(true)
|
||||
let successCount = 0
|
||||
let errorCount = 0
|
||||
|
||||
try {
|
||||
for (const taskId of selectedTasks) {
|
||||
try {
|
||||
if (exportFormat === 'json') {
|
||||
await apiClientV2.downloadJSON(taskId)
|
||||
} else if (exportFormat === 'markdown') {
|
||||
await apiClientV2.downloadMarkdown(taskId)
|
||||
} else if (exportFormat === 'pdf') {
|
||||
await apiClientV2.downloadPDF(taskId)
|
||||
}
|
||||
successCount++
|
||||
} catch (error) {
|
||||
console.error(`Export failed for task ${taskId}:`, error)
|
||||
errorCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
toast({
|
||||
title: t('export.exportSuccess'),
|
||||
description: `成功匯出 ${successCount} 個檔案${errorCount > 0 ? `,${errorCount} 個失敗` : ''}`,
|
||||
variant: 'success',
|
||||
})
|
||||
}
|
||||
|
||||
if (errorCount > 0 && successCount === 0) {
|
||||
toast({
|
||||
title: t('export.exportError'),
|
||||
description: `所有匯出失敗 (${errorCount} 個檔案)`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatIcons = {
|
||||
json: FileJson,
|
||||
markdown: FileText,
|
||||
pdf: FileType,
|
||||
}
|
||||
|
||||
const formatLabels = {
|
||||
json: 'JSON',
|
||||
markdown: 'Markdown',
|
||||
pdf: 'PDF',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="page-header">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline" onClick={() => navigate('/tasks')} className="gap-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="page-title">{t('export.title')}</h1>
|
||||
<p className="text-muted-foreground mt-1">批次匯出 OCR 處理結果</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column - Task Selection */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Format Selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileType className="w-5 h-5" />
|
||||
選擇匯出格式
|
||||
</CardTitle>
|
||||
<CardDescription>選擇要匯出的檔案格式</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(['json', 'markdown', 'pdf'] as ExportFormat[]).map((fmt) => {
|
||||
const Icon = formatIcons[fmt]
|
||||
return (
|
||||
<button
|
||||
key={fmt}
|
||||
onClick={() => setExportFormat(fmt)}
|
||||
className={`p-4 border-2 rounded-lg transition-all ${
|
||||
exportFormat === fmt
|
||||
? 'border-primary bg-primary/10 shadow-md'
|
||||
: 'border-border hover:border-primary/50 hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-6 h-6 mx-auto mb-2 ${exportFormat === fmt ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
<div className={`text-sm font-medium ${exportFormat === fmt ? 'text-primary' : 'text-foreground'}`}>
|
||||
{formatLabels[fmt]}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Task List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
選擇任務
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
選擇要匯出的已完成任務 ({selectedTasks.size}/{completedTasks.length} 已選)
|
||||
</CardDescription>
|
||||
</div>
|
||||
{completedTasks.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={handleSelectAll}>
|
||||
{selectedTasks.size === completedTasks.length ? '取消全選' : '全選'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : completedTasks.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<AlertCircle className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">沒有已完成的任務</p>
|
||||
<Button onClick={() => navigate('/upload')} className="mt-4">
|
||||
前往上傳頁面
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[500px] overflow-y-auto">
|
||||
{completedTasks.map((task) => (
|
||||
<div
|
||||
key={task.task_id}
|
||||
className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition-colors ${
|
||||
selectedTasks.has(task.task_id)
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50 hover:bg-muted/50'
|
||||
}`}
|
||||
onClick={() => handleToggleTask(task.task_id)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedTasks.has(task.task_id)}
|
||||
onCheckedChange={() => handleToggleTask(task.task_id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{task.filename || '未知檔案'}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(task.completed_at!).toLocaleString('zh-TW')}
|
||||
{task.processing_time_ms && ` · ${(task.processing_time_ms / 1000).toFixed(2)}s`}
|
||||
</p>
|
||||
</div>
|
||||
<FileText className="w-5 h-5 text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Export Summary */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="sticky top-6">
|
||||
<CardHeader>
|
||||
<CardTitle>匯出摘要</CardTitle>
|
||||
<CardDescription>當前設定概覽</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 bg-muted/30 rounded-lg space-y-3">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">格式</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const Icon = formatIcons[exportFormat]
|
||||
return <Icon className="w-4 h-4 text-primary" />
|
||||
})()}
|
||||
<span className="text-sm font-medium text-foreground">{formatLabels[exportFormat]}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">已選任務</div>
|
||||
<div className="text-2xl font-bold text-foreground">{selectedTasks.size}</div>
|
||||
</div>
|
||||
|
||||
{selectedTasks.size > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">預計下載</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{selectedTasks.size} 個 {formatLabels[exportFormat]} 檔案
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={selectedTasks.size === 0 || isExporting}
|
||||
className="w-full gap-2"
|
||||
size="lg"
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
匯出中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" />
|
||||
匯出選定任務
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate('/tasks')}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回任務歷史
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
import { apiClientV2 } from '@/services/apiV2'
|
||||
import type { ExportRule } from '@/types/apiV2'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [editingRule, setEditingRule] = useState<ExportRule | null>(null)
|
||||
const [formData, setFormData] = useState({
|
||||
rule_name: '',
|
||||
confidence_threshold: 0.5,
|
||||
include_metadata: true,
|
||||
filename_pattern: '{filename}_ocr',
|
||||
css_template: 'default',
|
||||
})
|
||||
|
||||
// Fetch export rules
|
||||
const { data: exportRules, isLoading } = useQuery({
|
||||
queryKey: ['exportRules'],
|
||||
queryFn: () => apiClientV2.getExportRules(),
|
||||
})
|
||||
|
||||
// Create rule mutation
|
||||
const createRuleMutation = useMutation({
|
||||
mutationFn: (rule: any) => apiClientV2.createExportRule(rule),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['exportRules'] })
|
||||
setIsCreating(false)
|
||||
resetForm()
|
||||
toast({
|
||||
title: t('common.success'),
|
||||
description: '規則已建立',
|
||||
variant: 'success',
|
||||
})
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: error.response?.data?.detail || t('errors.networkError'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// Update rule mutation
|
||||
const updateRuleMutation = useMutation({
|
||||
mutationFn: ({ ruleId, rule }: { ruleId: number; rule: any }) =>
|
||||
apiClientV2.updateExportRule(ruleId, rule),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['exportRules'] })
|
||||
setEditingRule(null)
|
||||
resetForm()
|
||||
toast({
|
||||
title: t('common.success'),
|
||||
description: '規則已更新',
|
||||
variant: 'success',
|
||||
})
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: error.response?.data?.detail || t('errors.networkError'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// Delete rule mutation
|
||||
const deleteRuleMutation = useMutation({
|
||||
mutationFn: (ruleId: number) => apiClientV2.deleteExportRule(ruleId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['exportRules'] })
|
||||
toast({
|
||||
title: t('common.success'),
|
||||
description: '規則已刪除',
|
||||
variant: 'success',
|
||||
})
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: error.response?.data?.detail || t('errors.networkError'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
rule_name: '',
|
||||
confidence_threshold: 0.5,
|
||||
include_metadata: true,
|
||||
filename_pattern: '{filename}_ocr',
|
||||
css_template: 'default',
|
||||
})
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
setIsCreating(true)
|
||||
setEditingRule(null)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const handleEdit = (rule: ExportRule) => {
|
||||
setEditingRule(rule)
|
||||
setIsCreating(false)
|
||||
setFormData({
|
||||
rule_name: rule.rule_name,
|
||||
confidence_threshold: rule.config_json.confidence_threshold || 0.5,
|
||||
include_metadata: rule.config_json.include_metadata || true,
|
||||
filename_pattern: rule.config_json.filename_pattern || '{filename}_ocr',
|
||||
css_template: rule.css_template || 'default',
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
const ruleData = {
|
||||
rule_name: formData.rule_name,
|
||||
config_json: {
|
||||
confidence_threshold: formData.confidence_threshold,
|
||||
include_metadata: formData.include_metadata,
|
||||
filename_pattern: formData.filename_pattern,
|
||||
},
|
||||
css_template: formData.css_template,
|
||||
}
|
||||
|
||||
if (editingRule) {
|
||||
updateRuleMutation.mutate({ ruleId: editingRule.id, rule: ruleData })
|
||||
} else {
|
||||
createRuleMutation.mutate(ruleData)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsCreating(false)
|
||||
setEditingRule(null)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const handleDelete = (ruleId: number) => {
|
||||
if (window.confirm('確定要刪除此規則嗎?')) {
|
||||
deleteRuleMutation.mutate(ruleId)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold text-foreground">{t('settings.title')}</h1>
|
||||
{!isCreating && !editingRule && (
|
||||
<Button onClick={handleCreate}>{t('export.rules.newRule')}</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Form */}
|
||||
{(isCreating || editingRule) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{editingRule ? t('common.edit') + ' ' + t('export.rules.title') : t('export.rules.newRule')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Rule Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
{t('export.rules.ruleName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.rule_name}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, rule_name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="例如:高信心度匯出"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confidence Threshold */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
{t('export.options.confidenceThreshold')}: {formData.confidence_threshold}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={formData.confidence_threshold}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
confidence_threshold: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Include Metadata */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="include-metadata-form"
|
||||
checked={formData.include_metadata}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
include_metadata: e.target.checked,
|
||||
}))
|
||||
}
|
||||
className="w-4 h-4 border border-gray-200 rounded"
|
||||
/>
|
||||
<label htmlFor="include-metadata-form" className="text-sm font-medium text-foreground">
|
||||
{t('export.options.includeMetadata')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Filename Pattern */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
{t('export.options.filenamePattern')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.filename_pattern}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
filename_pattern: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="{filename}_ocr"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CSS Template */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
{t('export.options.cssTemplate')}
|
||||
</label>
|
||||
<select
|
||||
value={formData.css_template}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, css_template: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="default">預設</option>
|
||||
<option value="academic">學術</option>
|
||||
<option value="business">商務</option>
|
||||
<option value="report">報告</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!formData.rule_name || createRuleMutation.isPending || updateRuleMutation.isPending}
|
||||
>
|
||||
{createRuleMutation.isPending || updateRuleMutation.isPending
|
||||
? t('common.loading')
|
||||
: t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Rules List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('export.rules.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="text-center text-muted-foreground py-8">{t('common.loading')}</p>
|
||||
) : exportRules && exportRules.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{exportRules.map((rule) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex items-center justify-between p-4 border border-gray-200 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground">{rule.rule_name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
信心度閾值: {rule.config_json.confidence_threshold || 0.5} | CSS 樣板:{' '}
|
||||
{rule.css_template || 'default'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => handleEdit(rule)}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(rule.id)}
|
||||
disabled={deleteRuleMutation.isPending}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-8">尚無匯出規則</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
TopUser,
|
||||
AuditLogListResponse,
|
||||
UserActivitySummary,
|
||||
TranslationStats,
|
||||
ProcessingOptions,
|
||||
ProcessingMetadata,
|
||||
DocumentAnalysisResponse,
|
||||
@@ -621,6 +622,14 @@ class ApiClientV2 {
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translation statistics (admin only)
|
||||
*/
|
||||
async getTranslationStats(): Promise<TranslationStats> {
|
||||
const response = await this.client.get<TranslationStats>('/admin/translation-stats')
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ==================== Translation APIs ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -294,6 +294,41 @@ export interface UserActivitySummary {
|
||||
recent_actions: AuditLog[]
|
||||
}
|
||||
|
||||
// ==================== Translation Statistics (Admin) ====================
|
||||
|
||||
export interface TranslationLanguageBreakdown {
|
||||
language: string
|
||||
count: number
|
||||
tokens: number
|
||||
characters: number
|
||||
}
|
||||
|
||||
export interface RecentTranslation {
|
||||
id: number
|
||||
task_id: string
|
||||
target_lang: string
|
||||
total_tokens: number
|
||||
total_characters: number
|
||||
processing_time_seconds: number
|
||||
estimated_cost: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface TranslationStats {
|
||||
total_translations: number
|
||||
total_tokens: number
|
||||
total_input_tokens: number
|
||||
total_output_tokens: number
|
||||
total_characters: number
|
||||
estimated_cost: number
|
||||
by_language: TranslationLanguageBreakdown[]
|
||||
recent_translations: RecentTranslation[]
|
||||
last_30_days: {
|
||||
count: number
|
||||
tokens: number
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Translation Types ====================
|
||||
|
||||
export type TranslationStatus = 'pending' | 'loading_model' | 'translating' | 'completed' | 'failed'
|
||||
|
||||
Reference in New Issue
Block a user