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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user