653 lines
14 KiB
Markdown
653 lines
14 KiB
Markdown
# Tool_OCR 前端代碼示例和最佳實踐
|
||
|
||
## 1. Tailwind CSS 樣式使用示例
|
||
|
||
### 佈局組件 (Layout.tsx 提取)
|
||
```typescript
|
||
// 導航欄樣式示例
|
||
<header className="border-b bg-card">
|
||
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
|
||
<h1 className="text-2xl font-bold text-foreground">{t('app.title')}</h1>
|
||
<button
|
||
className="px-4 py-2 text-sm font-medium text-foreground hover:text-primary transition-colors"
|
||
>
|
||
{t('nav.logout')}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* 導航選項卡 */}
|
||
<nav className="border-b bg-card">
|
||
<div className="container mx-auto px-4">
|
||
<ul className="flex space-x-1">
|
||
{navLinks.map((link) => (
|
||
<NavLink
|
||
to={link.to}
|
||
className={({ isActive }) =>
|
||
`block px-4 py-3 text-sm font-medium transition-colors ${
|
||
isActive
|
||
? 'text-primary border-b-2 border-primary'
|
||
: 'text-muted-foreground hover:text-foreground'
|
||
}`
|
||
}
|
||
>
|
||
{link.label}
|
||
</NavLink>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</nav>
|
||
```
|
||
|
||
### 按鈕變體示例 (button.tsx)
|
||
```typescript
|
||
// 默認按鈕
|
||
<Button>Save Changes</Button>
|
||
|
||
// 危險操作
|
||
<Button variant="destructive">Delete</Button>
|
||
|
||
// 輪廓樣式
|
||
<Button variant="outline">Cancel</Button>
|
||
|
||
// 幽靈按鈕
|
||
<Button variant="ghost">Remove</Button>
|
||
|
||
// 鏈接樣式
|
||
<Button variant="link">Learn More</Button>
|
||
|
||
// 不同尺寸
|
||
<Button size="sm">Small</Button>
|
||
<Button size="lg">Large</Button>
|
||
<Button size="icon">+</Button>
|
||
```
|
||
|
||
### 響應式卡片示例 (UploadPage.tsx 提取)
|
||
```typescript
|
||
<div className="max-w-4xl mx-auto space-y-6">
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle className="text-lg">
|
||
{t('upload.selectedFiles')} ({selectedFiles.length})
|
||
</CardTitle>
|
||
<Button variant="outline" size="sm" onClick={handleClearAll}>
|
||
{t('upload.clearAll')}
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-2">
|
||
{selectedFiles.map((file, index) => (
|
||
<div
|
||
key={index}
|
||
className="flex items-center justify-between p-3 bg-muted rounded-md"
|
||
>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-foreground truncate">
|
||
{file.name}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
{formatFileSize(file.size)}
|
||
</p>
|
||
</div>
|
||
<Button variant="ghost" size="sm" onClick={() => handleRemoveFile(index)}>
|
||
{t('upload.removeFile')}
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
```
|
||
|
||
### 文件上傳拖放區域 (FileUpload.tsx 提取)
|
||
```typescript
|
||
<Card
|
||
{...getRootProps()}
|
||
className={cn(
|
||
'border-2 border-dashed transition-colors cursor-pointer hover:border-primary/50',
|
||
{
|
||
'border-primary bg-primary/5': isDragActive && !isDragReject,
|
||
'border-destructive bg-destructive/5': isDragReject,
|
||
'opacity-50 cursor-not-allowed': disabled,
|
||
}
|
||
)}
|
||
>
|
||
<div className="p-12 text-center">
|
||
<input {...getInputProps()} />
|
||
|
||
<div className="mb-4">
|
||
<svg className="mx-auto h-12 w-12 text-muted-foreground" />
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
{isDragActive ? (
|
||
<p className="text-lg font-medium text-primary">
|
||
{isDragReject ? t('upload.invalidFiles') : t('upload.dropFilesHere')}
|
||
</p>
|
||
) : (
|
||
<>
|
||
<p className="text-lg font-medium text-foreground">
|
||
{t('upload.dragAndDrop')}
|
||
</p>
|
||
<p className="text-sm text-muted-foreground">
|
||
{t('upload.supportedFormats')}
|
||
</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
```
|
||
|
||
---
|
||
|
||
## 2. React Query 使用示例
|
||
|
||
### 批次狀態查詢 (ResultsPage.tsx 提取)
|
||
```typescript
|
||
import { useQuery } from '@tanstack/react-query'
|
||
|
||
export default function ResultsPage() {
|
||
const { batchId } = useUploadStore()
|
||
const [selectedFileId, setSelectedFileId] = useState<number | null>(null)
|
||
|
||
// 獲取批次狀態
|
||
const { data: batchStatus, isLoading } = useQuery({
|
||
queryKey: ['batchStatus', batchId],
|
||
queryFn: () => apiClient.getBatchStatus(batchId!),
|
||
enabled: !!batchId, // 只在有 batchId 時查詢
|
||
})
|
||
|
||
// 獲取 OCR 結果
|
||
const { data: ocrResult, isLoading: isLoadingResult } = useQuery({
|
||
queryKey: ['ocrResult', selectedFileId],
|
||
queryFn: () => apiClient.getOCRResult(selectedFileId!.toString()),
|
||
enabled: !!selectedFileId,
|
||
})
|
||
|
||
if (!batchId) {
|
||
return <div>Please upload files first</div>
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
{isLoading ? (
|
||
<p>Loading batch status...</p>
|
||
) : (
|
||
<ResultsTable data={batchStatus?.files} />
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Zustand 狀態管理示例
|
||
|
||
### 認證存儲 (authStore.ts)
|
||
```typescript
|
||
import { create } from 'zustand'
|
||
import { persist } from 'zustand/middleware'
|
||
import type { User } from '@/types/api'
|
||
|
||
interface AuthState {
|
||
user: User | null
|
||
isAuthenticated: boolean
|
||
setUser: (user: User | null) => void
|
||
logout: () => void
|
||
}
|
||
|
||
export const useAuthStore = create<AuthState>()(
|
||
persist(
|
||
(set) => ({
|
||
user: null,
|
||
isAuthenticated: false,
|
||
setUser: (user) =>
|
||
set({
|
||
user,
|
||
isAuthenticated: user !== null,
|
||
}),
|
||
logout: () =>
|
||
set({
|
||
user: null,
|
||
isAuthenticated: false,
|
||
}),
|
||
}),
|
||
{
|
||
name: 'auth-storage', // localStorage 鍵名
|
||
}
|
||
)
|
||
)
|
||
|
||
// 使用示例
|
||
function LoginPage() {
|
||
const setUser = useAuthStore((state) => state.setUser)
|
||
|
||
const handleLogin = async (username: string, password: string) => {
|
||
const response = await apiClient.login({ username, password })
|
||
setUser({ id: 1, username })
|
||
}
|
||
}
|
||
```
|
||
|
||
### 上傳狀態存儲 (uploadStore.ts)
|
||
```typescript
|
||
export const useUploadStore = create<UploadState>()(
|
||
persist(
|
||
(set) => ({
|
||
batchId: null,
|
||
files: [],
|
||
uploadProgress: 0,
|
||
|
||
setBatchId: (id) => {
|
||
set({ batchId: id })
|
||
},
|
||
|
||
setFiles: (files) => set({ files }),
|
||
|
||
setUploadProgress: (progress) => set({ uploadProgress: progress }),
|
||
|
||
updateFileStatus: (fileId, status) =>
|
||
set((state) => ({
|
||
files: state.files.map((file) =>
|
||
file.id === fileId ? { ...file, status } : file
|
||
),
|
||
})),
|
||
|
||
clearUpload: () =>
|
||
set({
|
||
batchId: null,
|
||
files: [],
|
||
uploadProgress: 0,
|
||
}),
|
||
}),
|
||
{
|
||
name: 'tool-ocr-upload-store',
|
||
// 只持久化 batchId 和 files,不持久化進度
|
||
partialize: (state) => ({
|
||
batchId: state.batchId,
|
||
files: state.files,
|
||
}),
|
||
}
|
||
)
|
||
)
|
||
|
||
// 使用示例
|
||
function UploadPage() {
|
||
const { setBatchId, setFiles } = useUploadStore()
|
||
|
||
const handleUploadSuccess = (data: UploadResponse) => {
|
||
setBatchId(data.batch_id)
|
||
setFiles(data.files)
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. API 客戶端使用示例
|
||
|
||
### API 認證流程 (api.ts)
|
||
```typescript
|
||
// 登錄
|
||
const response = await apiClient.login({
|
||
username: 'admin',
|
||
password: 'password'
|
||
})
|
||
// Token 自動保存到 localStorage
|
||
|
||
// 後續請求自動附帶 token
|
||
const status = await apiClient.getBatchStatus(123)
|
||
// 請求頭自動包含: Authorization: Bearer <token>
|
||
|
||
// 登出
|
||
apiClient.logout()
|
||
// Token 自動從 localStorage 清除
|
||
```
|
||
|
||
### 文件上傳示例
|
||
```typescript
|
||
const handleUpload = async (files: File[]) => {
|
||
try {
|
||
const response = await apiClient.uploadFiles(files)
|
||
// response: { batch_id: 1, files: [...] }
|
||
setBatchId(response.batch_id)
|
||
setFiles(response.files)
|
||
} catch (error) {
|
||
// 自動處理 401 錯誤並重定向到登錄頁
|
||
showError(error.response?.data?.detail)
|
||
}
|
||
}
|
||
```
|
||
|
||
### 導出功能示例
|
||
```typescript
|
||
// 導出 PDF
|
||
const handleDownloadPDF = async (fileId: number) => {
|
||
const blob = await apiClient.exportPDF(fileId)
|
||
const url = window.URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `ocr-result-${fileId}.pdf`
|
||
a.click()
|
||
window.URL.revokeObjectURL(url)
|
||
}
|
||
|
||
// 導出規則管理
|
||
const rules = await apiClient.getExportRules()
|
||
const newRule = await apiClient.createExportRule({
|
||
rule_name: 'My Rule',
|
||
config_json: { /* ... */ }
|
||
})
|
||
await apiClient.updateExportRule(rule.id, { rule_name: 'Updated' })
|
||
await apiClient.deleteExportRule(rule.id)
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 國際化使用示例
|
||
|
||
### 在組件中使用翻譯
|
||
```typescript
|
||
import { useTranslation } from 'react-i18next'
|
||
|
||
export default function UploadPage() {
|
||
const { t } = useTranslation()
|
||
|
||
return (
|
||
<div>
|
||
<h1>{t('upload.title')}</h1>
|
||
<p>{t('upload.dragAndDrop')}</p>
|
||
|
||
{/* 帶插值的翻譯 */}
|
||
<p>{t('upload.fileCount', { count: 5 })}</p>
|
||
{/* 會渲染: "已選擇 5 個檔案" */}
|
||
|
||
<button>{t('upload.uploadButton')}</button>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
### i18n 初始化 (i18n/index.ts)
|
||
```typescript
|
||
import i18n from 'i18next'
|
||
import { initReactI18next } from 'react-i18next'
|
||
import zhTW from './locales/zh-TW.json'
|
||
|
||
i18n.use(initReactI18next).init({
|
||
resources: {
|
||
'zh-TW': {
|
||
translation: zhTW,
|
||
},
|
||
},
|
||
lng: 'zh-TW',
|
||
fallbackLng: 'zh-TW',
|
||
interpolation: {
|
||
escapeValue: false,
|
||
},
|
||
})
|
||
|
||
export default i18n
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 路由和保護示例
|
||
|
||
### 受保護的路由 (App.tsx)
|
||
```typescript
|
||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||
|
||
if (!isAuthenticated) {
|
||
return <Navigate to="/login" replace />
|
||
}
|
||
|
||
return <>{children}</>
|
||
}
|
||
|
||
function App() {
|
||
return (
|
||
<Routes>
|
||
{/* 公開路由 */}
|
||
<Route path="/login" element={<LoginPage />} />
|
||
|
||
{/* 受保護的路由 */}
|
||
<Route
|
||
path="/"
|
||
element={
|
||
<ProtectedRoute>
|
||
<Layout />
|
||
</ProtectedRoute>
|
||
}
|
||
>
|
||
<Route index element={<Navigate to="/upload" replace />} />
|
||
<Route path="upload" element={<UploadPage />} />
|
||
<Route path="processing" element={<ProcessingPage />} />
|
||
<Route path="results" element={<ResultsPage />} />
|
||
<Route path="export" element={<ExportPage />} />
|
||
<Route path="settings" element={<SettingsPage />} />
|
||
</Route>
|
||
|
||
{/* 全部匹配 */}
|
||
<Route path="*" element={<Navigate to="/" replace />} />
|
||
</Routes>
|
||
)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 類型定義示例
|
||
|
||
### API 類型 (types/api.ts)
|
||
```typescript
|
||
// 認證
|
||
export interface LoginRequest {
|
||
username: string
|
||
password: string
|
||
}
|
||
|
||
export interface LoginResponse {
|
||
access_token: string
|
||
token_type: string
|
||
}
|
||
|
||
// 文件上傳
|
||
export interface UploadResponse {
|
||
batch_id: number
|
||
files: FileInfo[]
|
||
}
|
||
|
||
export interface FileInfo {
|
||
id: number
|
||
filename: string
|
||
file_size: number
|
||
format: string
|
||
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||
}
|
||
|
||
// OCR 結果
|
||
export interface OCRResult {
|
||
file_id: number
|
||
filename: string
|
||
status: string
|
||
markdown_content: string
|
||
json_data: OCRJsonData
|
||
confidence: number
|
||
processing_time: number
|
||
}
|
||
|
||
export interface TextBlock {
|
||
text: string
|
||
confidence: number
|
||
bbox: [number, number, number, number]
|
||
position: number
|
||
}
|
||
|
||
// 導出規則
|
||
export interface ExportRule {
|
||
id: number
|
||
rule_name: string
|
||
config_json: Record<string, any>
|
||
css_template?: string
|
||
created_at: string
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 8. 最佳實踐
|
||
|
||
### 1. 組件結構
|
||
```typescript
|
||
// 導入順序
|
||
import { useState } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { useTranslation } from 'react-i18next'
|
||
import { useQuery } from '@tanstack/react-query'
|
||
|
||
// 內部導入
|
||
import { Button } from '@/components/ui/button'
|
||
import { Card } from '@/components/ui/card'
|
||
import { useAuthStore } from '@/store/authStore'
|
||
import { apiClient } from '@/services/api'
|
||
|
||
// 組件定義
|
||
export default function MyPage() {
|
||
// Hooks
|
||
const { t } = useTranslation()
|
||
const navigate = useNavigate()
|
||
const [state, setState] = useState(null)
|
||
|
||
// 查詢
|
||
const { data, isLoading } = useQuery({
|
||
queryKey: ['key'],
|
||
queryFn: () => apiClient.getData(),
|
||
})
|
||
|
||
// 狀態更新
|
||
const handleClick = () => {
|
||
// ...
|
||
}
|
||
|
||
// 渲染
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* JSX */}
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
### 2. 錯誤處理
|
||
```typescript
|
||
const { toast } = useToast()
|
||
|
||
const handleAction = async () => {
|
||
try {
|
||
const result = await apiClient.doSomething()
|
||
toast({
|
||
title: t('success.title'),
|
||
description: t('success.message'),
|
||
variant: 'success',
|
||
})
|
||
} catch (error: any) {
|
||
const errorMessage = error.response?.data?.detail || t('errors.generic')
|
||
toast({
|
||
title: t('error.title'),
|
||
description: errorMessage,
|
||
variant: 'destructive',
|
||
})
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3. 類名合併
|
||
```typescript
|
||
import { cn } from '@/lib/utils'
|
||
|
||
// 條件類名
|
||
const buttonClass = cn(
|
||
'base-classes',
|
||
{
|
||
'conditional-classes': isActive,
|
||
'other-classes': isDisabled,
|
||
},
|
||
customClassName
|
||
)
|
||
|
||
// 動態樣式
|
||
const cardClass = cn(
|
||
'p-4 rounded-lg',
|
||
variant === 'outlined' && 'border border-input',
|
||
variant === 'elevated' && 'shadow-lg'
|
||
)
|
||
```
|
||
|
||
### 4. React Query 配置
|
||
```typescript
|
||
// 主入口 (main.tsx)
|
||
const queryClient = new QueryClient({
|
||
defaultOptions: {
|
||
queries: {
|
||
retry: 1,
|
||
refetchOnWindowFocus: false,
|
||
staleTime: 1000 * 60 * 5, // 5 分鐘
|
||
},
|
||
},
|
||
})
|
||
|
||
// 使用示例
|
||
const { data, isLoading, error } = useQuery({
|
||
queryKey: ['items', id],
|
||
queryFn: () => apiClient.getItem(id),
|
||
enabled: !!id, // 條件查詢
|
||
staleTime: 1000 * 60 * 10, // 10 分鐘不新鮮
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
## 9. 環境變數
|
||
|
||
### .env 文件
|
||
```env
|
||
VITE_API_BASE_URL=http://localhost:12010
|
||
```
|
||
|
||
### 使用環境變數
|
||
```typescript
|
||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:12010'
|
||
```
|
||
|
||
---
|
||
|
||
## 10. 常見開發流程
|
||
|
||
### 創建新頁面
|
||
1. 在 `/src/pages` 創建 `NewPage.tsx`
|
||
2. 在 `App.tsx` 添加路由
|
||
3. 使用 Zustand 管理狀態
|
||
4. 使用 React Query 獲取數據
|
||
5. 使用 Tailwind CSS 樣式
|
||
6. 使用 i18next 添加文字
|
||
|
||
### 添加新 API 接口
|
||
1. 在 `types/api.ts` 定義類型
|
||
2. 在 `services/api.ts` 添加方法
|
||
3. 在需要的地方使用 `apiClient.method()`
|
||
4. 使用 React Query 或直接調用
|
||
|
||
### 修改樣式
|
||
1. 優先使用 Tailwind CSS 工具類
|
||
2. 使用 cn() 合併類名
|
||
3. 修改 `tailwind.config.js` 自定義主題
|
||
4. 在 `index.css` 添加全局樣式
|
||
|