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>