Introduces core backend and frontend infrastructure for a PDF translation interface. Adds API endpoints for translation, PDF testing, and AI provider testing; implements PDF text extraction, cost tracking, and pricing logic in the lib directory; adds reusable UI components; and provides comprehensive documentation (SDD, environment setup, Claude instructions). Updates Tailwind and global styles, and includes a sample test PDF and configuration files.
771 lines
35 KiB
TypeScript
771 lines
35 KiB
TypeScript
"use client"
|
||
|
||
import type React from "react"
|
||
|
||
import { useState, useEffect } from "react"
|
||
import { Upload, FileText, Languages, Loader2, Download, FileDown, DollarSign, Hash, TrendingUp, Play, Pause, Square, Volume2 } from "lucide-react"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Card } from "@/components/ui/card"
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||
import { Label } from "@/components/ui/label"
|
||
import { Checkbox } from "@/components/ui/checkbox"
|
||
import { costTracker } from "@/lib/cost-tracker"
|
||
import type { CostSummary } from "@/lib/cost-tracker"
|
||
|
||
const LANGUAGES = [
|
||
{ code: "auto", name: "自動偵測" },
|
||
{ code: "zh-TW", name: "繁體中文" },
|
||
{ code: "zh-CN", name: "簡體中文" },
|
||
{ code: "en", name: "English" },
|
||
{ code: "ja", name: "日本語" },
|
||
{ code: "ko", name: "한국어" },
|
||
{ code: "es", name: "Español" },
|
||
{ code: "fr", name: "Français" },
|
||
{ code: "de", name: "Deutsch" },
|
||
{ code: "it", name: "Italiano" },
|
||
{ code: "pt", name: "Português" },
|
||
{ code: "ru", name: "Русский" },
|
||
{ code: "ar", name: "العربية" },
|
||
{ code: "hi", name: "हिन्दी" },
|
||
{ code: "th", name: "ไทย" },
|
||
{ code: "vi", name: "Tiếng Việt" },
|
||
]
|
||
|
||
export function PDFTranslator() {
|
||
const [file, setFile] = useState<File | null>(null)
|
||
const [sourceLanguage, setSourceLanguage] = useState<string>("auto")
|
||
const [targetLanguage, setTargetLanguage] = useState<string>("")
|
||
const [isTranslating, setIsTranslating] = useState(false)
|
||
const [translatedText, setTranslatedText] = useState<string>("")
|
||
const [translatedPDFBase64, setTranslatedPDFBase64] = useState<string>("")
|
||
const [isDragging, setIsDragging] = useState(false)
|
||
const [generatePDF, setGeneratePDF] = useState(true)
|
||
const [tokenUsage, setTokenUsage] = useState<any>(null)
|
||
const [cost, setCost] = useState<any>(null)
|
||
const [model, setModel] = useState<any>(null)
|
||
const [costSummary, setCostSummary] = useState<CostSummary | null>(null)
|
||
|
||
// Speech synthesis states
|
||
const [isPlaying, setIsPlaying] = useState(false)
|
||
const [isPaused, setIsPaused] = useState(false)
|
||
const [speechSupported, setSpeechSupported] = useState(false)
|
||
const [selectedVoice, setSelectedVoice] = useState<string>("")
|
||
const [availableVoices, setAvailableVoices] = useState<SpeechSynthesisVoice[]>([])
|
||
const [speechRate, setSpeechRate] = useState(1.0)
|
||
const [speechVolume, setSpeechVolume] = useState(1.0)
|
||
|
||
// Load cost summary on component mount
|
||
useEffect(() => {
|
||
const summary = costTracker.getCostSummary()
|
||
setCostSummary(summary)
|
||
}, [])
|
||
|
||
// Initialize speech synthesis
|
||
useEffect(() => {
|
||
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
|
||
setSpeechSupported(true)
|
||
|
||
// Load available voices
|
||
const loadVoices = () => {
|
||
const voices = speechSynthesis.getVoices()
|
||
setAvailableVoices(voices)
|
||
|
||
// Auto-select voice based on target language
|
||
if (targetLanguage && voices.length > 0) {
|
||
const preferredVoice = findPreferredVoice(voices, targetLanguage)
|
||
if (preferredVoice) {
|
||
setSelectedVoice(preferredVoice.name)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Load voices immediately and on voiceschanged event
|
||
loadVoices()
|
||
speechSynthesis.addEventListener('voiceschanged', loadVoices)
|
||
|
||
return () => {
|
||
speechSynthesis.removeEventListener('voiceschanged', loadVoices)
|
||
speechSynthesis.cancel() // Cancel any ongoing speech
|
||
}
|
||
}
|
||
}, [targetLanguage])
|
||
|
||
// Helper function to find preferred voice for a language
|
||
const findPreferredVoice = (voices: SpeechSynthesisVoice[], langCode: string) => {
|
||
// Map language codes to speech synthesis language codes
|
||
const langMap: Record<string, string[]> = {
|
||
'zh-TW': ['zh-TW', 'zh-HK', 'zh'],
|
||
'zh-CN': ['zh-CN', 'zh'],
|
||
'en': ['en-US', 'en-GB', 'en'],
|
||
'ja': ['ja-JP', 'ja'],
|
||
'ko': ['ko-KR', 'ko'],
|
||
'es': ['es-ES', 'es-US', 'es'],
|
||
'fr': ['fr-FR', 'fr'],
|
||
'de': ['de-DE', 'de'],
|
||
'it': ['it-IT', 'it'],
|
||
'pt': ['pt-BR', 'pt-PT', 'pt'],
|
||
'ru': ['ru-RU', 'ru'],
|
||
'ar': ['ar-SA', 'ar'],
|
||
'hi': ['hi-IN', 'hi'],
|
||
'th': ['th-TH', 'th'],
|
||
'vi': ['vi-VN', 'vi']
|
||
}
|
||
|
||
const targetLangs = langMap[langCode] || [langCode]
|
||
|
||
for (const targetLang of targetLangs) {
|
||
const voice = voices.find(v => v.lang.startsWith(targetLang))
|
||
if (voice) return voice
|
||
}
|
||
|
||
return voices[0] // Fallback to first available voice
|
||
}
|
||
|
||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const selectedFile = e.target.files?.[0]
|
||
if (selectedFile) {
|
||
const isPDF = selectedFile.type === "application/pdf"
|
||
|
||
if (isPDF) {
|
||
setFile(selectedFile)
|
||
setTranslatedText("")
|
||
setTranslatedPDFBase64("")
|
||
setTokenUsage(null)
|
||
setCost(null)
|
||
setModel(null)
|
||
} else {
|
||
alert("目前僅支援 PDF 文件")
|
||
}
|
||
}
|
||
}
|
||
|
||
const handleDragOver = (e: React.DragEvent) => {
|
||
e.preventDefault()
|
||
setIsDragging(true)
|
||
}
|
||
|
||
const handleDragLeave = () => {
|
||
setIsDragging(false)
|
||
}
|
||
|
||
const handleDrop = (e: React.DragEvent) => {
|
||
e.preventDefault()
|
||
setIsDragging(false)
|
||
|
||
const droppedFile = e.dataTransfer.files[0]
|
||
if (droppedFile) {
|
||
const isPDF = droppedFile.type === "application/pdf"
|
||
|
||
if (isPDF) {
|
||
setFile(droppedFile)
|
||
setTranslatedText("")
|
||
setTranslatedPDFBase64("")
|
||
setTokenUsage(null)
|
||
setCost(null)
|
||
setModel(null)
|
||
} else {
|
||
alert("目前僅支援 PDF 文件")
|
||
}
|
||
}
|
||
}
|
||
|
||
const handleTranslate = async () => {
|
||
if (!file || !targetLanguage) {
|
||
alert("請選擇檔案和目標語言")
|
||
return
|
||
}
|
||
|
||
setIsTranslating(true)
|
||
setTranslatedText("")
|
||
setTranslatedPDFBase64("")
|
||
setTokenUsage(null)
|
||
setCost(null)
|
||
setModel(null)
|
||
|
||
try {
|
||
const formData = new FormData()
|
||
formData.append("file", file)
|
||
formData.append("sourceLanguage", sourceLanguage)
|
||
formData.append("targetLanguage", targetLanguage)
|
||
formData.append("returnPDF", generatePDF.toString())
|
||
|
||
const response = await fetch("/api/translate", {
|
||
method: "POST",
|
||
body: formData,
|
||
})
|
||
|
||
const data = await response.json()
|
||
|
||
if (!response.ok) {
|
||
throw new Error(data.error || "翻譯失敗")
|
||
}
|
||
|
||
setTranslatedText(data.translatedText)
|
||
if (data.pdfBase64) {
|
||
setTranslatedPDFBase64(data.pdfBase64)
|
||
}
|
||
|
||
// Set token usage and cost information
|
||
if (data.tokenUsage) {
|
||
setTokenUsage(data.tokenUsage)
|
||
}
|
||
if (data.cost) {
|
||
setCost(data.cost)
|
||
}
|
||
if (data.model) {
|
||
setModel(data.model)
|
||
}
|
||
|
||
// Track cost in accumulator
|
||
if (data.costSession) {
|
||
const updatedSummary = costTracker.addCostSession(data.costSession)
|
||
setCostSummary(updatedSummary)
|
||
}
|
||
} catch (error) {
|
||
console.error("Translation error:", error)
|
||
alert(error instanceof Error ? error.message : "翻譯過程中發生錯誤")
|
||
} finally {
|
||
setIsTranslating(false)
|
||
}
|
||
}
|
||
|
||
const downloadTranslatedText = () => {
|
||
if (!translatedText) return
|
||
|
||
const blob = new Blob([translatedText], { type: "text/plain;charset=utf-8" })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement("a")
|
||
a.href = url
|
||
a.download = `translated_${file?.name?.replace(".pdf", ".txt") || "document.txt"}`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
// Speech synthesis functions
|
||
const playText = () => {
|
||
if (!speechSupported || !translatedText) return
|
||
|
||
// If paused, resume
|
||
if (isPaused) {
|
||
speechSynthesis.resume()
|
||
setIsPaused(false)
|
||
setIsPlaying(true)
|
||
return
|
||
}
|
||
|
||
// If already playing, do nothing
|
||
if (isPlaying) return
|
||
|
||
// Cancel any existing speech
|
||
speechSynthesis.cancel()
|
||
|
||
const utterance = new SpeechSynthesisUtterance(translatedText)
|
||
|
||
// Configure voice
|
||
if (selectedVoice) {
|
||
const voice = availableVoices.find(v => v.name === selectedVoice)
|
||
if (voice) {
|
||
utterance.voice = voice
|
||
}
|
||
}
|
||
|
||
// Configure speech parameters
|
||
utterance.rate = speechRate
|
||
utterance.volume = speechVolume
|
||
utterance.pitch = 1.0
|
||
|
||
// Event handlers
|
||
utterance.onstart = () => {
|
||
setIsPlaying(true)
|
||
setIsPaused(false)
|
||
}
|
||
|
||
utterance.onend = () => {
|
||
setIsPlaying(false)
|
||
setIsPaused(false)
|
||
}
|
||
|
||
utterance.onerror = (event) => {
|
||
console.error('Speech synthesis error:', event.error)
|
||
setIsPlaying(false)
|
||
setIsPaused(false)
|
||
}
|
||
|
||
utterance.onpause = () => {
|
||
setIsPaused(true)
|
||
setIsPlaying(false)
|
||
}
|
||
|
||
utterance.onresume = () => {
|
||
setIsPaused(false)
|
||
setIsPlaying(true)
|
||
}
|
||
|
||
speechSynthesis.speak(utterance)
|
||
}
|
||
|
||
const pauseText = () => {
|
||
if (speechSupported && isPlaying) {
|
||
speechSynthesis.pause()
|
||
}
|
||
}
|
||
|
||
const stopText = () => {
|
||
if (speechSupported) {
|
||
speechSynthesis.cancel()
|
||
setIsPlaying(false)
|
||
setIsPaused(false)
|
||
}
|
||
}
|
||
|
||
const downloadTranslatedPDF = () => {
|
||
if (!translatedPDFBase64) return
|
||
|
||
// Convert base64 to blob
|
||
const byteCharacters = atob(translatedPDFBase64)
|
||
const byteNumbers = new Array(byteCharacters.length)
|
||
for (let i = 0; i < byteCharacters.length; i++) {
|
||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||
}
|
||
const byteArray = new Uint8Array(byteNumbers)
|
||
const blob = new Blob([byteArray], { type: "application/pdf" })
|
||
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement("a")
|
||
a.href = url
|
||
a.download = `translated_${file?.name || "document.pdf"}`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
const resetCostTracking = () => {
|
||
if (confirm("確定要重置費用追蹤記錄嗎?這個操作無法復原。")) {
|
||
const resetSummary = costTracker.resetCostTracking()
|
||
setCostSummary(resetSummary)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-amber-50 via-green-50 to-teal-50 dark:from-slate-800 dark:via-slate-900 dark:to-slate-800">
|
||
{/* 文清楓背景裝飾 */}
|
||
<div className="absolute inset-0 opacity-10">
|
||
<div className="absolute top-20 left-10 w-32 h-32 bg-amber-200 rounded-full blur-xl"></div>
|
||
<div className="absolute top-40 right-20 w-40 h-40 bg-green-200 rounded-full blur-xl"></div>
|
||
<div className="absolute bottom-32 left-1/4 w-36 h-36 bg-teal-200 rounded-full blur-xl"></div>
|
||
<div className="absolute bottom-20 right-10 w-28 h-28 bg-amber-300 rounded-full blur-xl"></div>
|
||
</div>
|
||
|
||
<div className="relative container mx-auto p-4 sm:p-6 max-w-7xl">
|
||
<div className="text-center mb-8 sm:mb-12">
|
||
<div className="mb-4 sm:mb-6">
|
||
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold mb-3 sm:mb-4 bg-clip-text text-transparent bg-gradient-to-r from-amber-600 via-green-600 to-teal-600">
|
||
文清楓翻譯
|
||
</h1>
|
||
<div className="flex items-center justify-center gap-2 mb-2 flex-wrap">
|
||
<span className="text-xl sm:text-2xl">🍃</span>
|
||
<span className="text-base sm:text-lg lg:text-xl text-amber-600 font-medium text-center">清雅 · 自然 · 智慧</span>
|
||
<span className="text-xl sm:text-2xl">🍂</span>
|
||
</div>
|
||
</div>
|
||
<p className="text-amber-700 dark:text-amber-300 text-base sm:text-lg font-medium leading-relaxed px-4">
|
||
以文雅之心,譯天下之言
|
||
<br />
|
||
<span className="text-green-600 dark:text-green-400 text-sm sm:text-base">
|
||
上傳 PDF 文件,體驗清新典雅的多語翻譯之旅
|
||
</span>
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6 lg:gap-8">
|
||
{/* Upload Section */}
|
||
<Card className="p-4 sm:p-6 lg:p-8 shadow-xl border-amber-200 dark:border-amber-800 bg-gradient-to-br from-amber-50/80 via-white to-green-50/80 dark:from-slate-800/90 dark:via-slate-900/90 dark:to-slate-800/90 backdrop-blur-sm">
|
||
<h2 className="text-xl sm:text-2xl font-semibold mb-4 sm:mb-6 flex items-center gap-2 text-amber-800 dark:text-amber-300">
|
||
<Upload className="h-5 w-5 sm:h-6 sm:w-6 text-green-600 dark:text-green-400" />
|
||
📄 上傳文件
|
||
</h2>
|
||
|
||
<div
|
||
className={`border-2 sm:border-3 border-dashed rounded-xl p-6 sm:p-8 lg:p-12 text-center transition-all ${
|
||
isDragging
|
||
? "border-green-500 bg-green-50/50 dark:bg-green-950/50 shadow-lg"
|
||
: "border-amber-300 hover:border-green-400 hover:shadow-md"
|
||
}`}
|
||
onDragOver={handleDragOver}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={handleDrop}
|
||
>
|
||
<FileText className="h-12 w-12 sm:h-16 sm:w-16 mx-auto mb-3 sm:mb-4 text-amber-400 dark:text-amber-500" />
|
||
<p className="text-amber-700 dark:text-amber-300 mb-3 sm:mb-4 font-medium text-sm sm:text-base">
|
||
🍃 拖曳 PDF 文件到此處,或點擊選擇文件 🍃
|
||
</p>
|
||
<p className="text-xs sm:text-sm text-green-600 dark:text-green-400 mb-3 sm:mb-4">
|
||
支援包含文字的 PDF 文件
|
||
</p>
|
||
<div className="flex gap-2 sm:gap-3 justify-center">
|
||
<input
|
||
type="file"
|
||
accept=".pdf"
|
||
onChange={handleFileChange}
|
||
className="hidden"
|
||
id="file-upload"
|
||
/>
|
||
<label htmlFor="file-upload">
|
||
<Button variant="outline" asChild className="border-amber-300 text-amber-700 hover:bg-amber-50 hover:border-amber-400 dark:border-amber-600 dark:text-amber-300 dark:hover:bg-amber-950/30 text-sm sm:text-base px-4 sm:px-6">
|
||
<span>
|
||
<Upload className="mr-1 sm:mr-2 h-4 w-4" />
|
||
📁 選擇 PDF 文件
|
||
</span>
|
||
</Button>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{file && (
|
||
<div className="mt-4 sm:mt-6 p-3 sm:p-4 bg-gradient-to-r from-green-50 via-teal-50 to-green-50 dark:from-green-950/50 dark:via-teal-950/50 dark:to-green-950/50 rounded-lg border border-green-200 dark:border-green-800">
|
||
<div className="flex items-center gap-2 text-green-700 dark:text-green-300 font-medium mb-2 text-sm sm:text-base">
|
||
{file.type.startsWith("image/") ? (
|
||
<Image className="h-4 w-4 flex-shrink-0" />
|
||
) : (
|
||
<FileText className="h-4 w-4 flex-shrink-0" />
|
||
)}
|
||
<span className="truncate">✅ 已選擇: {file.name}</span>
|
||
</div>
|
||
<div className="text-xs sm:text-sm text-green-600 dark:text-green-400 space-y-1">
|
||
<p>📄 檔案類型: PDF 檔案</p>
|
||
<p>📏 大小: {(file.size / 1024 / 1024).toFixed(2)} MB</p>
|
||
<p>🔧 處理方式: 文字提取</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-4 sm:mt-6 space-y-3 sm:space-y-4">
|
||
<div>
|
||
<Label htmlFor="source-language" className="text-amber-700 dark:text-amber-300 font-medium text-sm sm:text-base">🌍 來源語言</Label>
|
||
<Select value={sourceLanguage} onValueChange={setSourceLanguage}>
|
||
<SelectTrigger id="source-language" className="mt-2 border-amber-200 focus:border-green-400 dark:border-amber-700 dark:focus:border-green-500 h-10 sm:h-auto">
|
||
<SelectValue placeholder="選擇來源語言" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{LANGUAGES.map((lang) => (
|
||
<SelectItem key={lang.code} value={lang.code}>
|
||
{lang.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div>
|
||
<Label htmlFor="target-language" className="text-amber-700 dark:text-amber-300 font-medium text-sm sm:text-base">🎯 目標語言</Label>
|
||
<Select value={targetLanguage} onValueChange={setTargetLanguage}>
|
||
<SelectTrigger id="target-language" className="mt-2 border-amber-200 focus:border-green-400 dark:border-amber-700 dark:focus:border-green-500 h-10 sm:h-auto">
|
||
<SelectValue placeholder="選擇目標語言" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{LANGUAGES.filter(lang => lang.code !== "auto").map((lang) => (
|
||
<SelectItem key={lang.code} value={lang.code}>
|
||
{lang.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="flex items-center space-x-2">
|
||
<Checkbox
|
||
id="generate-pdf"
|
||
checked={generatePDF}
|
||
onCheckedChange={(checked) => setGeneratePDF(checked as boolean)}
|
||
/>
|
||
<Label htmlFor="generate-pdf" className="cursor-pointer text-green-700 dark:text-green-300 font-medium text-sm sm:text-base">
|
||
📋 生成翻譯後的 PDF 檔案
|
||
</Label>
|
||
</div>
|
||
|
||
<Button
|
||
onClick={handleTranslate}
|
||
disabled={!file || !targetLanguage || isTranslating}
|
||
className="w-full h-10 sm:h-12 text-base sm:text-lg bg-gradient-to-r from-amber-500 via-green-500 to-teal-500 hover:from-amber-600 hover:via-green-600 hover:to-teal-600 text-white font-semibold shadow-lg transition-all duration-300 transform hover:scale-105"
|
||
size="lg"
|
||
>
|
||
{isTranslating ? (
|
||
<>
|
||
<Loader2 className="mr-1 sm:mr-2 h-4 w-4 sm:h-5 sm:w-5 animate-spin" />
|
||
🔄 翻譯中...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Languages className="mr-1 sm:mr-2 h-4 w-4 sm:h-5 sm:w-5" />
|
||
✨ 開始翻譯
|
||
</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* Result Section */}
|
||
<Card className="p-4 sm:p-6 lg:p-8 shadow-xl border-teal-200 dark:border-teal-800 bg-gradient-to-br from-teal-50/80 via-white to-green-50/80 dark:from-slate-800/90 dark:via-slate-900/90 dark:to-slate-800/90 backdrop-blur-sm">
|
||
<h2 className="text-xl sm:text-2xl font-semibold mb-4 sm:mb-6 flex items-center gap-2 text-teal-800 dark:text-teal-300">
|
||
<FileText className="h-5 w-5 sm:h-6 sm:w-6 text-amber-600 dark:text-amber-400" />
|
||
🌿 翻譯結果
|
||
</h2>
|
||
|
||
{translatedText ? (
|
||
<div className="space-y-3 sm:space-y-4">
|
||
{/* Token Usage and Cost Information */}
|
||
{tokenUsage && cost && model && (
|
||
<div className="bg-gradient-to-r from-amber-50 via-green-50 to-teal-50 dark:from-amber-950/30 dark:via-green-950/30 dark:to-teal-950/30 rounded-lg p-3 sm:p-4 space-y-3 border border-amber-200 dark:border-amber-800">
|
||
<div className="flex items-center gap-2 text-amber-700 dark:text-amber-300 font-medium text-sm sm:text-base">
|
||
<Hash className="h-4 w-4 flex-shrink-0" />
|
||
📊 Token 使用量與費用
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4 text-xs sm:text-sm">
|
||
<div>
|
||
<div className="text-gray-600 dark:text-gray-400">AI 模型</div>
|
||
<div className="font-medium">{model.displayName}</div>
|
||
<div className="text-xs text-gray-500">({model.provider})</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="text-gray-600 dark:text-gray-400">總 Token 數</div>
|
||
<div className="font-medium">{tokenUsage.formattedCounts.total}</div>
|
||
<div className="text-xs text-gray-500">
|
||
輸入: {tokenUsage.formattedCounts.prompt} | 輸出: {tokenUsage.formattedCounts.completion}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="text-gray-600 dark:text-gray-400 flex items-center gap-1">
|
||
<DollarSign className="h-3 w-3" />
|
||
翻譯費用
|
||
</div>
|
||
<div className="font-medium text-lg text-green-600 dark:text-green-400">
|
||
{cost.formattedCost}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="text-gray-600 dark:text-gray-400">費用分析</div>
|
||
<div className="text-xs space-y-1">
|
||
<div>輸入: ${cost.inputCost.toFixed(6)}</div>
|
||
<div>輸出: ${cost.outputCost.toFixed(6)}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Cumulative Cost Summary */}
|
||
{costSummary && costSummary.totalSessions > 0 && (
|
||
<div className="bg-gradient-to-r from-green-50 via-teal-50 to-green-50 dark:from-green-950/30 dark:via-teal-950/30 dark:to-green-950/30 rounded-lg p-3 sm:p-4 space-y-3 border border-green-200 dark:border-green-800">
|
||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||
<div className="flex items-center gap-2 text-green-700 dark:text-green-300 font-medium text-sm sm:text-base">
|
||
<TrendingUp className="h-4 w-4 flex-shrink-0" />
|
||
📈 累積費用統計
|
||
</div>
|
||
<Button
|
||
onClick={resetCostTracking}
|
||
variant="outline"
|
||
size="sm"
|
||
className="text-xs border-green-300 text-green-700 hover:bg-green-50 dark:border-green-600 dark:text-green-300 dark:hover:bg-green-950/30 px-2 sm:px-3 py-1"
|
||
>
|
||
<span className="hidden sm:inline">🔄 重置記錄</span>
|
||
<span className="sm:hidden">🔄</span>
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 sm:gap-3 text-xs sm:text-sm">
|
||
<div className="text-center p-2 bg-gradient-to-br from-amber-50 to-yellow-50 dark:from-amber-950/20 dark:to-yellow-950/20 rounded border border-amber-200 dark:border-amber-800">
|
||
<div className="text-amber-600 dark:text-amber-400 text-xs">📊 總翻譯次數</div>
|
||
<div className="font-bold text-lg">{costSummary.totalSessions}</div>
|
||
</div>
|
||
|
||
<div className="text-center p-2 bg-gradient-to-br from-green-50 to-teal-50 dark:from-green-950/20 dark:to-teal-950/20 rounded border border-green-200 dark:border-green-800">
|
||
<div className="text-green-600 dark:text-green-400 text-xs">💬 總 Token 數</div>
|
||
<div className="font-bold text-lg">{costTracker.formatNumber(costSummary.totalTokens)}</div>
|
||
</div>
|
||
|
||
<div className="text-center p-2 bg-gradient-to-br from-red-50 to-pink-50 dark:from-red-950/20 dark:to-pink-950/20 rounded border border-red-200 dark:border-red-800">
|
||
<div className="text-red-600 dark:text-red-400 text-xs">💰 累積費用</div>
|
||
<div className="font-bold text-lg text-red-600 dark:text-red-400">
|
||
{costTracker.formatCost(costSummary.totalCost, costSummary.currency)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="text-center p-2 bg-gradient-to-br from-teal-50 to-cyan-50 dark:from-teal-950/20 dark:to-cyan-950/20 rounded border border-teal-200 dark:border-teal-800">
|
||
<div className="text-teal-600 dark:text-teal-400 text-xs">💵 平均費用</div>
|
||
<div className="font-bold text-lg">
|
||
{costTracker.formatCost(costSummary.totalCost / costSummary.totalSessions, costSummary.currency)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{Object.keys(costSummary.byProvider).length > 0 && (
|
||
<div className="pt-2 border-t border-green-200 dark:border-green-700">
|
||
<div className="text-xs text-green-600 dark:text-green-400 mb-2">📉 使用統計:</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{Object.entries(costSummary.byProvider).map(([provider, stats]) => (
|
||
<div key={provider} className="text-xs bg-gradient-to-r from-amber-50 to-green-50 dark:from-amber-950/20 dark:to-green-950/20 border border-amber-200 dark:border-amber-700 px-2 py-1 rounded">
|
||
{provider}: {stats.sessions}次 ({costTracker.formatCost(stats.cost, costSummary.currency)})
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Speech Synthesis Controls */}
|
||
{speechSupported && (
|
||
<div className="bg-gradient-to-r from-teal-50 via-green-50 to-amber-50 dark:from-teal-950/40 dark:via-green-950/40 dark:to-amber-950/40 rounded-lg p-3 sm:p-4 border border-teal-200 dark:border-teal-800">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<Volume2 className="h-4 w-4 sm:h-5 sm:w-5 text-teal-600 dark:text-teal-400 flex-shrink-0" />
|
||
<span className="font-medium text-teal-700 dark:text-teal-300 text-sm sm:text-base">🔊 語音播放</span>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 sm:gap-3 flex-wrap">
|
||
{/* Play/Pause/Stop Controls */}
|
||
<div className="flex gap-1 sm:gap-2">
|
||
<Button
|
||
onClick={playText}
|
||
disabled={!translatedText || isPlaying}
|
||
size="sm"
|
||
variant={isPlaying ? "secondary" : "default"}
|
||
className="flex items-center gap-1 sm:gap-2 bg-gradient-to-r from-green-500 to-teal-500 hover:from-green-600 hover:to-teal-600 text-white text-xs sm:text-sm px-2 sm:px-3"
|
||
>
|
||
<Play className="h-3 w-3 sm:h-4 sm:w-4" />
|
||
<span className="hidden sm:inline">{isPaused ? "繼續" : "播放"}</span>
|
||
<span className="sm:hidden">{isPaused ? "▶️" : "🔊"}</span>
|
||
</Button>
|
||
|
||
<Button
|
||
onClick={pauseText}
|
||
disabled={!isPlaying}
|
||
size="sm"
|
||
variant="outline"
|
||
className="flex items-center gap-1 sm:gap-2 border-amber-300 text-amber-700 hover:bg-amber-50 dark:border-amber-600 dark:text-amber-300 dark:hover:bg-amber-950/30 text-xs sm:text-sm px-2 sm:px-3"
|
||
>
|
||
<Pause className="h-3 w-3 sm:h-4 sm:w-4" />
|
||
<span className="hidden sm:inline">暫停</span>
|
||
<span className="sm:hidden">⏸️</span>
|
||
</Button>
|
||
|
||
<Button
|
||
onClick={stopText}
|
||
disabled={!isPlaying && !isPaused}
|
||
size="sm"
|
||
variant="outline"
|
||
className="flex items-center gap-1 sm:gap-2 border-red-300 text-red-700 hover:bg-red-50 dark:border-red-600 dark:text-red-300 dark:hover:bg-red-950/30 text-xs sm:text-sm px-2 sm:px-3"
|
||
>
|
||
<Square className="h-3 w-3 sm:h-4 sm:w-4" />
|
||
<span className="hidden sm:inline">停止</span>
|
||
<span className="sm:hidden">⏹️</span>
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Voice Selection */}
|
||
{availableVoices.length > 0 && (
|
||
<div className="flex items-center gap-1 sm:gap-2 w-full sm:w-auto">
|
||
<Label htmlFor="voice-select" className="text-xs sm:text-sm text-teal-700 dark:text-teal-300 flex-shrink-0">
|
||
<span className="hidden sm:inline">🎤 語音:</span>
|
||
<span className="sm:hidden">🎤</span>
|
||
</Label>
|
||
<Select value={selectedVoice} onValueChange={setSelectedVoice}>
|
||
<SelectTrigger className="w-full sm:w-40 lg:w-48 h-8 text-xs sm:text-sm border-teal-200 focus:border-green-400 dark:border-teal-700 dark:focus:border-green-500">
|
||
<SelectValue placeholder="選擇語音" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{availableVoices
|
||
.filter(voice => voice.lang.includes(targetLanguage?.split('-')[0] || 'en'))
|
||
.map((voice) => (
|
||
<SelectItem key={voice.name} value={voice.name}>
|
||
{voice.name} ({voice.lang})
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
|
||
{/* Speech Rate Control */}
|
||
<div className="flex items-center gap-1 sm:gap-2 w-full sm:w-auto">
|
||
<Label className="text-xs sm:text-sm text-teal-700 dark:text-teal-300 flex-shrink-0">
|
||
<span className="hidden sm:inline">⚡ 速度:</span>
|
||
<span className="sm:hidden">⚡</span>
|
||
</Label>
|
||
<input
|
||
type="range"
|
||
min="0.5"
|
||
max="2"
|
||
step="0.1"
|
||
value={speechRate}
|
||
onChange={(e) => setSpeechRate(Number(e.target.value))}
|
||
className="flex-1 sm:w-16 lg:w-20 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
|
||
/>
|
||
<span className="text-xs sm:text-sm text-gray-600 dark:text-gray-400 w-8 sm:w-10 text-center">
|
||
{speechRate.toFixed(1)}x
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Status Indicator */}
|
||
{(isPlaying || isPaused) && (
|
||
<div className="mt-3 flex items-center gap-2 text-sm">
|
||
<div className={`w-2 h-2 rounded-full ${isPlaying ? 'bg-green-500 animate-pulse' : 'bg-yellow-500'}`}></div>
|
||
<span className="text-gray-600 dark:text-gray-400">
|
||
{isPlaying ? '正在播放...' : isPaused ? '已暫停' : ''}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="bg-gradient-to-br from-amber-50/50 via-green-50/50 to-teal-50/50 dark:from-slate-800/50 dark:via-slate-900/50 dark:to-slate-800/50 rounded-lg p-3 sm:p-4 lg:p-6 max-h-64 sm:max-h-80 lg:max-h-96 overflow-y-auto border border-amber-200 dark:border-amber-800">
|
||
<pre className="whitespace-pre-wrap font-sans text-amber-800 dark:text-amber-200 text-sm sm:text-base">
|
||
{translatedText}
|
||
</pre>
|
||
</div>
|
||
|
||
<div className="flex flex-col sm:flex-row gap-2 sm:gap-3">
|
||
<Button
|
||
onClick={downloadTranslatedText}
|
||
variant="outline"
|
||
className="flex-1 border-green-300 text-green-700 hover:bg-green-50 dark:border-green-600 dark:text-green-300 dark:hover:bg-green-950/30 text-sm sm:text-base h-9 sm:h-10"
|
||
>
|
||
<Download className="mr-1 sm:mr-2 h-4 w-4" />
|
||
<span className="hidden sm:inline">📄 下載文字檔 (.txt)</span>
|
||
<span className="sm:hidden">📄 文字檔</span>
|
||
</Button>
|
||
|
||
{translatedPDFBase64 && (
|
||
<Button
|
||
onClick={downloadTranslatedPDF}
|
||
className="flex-1 bg-gradient-to-r from-teal-500 via-green-500 to-amber-500 hover:from-teal-600 hover:via-green-600 hover:to-amber-600 text-white font-semibold text-sm sm:text-base h-9 sm:h-10"
|
||
>
|
||
<FileDown className="mr-1 sm:mr-2 h-4 w-4" />
|
||
<span className="hidden sm:inline">📁 下載 PDF 檔案</span>
|
||
<span className="sm:hidden">📁 PDF</span>
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="h-64 sm:h-80 lg:h-96 flex items-center justify-center text-amber-400 dark:text-amber-500">
|
||
<div className="text-center px-4">
|
||
<Languages className="h-12 w-12 sm:h-16 sm:w-16 mx-auto mb-3 sm:mb-4 opacity-50 text-green-400 dark:text-green-500" />
|
||
<p className="text-teal-600 dark:text-teal-400 font-medium text-sm sm:text-base">🌿 翻譯結果將在此顯示</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
)
|
||
} |