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.
1100 lines
28 KiB
Markdown
1100 lines
28 KiB
Markdown
# 技術設計文件 (Technical Design Document)
|
||
|
||
## 1. 技術概述
|
||
|
||
### 1.1 系統架構
|
||
PDF Translation Interface 採用現代化的全端技術堆疊,基於 Next.js 15 App Router 架構,提供高效能的 PDF 文件翻譯服務。
|
||
|
||
### 1.2 核心技術選擇理由
|
||
|
||
#### 前端技術選擇
|
||
- **Next.js 15 App Router**: 最新的 React 框架,提供更好的 SEO 和效能
|
||
- **React 19**: 最新的狀態管理和渲染優化
|
||
- **TypeScript**: 強型別系統,提高程式碼品質和維護性
|
||
- **Tailwind CSS v4**: 現代化的 CSS 框架,提供快速開發和優秀的自訂性
|
||
|
||
#### 後端技術選擇
|
||
- **Next.js API Routes**: 與前端整合,簡化部署和維護
|
||
- **pdf-parse + pdf2json**: 多層備援的 PDF 文字擷取策略
|
||
- **Tesseract.js**: 客戶端 OCR,減少伺服器負載
|
||
- **PDFKit**: 強大的 PDF 生成能力,支援 Unicode
|
||
|
||
## 2. 系統架構詳細設計
|
||
|
||
### 2.1 前端架構
|
||
|
||
```typescript
|
||
// 主要目錄結構
|
||
/app // Next.js 15 App Router
|
||
├── globals.css // 全域樣式
|
||
├── layout.tsx // 根佈局
|
||
├── page.tsx // 首頁
|
||
└── api/ // API 路由
|
||
└── translate/
|
||
└── route.ts // 翻譯 API 端點
|
||
|
||
/components // React 元件
|
||
├── ui/ // 基礎 UI 元件 (shadcn/ui)
|
||
│ ├── button.tsx
|
||
│ ├── card.tsx
|
||
│ ├── select.tsx
|
||
│ ├── label.tsx
|
||
│ └── checkbox.tsx
|
||
└── pdf-translator.tsx // 主要應用程式元件
|
||
|
||
/lib // 工具函式與邏輯
|
||
├── utils.ts // 通用工具函式
|
||
├── pdf-processor.ts // PDF 處理核心邏輯
|
||
├── pdf-to-image.ts // PDF 轉圖片功能
|
||
└── cost-tracker.ts // 費用追蹤系統
|
||
```
|
||
|
||
### 2.2 狀態管理架構
|
||
|
||
```typescript
|
||
// PDFTranslator 元件狀態設計
|
||
interface PDFTranslatorState {
|
||
// 檔案處理
|
||
file: File | null
|
||
isDragging: boolean
|
||
|
||
// 語言設定
|
||
sourceLanguage: string
|
||
targetLanguage: string
|
||
|
||
// 翻譯流程
|
||
isTranslating: boolean
|
||
translatedText: string
|
||
translatedPDFBase64: string
|
||
generatePDF: boolean
|
||
|
||
// 語音功能
|
||
isPlaying: boolean
|
||
isPaused: boolean
|
||
speechSupported: boolean
|
||
selectedVoice: string
|
||
availableVoices: SpeechSynthesisVoice[]
|
||
speechRate: number
|
||
speechVolume: number
|
||
|
||
// 費用追蹤
|
||
tokenUsage: TokenUsage | null
|
||
cost: CostInfo | null
|
||
model: ModelInfo | null
|
||
costSummary: CostSummary | null
|
||
}
|
||
```
|
||
|
||
## 3. 核心模組技術實作
|
||
|
||
### 3.1 PDF 處理模組 (pdf-processor.ts)
|
||
|
||
#### 3.1.1 多層文字擷取策略
|
||
|
||
```typescript
|
||
export async function extractTextFromPDF(buffer: Buffer): Promise<PDFProcessResult> {
|
||
// 第一層:pdf-parse (主要方法)
|
||
try {
|
||
const pdfParseModule = await import('pdf-parse')
|
||
const result = await pdfParseModule(buffer)
|
||
if (result.text?.trim().length > 10) {
|
||
return { text: result.text, pageCount: result.numpages, isScanned: false }
|
||
}
|
||
} catch (error) {
|
||
console.log('pdf-parse failed, falling back to pdf2json')
|
||
}
|
||
|
||
// 第二層:pdf2json (備援方法)
|
||
try {
|
||
const PDFParser = require('pdf2json')
|
||
const pdfParser = new PDFParser()
|
||
|
||
const parseResult = await new Promise((resolve, reject) => {
|
||
pdfParser.on('pdfParser_dataReady', (pdfData: any) => {
|
||
let text = ''
|
||
if (pdfData.Pages) {
|
||
for (const page of pdfData.Pages) {
|
||
if (page.Texts) {
|
||
for (const textItem of page.Texts) {
|
||
if (textItem.R) {
|
||
for (const run of textItem.R) {
|
||
if (run.T) {
|
||
text += decodeURIComponent(run.T) + ' '
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
text += '\n'
|
||
}
|
||
}
|
||
resolve(text.trim())
|
||
})
|
||
|
||
pdfParser.on('pdfParser_dataError', reject)
|
||
pdfParser.parseBuffer(buffer)
|
||
})
|
||
|
||
if (parseResult && parseResult.length > 10) {
|
||
return { text: parseResult, pageCount: 1, isScanned: false }
|
||
}
|
||
} catch (error) {
|
||
console.log('pdf2json failed, PDF may be scanned')
|
||
}
|
||
|
||
// 第三層:標記為掃描文件,需要 OCR
|
||
return { text: '', pageCount: 1, isScanned: true }
|
||
}
|
||
```
|
||
|
||
#### 3.1.2 智慧 PDF 生成
|
||
|
||
```typescript
|
||
export async function generateTranslatedPDF(
|
||
translatedText: string,
|
||
originalMetadata?: any,
|
||
targetLanguage?: string
|
||
): Promise<Uint8Array> {
|
||
const pdfDoc = await PDFDocument.create()
|
||
pdfDoc.registerFontkit(fontkit)
|
||
|
||
// 中文字元檢測
|
||
const hasChinese = /[\u4e00-\u9fff]/.test(translatedText)
|
||
|
||
if (hasChinese) {
|
||
// 中文內容:使用智慧後備描述系統
|
||
return await generateChinesePDF(pdfDoc, translatedText, targetLanguage)
|
||
} else {
|
||
// 非中文內容:標準 PDF 生成
|
||
return await generateStandardPDF(pdfDoc, translatedText)
|
||
}
|
||
}
|
||
|
||
async function generateChinesePDF(
|
||
pdfDoc: PDFDocument,
|
||
text: string,
|
||
targetLanguage?: string
|
||
): Promise<Uint8Array> {
|
||
const page = pdfDoc.addPage()
|
||
const { width, height } = page.getSize()
|
||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica)
|
||
|
||
// 添加重要提示
|
||
page.drawText('IMPORTANT: Full Chinese translation is available in the', {
|
||
x: 50, y: height - 100, size: 11, font, color: rgb(0.8, 0.4, 0.0)
|
||
})
|
||
page.drawText('text output above this PDF download button.', {
|
||
x: 50, y: height - 115, size: 11, font, color: rgb(0.8, 0.4, 0.0)
|
||
})
|
||
|
||
// 處理中文字元並提供有意義的描述
|
||
const lines = text.split('\n')
|
||
let yPosition = height - 150
|
||
|
||
for (const line of lines) {
|
||
const processedLine = processChineseText(line)
|
||
try {
|
||
page.drawText(processedLine, {
|
||
x: 50, y: yPosition, size: 12, font, color: rgb(0, 0, 0)
|
||
})
|
||
} catch (error) {
|
||
// WinAnsi 編碼失敗時的後備處理
|
||
const fallbackDescription = generateContentDescription(line)
|
||
page.drawText(fallbackDescription, {
|
||
x: 50, y: yPosition, size: 12, font, color: rgb(0.3, 0.3, 0.3)
|
||
})
|
||
}
|
||
yPosition -= 15
|
||
}
|
||
|
||
return await pdfDoc.save()
|
||
}
|
||
|
||
function generateContentDescription(chineseText: string): string {
|
||
// 基於內容提供有意義的英文描述
|
||
if (chineseText.includes('測試')) return 'Testing PDF processing'
|
||
if (chineseText.includes('文字提取')) return 'Text extraction functionality'
|
||
if (chineseText.includes('翻譯')) return 'Translation process'
|
||
return 'Translated Chinese content'
|
||
}
|
||
```
|
||
|
||
### 3.2 費用追蹤模組 (cost-tracker.ts)
|
||
|
||
```typescript
|
||
interface CostSession {
|
||
id: string
|
||
timestamp: Date
|
||
provider: string
|
||
model: string
|
||
tokenUsage: {
|
||
prompt: number
|
||
completion: number
|
||
total: number
|
||
}
|
||
cost: {
|
||
inputCost: number
|
||
outputCost: number
|
||
totalCost: number
|
||
currency: string
|
||
}
|
||
}
|
||
|
||
interface CostSummary {
|
||
totalSessions: number
|
||
totalTokens: number
|
||
totalCost: number
|
||
currency: string
|
||
byProvider: Record<string, {
|
||
sessions: number
|
||
tokens: number
|
||
cost: number
|
||
}>
|
||
}
|
||
|
||
class CostTracker {
|
||
private readonly STORAGE_KEY = 'pdf-translator-costs'
|
||
|
||
addCostSession(session: CostSession): CostSummary {
|
||
const sessions = this.getCostSessions()
|
||
sessions.push(session)
|
||
|
||
if (typeof window !== 'undefined') {
|
||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(sessions))
|
||
}
|
||
|
||
return this.calculateSummary(sessions)
|
||
}
|
||
|
||
getCostSummary(): CostSummary {
|
||
const sessions = this.getCostSessions()
|
||
return this.calculateSummary(sessions)
|
||
}
|
||
|
||
private calculateSummary(sessions: CostSession[]): CostSummary {
|
||
return sessions.reduce((summary, session) => ({
|
||
totalSessions: summary.totalSessions + 1,
|
||
totalTokens: summary.totalTokens + session.tokenUsage.total,
|
||
totalCost: summary.totalCost + session.cost.totalCost,
|
||
currency: session.cost.currency,
|
||
byProvider: {
|
||
...summary.byProvider,
|
||
[session.provider]: {
|
||
sessions: (summary.byProvider[session.provider]?.sessions || 0) + 1,
|
||
tokens: (summary.byProvider[session.provider]?.tokens || 0) + session.tokenUsage.total,
|
||
cost: (summary.byProvider[session.provider]?.cost || 0) + session.cost.totalCost
|
||
}
|
||
}
|
||
}), {
|
||
totalSessions: 0,
|
||
totalTokens: 0,
|
||
totalCost: 0,
|
||
currency: 'USD',
|
||
byProvider: {}
|
||
})
|
||
}
|
||
}
|
||
|
||
export const costTracker = new CostTracker()
|
||
```
|
||
|
||
### 3.3 語音播放模組
|
||
|
||
```typescript
|
||
// 語音功能整合在 PDFTranslator 元件中
|
||
const playText = () => {
|
||
if (!speechSupported || !translatedText) return
|
||
|
||
// 恢復播放
|
||
if (isPaused) {
|
||
speechSynthesis.resume()
|
||
setIsPaused(false)
|
||
setIsPlaying(true)
|
||
return
|
||
}
|
||
|
||
// 新的播放
|
||
speechSynthesis.cancel()
|
||
const utterance = new SpeechSynthesisUtterance(translatedText)
|
||
|
||
// 語音配置
|
||
if (selectedVoice) {
|
||
const voice = availableVoices.find(v => v.name === selectedVoice)
|
||
if (voice) utterance.voice = voice
|
||
}
|
||
|
||
utterance.rate = speechRate
|
||
utterance.volume = speechVolume
|
||
utterance.pitch = 1.0
|
||
|
||
// 事件處理
|
||
utterance.onstart = () => setIsPlaying(true)
|
||
utterance.onend = () => { setIsPlaying(false); setIsPaused(false) }
|
||
utterance.onerror = (event) => {
|
||
console.error('Speech synthesis error:', event.error)
|
||
setIsPlaying(false)
|
||
setIsPaused(false)
|
||
}
|
||
|
||
speechSynthesis.speak(utterance)
|
||
}
|
||
|
||
// 自動語音選擇
|
||
const findPreferredVoice = (voices: SpeechSynthesisVoice[], langCode: string) => {
|
||
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'],
|
||
// ... 其他語言映射
|
||
}
|
||
|
||
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] // 後備選項
|
||
}
|
||
```
|
||
|
||
## 4. API 設計與實作
|
||
|
||
### 4.1 翻譯 API (app/api/translate/route.ts)
|
||
|
||
```typescript
|
||
export async function POST(request: Request) {
|
||
try {
|
||
const formData = await request.formData()
|
||
const file = formData.get('file') as File
|
||
const sourceLanguage = formData.get('sourceLanguage') as string
|
||
const targetLanguage = formData.get('targetLanguage') as string
|
||
const returnPDF = formData.get('returnPDF') === 'true'
|
||
|
||
// 檔案驗證
|
||
if (!file || file.type !== 'application/pdf') {
|
||
return NextResponse.json({ error: '請上傳有效的 PDF 檔案' }, { status: 400 })
|
||
}
|
||
|
||
if (file.size > 10 * 1024 * 1024) { // 10MB 限制
|
||
return NextResponse.json({ error: '檔案大小不能超過 10MB' }, { status: 413 })
|
||
}
|
||
|
||
// PDF 文字擷取
|
||
const buffer = Buffer.from(await file.arrayBuffer())
|
||
const result = await extractTextFromPDF(buffer)
|
||
|
||
if (!result.text.trim()) {
|
||
return NextResponse.json({
|
||
error: 'PDF 文字提取失敗,可能是掃描檔案或加密文件'
|
||
}, { status: 400 })
|
||
}
|
||
|
||
// AI 翻譯
|
||
const { translatedText, tokenUsage, cost, model } = await translateText(
|
||
result.text,
|
||
sourceLanguage,
|
||
targetLanguage
|
||
)
|
||
|
||
// 費用追蹤
|
||
const costSession = {
|
||
id: generateId(),
|
||
timestamp: new Date(),
|
||
provider: model.provider,
|
||
model: model.name,
|
||
tokenUsage,
|
||
cost
|
||
}
|
||
|
||
// PDF 生成(可選)
|
||
let pdfBase64: string | undefined
|
||
if (returnPDF) {
|
||
const pdfBytes = await generateTranslatedPDF(translatedText, result.metadata, targetLanguage)
|
||
pdfBase64 = Buffer.from(pdfBytes).toString('base64')
|
||
}
|
||
|
||
return NextResponse.json({
|
||
translatedText,
|
||
pdfBase64,
|
||
tokenUsage: {
|
||
...tokenUsage,
|
||
formattedCounts: {
|
||
prompt: formatNumber(tokenUsage.prompt),
|
||
completion: formatNumber(tokenUsage.completion),
|
||
total: formatNumber(tokenUsage.total)
|
||
}
|
||
},
|
||
cost: {
|
||
...cost,
|
||
formattedCost: formatCost(cost.totalCost, cost.currency)
|
||
},
|
||
model: {
|
||
name: model.name,
|
||
provider: model.provider,
|
||
displayName: model.displayName
|
||
},
|
||
costSession
|
||
})
|
||
|
||
} catch (error) {
|
||
console.error('Translation error:', error)
|
||
return NextResponse.json(
|
||
{ error: error instanceof Error ? error.message : '翻譯過程中發生錯誤' },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4.2 AI 翻譯整合
|
||
|
||
```typescript
|
||
import { createOpenAI } from '@ai-sdk/openai'
|
||
import { generateText } from 'ai'
|
||
|
||
const deepseek = createOpenAI({
|
||
apiKey: process.env.DEEPSEEK_API_KEY!,
|
||
baseURL: process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/v1',
|
||
})
|
||
|
||
const openai = createOpenAI({
|
||
apiKey: process.env.OPENAI_API_KEY!,
|
||
})
|
||
|
||
export async function translateText(
|
||
text: string,
|
||
sourceLanguage: string,
|
||
targetLanguage: string
|
||
) {
|
||
const provider = process.env.AI_PROVIDER || 'deepseek'
|
||
|
||
const model = provider === 'deepseek'
|
||
? deepseek(process.env.DEEPSEEK_MODEL || 'deepseek-chat')
|
||
: openai(process.env.OPENAI_MODEL || 'gpt-4o-mini')
|
||
|
||
const prompt = createTranslationPrompt(text, sourceLanguage, targetLanguage)
|
||
|
||
const result = await generateText({
|
||
model,
|
||
prompt,
|
||
maxTokens: 4000,
|
||
temperature: 0.3,
|
||
})
|
||
|
||
// Token 使用量計算
|
||
const tokenUsage = {
|
||
prompt: result.usage?.promptTokens || 0,
|
||
completion: result.usage?.completionTokens || 0,
|
||
total: result.usage?.totalTokens || 0
|
||
}
|
||
|
||
// 費用計算
|
||
const cost = calculateCost(tokenUsage, provider, model.modelId)
|
||
|
||
return {
|
||
translatedText: result.text,
|
||
tokenUsage,
|
||
cost,
|
||
model: {
|
||
name: model.modelId,
|
||
provider,
|
||
displayName: getModelDisplayName(model.modelId, provider)
|
||
}
|
||
}
|
||
}
|
||
|
||
function createTranslationPrompt(text: string, source: string, target: string): string {
|
||
const targetLang = LANGUAGE_NAMES[target] || target
|
||
const sourceLang = source === 'auto' ? '自動偵測' : LANGUAGE_NAMES[source] || source
|
||
|
||
return `請將以下${sourceLang}文字翻譯成${targetLang},保持原文的格式和段落結構,確保翻譯準確且自然:
|
||
|
||
${text}
|
||
|
||
請直接提供翻譯結果,不需要額外說明。`
|
||
}
|
||
```
|
||
|
||
## 5. UI/UX 技術實作
|
||
|
||
### 5.1 文清楓風格設計系統
|
||
|
||
```typescript
|
||
// Tailwind CSS 自訂配置
|
||
const designTokens = {
|
||
colors: {
|
||
// 文清楓主色調
|
||
primary: {
|
||
amber: 'rgb(245 158 11)', // amber-500
|
||
green: 'rgb(34 197 94)', // green-500
|
||
teal: 'rgb(20 184 166)', // teal-500
|
||
},
|
||
|
||
// 背景漸變
|
||
background: {
|
||
light: 'from-amber-50 via-green-50 to-teal-50',
|
||
dark: 'dark:from-slate-800 dark:via-slate-900 dark:to-slate-800',
|
||
},
|
||
|
||
// 裝飾元素
|
||
decoration: {
|
||
circles: [
|
||
'bg-amber-200 blur-xl',
|
||
'bg-green-200 blur-xl',
|
||
'bg-teal-200 blur-xl',
|
||
'bg-amber-300 blur-xl'
|
||
]
|
||
}
|
||
},
|
||
|
||
spacing: {
|
||
responsive: {
|
||
mobile: 'p-4 gap-4',
|
||
tablet: 'sm:p-6 sm:gap-6',
|
||
desktop: 'lg:p-8 lg:gap-8'
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5.2 響應式設計實作
|
||
|
||
```typescript
|
||
// 響應式斷點策略
|
||
const breakpoints = {
|
||
sm: '640px', // 手機橫向
|
||
md: '768px', // 平板直向
|
||
lg: '1024px', // 平板橫向
|
||
xl: '1280px', // 桌面
|
||
'2xl': '1536px' // 大桌面
|
||
}
|
||
|
||
// 響應式元件設計模式
|
||
const ResponsiveComponent = () => (
|
||
<div className="
|
||
grid grid-cols-1 xl:grid-cols-2
|
||
gap-4 sm:gap-6 lg:gap-8
|
||
p-4 sm:p-6 lg:p-8
|
||
">
|
||
<Card className="
|
||
p-4 sm:p-6 lg:p-8
|
||
text-sm sm:text-base lg:text-lg
|
||
">
|
||
{/* 內容 */}
|
||
</Card>
|
||
</div>
|
||
)
|
||
|
||
// 行動裝置優化
|
||
const MobileOptimizedButton = ({ children, fullText, iconText }) => (
|
||
<Button className="
|
||
text-xs sm:text-sm lg:text-base
|
||
px-2 sm:px-4 lg:px-6
|
||
h-8 sm:h-10 lg:h-12
|
||
">
|
||
<Icon className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
|
||
<span className="hidden sm:inline">{fullText}</span>
|
||
<span className="sm:hidden">{iconText}</span>
|
||
</Button>
|
||
)
|
||
```
|
||
|
||
### 5.3 拖拽上傳實作
|
||
|
||
```typescript
|
||
const DragDropUpload = () => {
|
||
const [isDragging, setIsDragging] = useState(false)
|
||
|
||
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 && droppedFile.type === 'application/pdf') {
|
||
setFile(droppedFile)
|
||
} else {
|
||
alert('目前僅支援 PDF 文件')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={`
|
||
border-2 border-dashed rounded-xl p-6 sm:p-8 lg:p-12
|
||
text-center transition-all duration-300
|
||
${isDragging
|
||
? 'border-green-500 bg-green-50/50 shadow-lg'
|
||
: 'border-amber-300 hover:border-green-400'
|
||
}
|
||
`}
|
||
onDragOver={handleDragOver}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={handleDrop}
|
||
>
|
||
{/* 拖拽區域內容 */}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
## 6. 效能優化策略
|
||
|
||
### 6.1 前端效能優化
|
||
|
||
```typescript
|
||
// 1. 元件懶加載
|
||
const PDFTranslator = dynamic(() => import('@/components/pdf-translator'), {
|
||
loading: () => <div className="animate-pulse">載入中...</div>,
|
||
ssr: false // 避免語音 API 的 SSR 問題
|
||
})
|
||
|
||
// 2. 記憶化計算
|
||
const memoizedCostSummary = useMemo(() => {
|
||
return costTracker.getCostSummary()
|
||
}, [costSummary])
|
||
|
||
// 3. 防抖動處理
|
||
const debouncedSpeechRateChange = useCallback(
|
||
debounce((rate: number) => setSpeechRate(rate), 300),
|
||
[]
|
||
)
|
||
|
||
// 4. 批次狀態更新
|
||
const updateTranslationResult = useCallback((data: TranslationResult) => {
|
||
// 批次更新避免多次重渲染
|
||
setTranslatedText(data.translatedText)
|
||
setTranslatedPDFBase64(data.pdfBase64 || '')
|
||
setTokenUsage(data.tokenUsage)
|
||
setCost(data.cost)
|
||
setModel(data.model)
|
||
|
||
if (data.costSession) {
|
||
const updatedSummary = costTracker.addCostSession(data.costSession)
|
||
setCostSummary(updatedSummary)
|
||
}
|
||
}, [])
|
||
```
|
||
|
||
### 6.2 後端效能優化
|
||
|
||
```typescript
|
||
// 1. 串流處理大檔案
|
||
export async function processLargeFile(buffer: Buffer) {
|
||
const stream = new Readable({
|
||
read() {
|
||
// 分塊處理邏輯
|
||
}
|
||
})
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const chunks: Buffer[] = []
|
||
stream.on('data', chunk => chunks.push(chunk))
|
||
stream.on('end', () => resolve(Buffer.concat(chunks)))
|
||
stream.on('error', reject)
|
||
})
|
||
}
|
||
|
||
// 2. PDF 處理快取
|
||
const pdfCache = new Map<string, PDFProcessResult>()
|
||
|
||
export async function cachedExtractTextFromPDF(buffer: Buffer): Promise<PDFProcessResult> {
|
||
const hash = createHash('md5').update(buffer).digest('hex')
|
||
|
||
if (pdfCache.has(hash)) {
|
||
return pdfCache.get(hash)!
|
||
}
|
||
|
||
const result = await extractTextFromPDF(buffer)
|
||
pdfCache.set(hash, result)
|
||
|
||
// 限制快取大小
|
||
if (pdfCache.size > 100) {
|
||
const firstKey = pdfCache.keys().next().value
|
||
pdfCache.delete(firstKey)
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// 3. AI API 請求優化
|
||
const rateLimiter = new Map<string, number>()
|
||
|
||
export async function rateLimitedTranslation(
|
||
text: string,
|
||
source: string,
|
||
target: string
|
||
) {
|
||
const key = `${source}-${target}`
|
||
const lastRequest = rateLimiter.get(key) || 0
|
||
const now = Date.now()
|
||
|
||
if (now - lastRequest < 1000) { // 1秒限制
|
||
await new Promise(resolve => setTimeout(resolve, 1000 - (now - lastRequest)))
|
||
}
|
||
|
||
rateLimiter.set(key, Date.now())
|
||
return await translateText(text, source, target)
|
||
}
|
||
```
|
||
|
||
## 7. 錯誤處理策略
|
||
|
||
### 7.1 分層錯誤處理
|
||
|
||
```typescript
|
||
// 1. API 層錯誤處理
|
||
export class TranslationError extends Error {
|
||
constructor(
|
||
message: string,
|
||
public code: string,
|
||
public statusCode: number = 500
|
||
) {
|
||
super(message)
|
||
this.name = 'TranslationError'
|
||
}
|
||
}
|
||
|
||
export class PDFProcessingError extends TranslationError {
|
||
constructor(message: string) {
|
||
super(message, 'PDF_PROCESSING_ERROR', 400)
|
||
}
|
||
}
|
||
|
||
export class AIServiceError extends TranslationError {
|
||
constructor(message: string, provider: string) {
|
||
super(`${provider} 服務錯誤: ${message}`, 'AI_SERVICE_ERROR', 502)
|
||
}
|
||
}
|
||
|
||
// 2. 全域錯誤處理中間件
|
||
export async function errorHandler(
|
||
error: Error,
|
||
request: Request
|
||
): Promise<NextResponse> {
|
||
console.error('API Error:', error)
|
||
|
||
if (error instanceof TranslationError) {
|
||
return NextResponse.json(
|
||
{ error: error.message, code: error.code },
|
||
{ status: error.statusCode }
|
||
)
|
||
}
|
||
|
||
// 未知錯誤
|
||
return NextResponse.json(
|
||
{ error: '內部伺服器錯誤', code: 'INTERNAL_ERROR' },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
|
||
// 3. 前端錯誤邊界
|
||
export function ErrorBoundary({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<ErrorBoundaryComponent
|
||
fallback={({ error, resetErrorBoundary }) => (
|
||
<div className="text-center p-8">
|
||
<h2 className="text-xl font-semibold mb-4">系統發生錯誤</h2>
|
||
<p className="text-gray-600 mb-4">{error.message}</p>
|
||
<Button onClick={resetErrorBoundary}>重新載入</Button>
|
||
</div>
|
||
)}
|
||
>
|
||
{children}
|
||
</ErrorBoundaryComponent>
|
||
)
|
||
}
|
||
```
|
||
|
||
### 7.2 使用者友善的錯誤提示
|
||
|
||
```typescript
|
||
const errorMessages = {
|
||
PDF_TOO_LARGE: '檔案大小超過 10MB 限制,請選擇較小的檔案',
|
||
PDF_CORRUPTED: 'PDF 檔案損壞或加密,無法處理',
|
||
PDF_NO_TEXT: 'PDF 中未找到可擷取的文字,可能為純圖片檔案',
|
||
AI_QUOTA_EXCEEDED: 'AI 服務配額已達上限,請稍後再試',
|
||
AI_RATE_LIMITED: '請求過於頻繁,請稍等片刻後再試',
|
||
NETWORK_ERROR: '網路連線失敗,請檢查網路狀況',
|
||
UNSUPPORTED_LANGUAGE: '不支援的語言組合'
|
||
}
|
||
|
||
export function getErrorMessage(error: unknown): string {
|
||
if (error instanceof Error) {
|
||
return errorMessages[error.message] || error.message
|
||
}
|
||
return '發生未知錯誤,請重新嘗試'
|
||
}
|
||
```
|
||
|
||
## 8. 測試策略
|
||
|
||
### 8.1 單元測試
|
||
|
||
```typescript
|
||
// PDF 處理測試
|
||
describe('PDF Processing', () => {
|
||
test('should extract text from text-based PDF', async () => {
|
||
const mockBuffer = Buffer.from('mock-pdf-content')
|
||
const result = await extractTextFromPDF(mockBuffer)
|
||
|
||
expect(result.text).toBeDefined()
|
||
expect(result.pageCount).toBeGreaterThan(0)
|
||
expect(result.isScanned).toBe(false)
|
||
})
|
||
|
||
test('should handle corrupted PDF gracefully', async () => {
|
||
const corruptedBuffer = Buffer.from('invalid-pdf')
|
||
|
||
await expect(extractTextFromPDF(corruptedBuffer))
|
||
.rejects.toThrow(PDFProcessingError)
|
||
})
|
||
})
|
||
|
||
// 費用追蹤測試
|
||
describe('Cost Tracking', () => {
|
||
beforeEach(() => {
|
||
localStorage.clear()
|
||
})
|
||
|
||
test('should calculate cost summary correctly', () => {
|
||
const session: CostSession = {
|
||
id: 'test-1',
|
||
timestamp: new Date(),
|
||
provider: 'deepseek',
|
||
model: 'deepseek-chat',
|
||
tokenUsage: { prompt: 100, completion: 50, total: 150 },
|
||
cost: { inputCost: 0.001, outputCost: 0.002, totalCost: 0.003, currency: 'USD' }
|
||
}
|
||
|
||
const summary = costTracker.addCostSession(session)
|
||
|
||
expect(summary.totalSessions).toBe(1)
|
||
expect(summary.totalTokens).toBe(150)
|
||
expect(summary.totalCost).toBe(0.003)
|
||
})
|
||
})
|
||
```
|
||
|
||
### 8.2 整合測試
|
||
|
||
```typescript
|
||
// API 端點測試
|
||
describe('/api/translate', () => {
|
||
test('should translate PDF successfully', async () => {
|
||
const formData = new FormData()
|
||
formData.append('file', new File(['mock-pdf'], 'test.pdf', { type: 'application/pdf' }))
|
||
formData.append('sourceLanguage', 'zh-TW')
|
||
formData.append('targetLanguage', 'en')
|
||
formData.append('returnPDF', 'true')
|
||
|
||
const response = await fetch('/api/translate', {
|
||
method: 'POST',
|
||
body: formData
|
||
})
|
||
|
||
expect(response.status).toBe(200)
|
||
|
||
const data = await response.json()
|
||
expect(data.translatedText).toBeDefined()
|
||
expect(data.tokenUsage).toBeDefined()
|
||
expect(data.cost).toBeDefined()
|
||
})
|
||
})
|
||
```
|
||
|
||
### 8.3 端對端測試
|
||
|
||
```typescript
|
||
// Playwright E2E 測試
|
||
test('complete translation workflow', async ({ page }) => {
|
||
await page.goto('/')
|
||
|
||
// 上傳檔案
|
||
await page.setInputFiles('input[type="file"]', 'test-files/sample.pdf')
|
||
|
||
// 選擇語言
|
||
await page.selectOption('[data-testid="source-language"]', 'zh-TW')
|
||
await page.selectOption('[data-testid="target-language"]', 'en')
|
||
|
||
// 開始翻譯
|
||
await page.click('[data-testid="translate-button"]')
|
||
|
||
// 等待結果
|
||
await page.waitForSelector('[data-testid="translation-result"]')
|
||
|
||
// 驗證結果
|
||
const result = await page.textContent('[data-testid="translation-result"]')
|
||
expect(result).toBeTruthy()
|
||
|
||
// 測試語音播放
|
||
await page.click('[data-testid="play-button"]')
|
||
await page.waitForTimeout(1000)
|
||
await page.click('[data-testid="pause-button"]')
|
||
})
|
||
```
|
||
|
||
## 9. 部署與監控
|
||
|
||
### 9.1 Vercel 部署配置
|
||
|
||
```json
|
||
{
|
||
"buildCommand": "npm run build",
|
||
"outputDirectory": ".next",
|
||
"installCommand": "npm install",
|
||
"framework": "nextjs",
|
||
"functions": {
|
||
"app/api/translate/route.ts": {
|
||
"maxDuration": 30
|
||
}
|
||
},
|
||
"env": {
|
||
"NODE_ENV": "production"
|
||
}
|
||
}
|
||
```
|
||
|
||
### 9.2 效能監控
|
||
|
||
```typescript
|
||
// 自訂效能監控
|
||
export class PerformanceMonitor {
|
||
static measureApiCall(name: string) {
|
||
const start = performance.now()
|
||
|
||
return {
|
||
end: (status: 'success' | 'error') => {
|
||
const duration = performance.now() - start
|
||
|
||
// 發送監控數據
|
||
if (typeof window !== 'undefined') {
|
||
navigator.sendBeacon('/api/metrics', JSON.stringify({
|
||
name,
|
||
duration,
|
||
status,
|
||
timestamp: Date.now()
|
||
}))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 使用範例
|
||
export async function monitoredTranslation(params: TranslationParams) {
|
||
const monitor = PerformanceMonitor.measureApiCall('translation')
|
||
|
||
try {
|
||
const result = await translateText(params)
|
||
monitor.end('success')
|
||
return result
|
||
} catch (error) {
|
||
monitor.end('error')
|
||
throw error
|
||
}
|
||
}
|
||
```
|
||
|
||
## 10. 安全性實作
|
||
|
||
### 10.1 輸入驗證
|
||
|
||
```typescript
|
||
import { z } from 'zod'
|
||
|
||
const TranslationRequestSchema = z.object({
|
||
file: z.instanceof(File)
|
||
.refine(file => file.type === 'application/pdf', '僅支援 PDF 檔案')
|
||
.refine(file => file.size <= 10 * 1024 * 1024, '檔案大小不能超過 10MB'),
|
||
sourceLanguage: z.enum(SUPPORTED_LANGUAGES),
|
||
targetLanguage: z.enum(SUPPORTED_LANGUAGES),
|
||
returnPDF: z.boolean().optional()
|
||
})
|
||
|
||
export async function validateRequest(formData: FormData) {
|
||
const data = {
|
||
file: formData.get('file'),
|
||
sourceLanguage: formData.get('sourceLanguage'),
|
||
targetLanguage: formData.get('targetLanguage'),
|
||
returnPDF: formData.get('returnPDF') === 'true'
|
||
}
|
||
|
||
return TranslationRequestSchema.parse(data)
|
||
}
|
||
```
|
||
|
||
### 10.2 API 金鑰管理
|
||
|
||
```typescript
|
||
// 環境變數驗證
|
||
const envSchema = z.object({
|
||
DEEPSEEK_API_KEY: z.string().min(1, 'DeepSeek API key is required'),
|
||
OPENAI_API_KEY: z.string().optional(),
|
||
AI_PROVIDER: z.enum(['deepseek', 'openai']).default('deepseek'),
|
||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development')
|
||
})
|
||
|
||
export const env = envSchema.parse(process.env)
|
||
|
||
// API 金鑰輪換
|
||
class APIKeyManager {
|
||
private keys: string[]
|
||
private currentIndex = 0
|
||
|
||
constructor(keys: string[]) {
|
||
this.keys = keys.filter(Boolean)
|
||
if (this.keys.length === 0) {
|
||
throw new Error('No valid API keys provided')
|
||
}
|
||
}
|
||
|
||
getKey(): string {
|
||
const key = this.keys[this.currentIndex]
|
||
this.currentIndex = (this.currentIndex + 1) % this.keys.length
|
||
return key
|
||
}
|
||
}
|
||
```
|
||
|
||
## 11. 未來技術升級計畫
|
||
|
||
### 11.1 短期升級 (1-3 個月)
|
||
- [ ] 實作 Redis 快取系統
|
||
- [ ] 添加請求速率限制
|
||
- [ ] 整合 Sentry 錯誤監控
|
||
- [ ] 實作批次檔案處理
|
||
|
||
### 11.2 中期升級 (3-6 個月)
|
||
- [ ] 實作 WebSocket 即時進度更新
|
||
- [ ] 整合更多 AI 模型 (Claude, Gemini)
|
||
- [ ] 實作用戶認證系統
|
||
- [ ] 添加翻譯歷史功能
|
||
|
||
### 11.3 長期升級 (6-12 個月)
|
||
- [ ] 實作分散式處理系統
|
||
- [ ] 整合專業 CAT 工具
|
||
- [ ] 實作協作翻譯功能
|
||
- [ ] 添加 API 開放平台
|
||
|
||
### 11.4 技術債務管理
|
||
- [ ] 重構 PDF 處理模組為微服務
|
||
- [ ] 實作完整的測試覆蓋率 (目標 >90%)
|
||
- [ ] 優化 TypeScript 型別定義
|
||
- [ ] 實作自動化效能測試
|
||
|
||
---
|
||
|
||
*本技術設計文件將隨著系統演進持續更新,確保技術架構的可維護性和可擴展性。* |