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:
egg
2025-12-12 17:38:12 +08:00
parent d20751d56b
commit 65abd51d60
21 changed files with 682 additions and 662 deletions

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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>
)
}