feat: modernize frontend UI with Tailwind v4 and professional design system
BREAKING CHANGE: Migrated to Tailwind CSS v4 configuration system Key Changes: - Migrated from Tailwind v3 to v4 configuration system - Removed tailwind.config.js (incompatible with v4) - Updated index.css with @theme directive and oklch color space - Defined all custom animations directly in CSS using @keyframes - Redesigned LoginPage with modern, enterprise-grade UI: - Full-screen gradient background (blue → purple → pink) - Floating animated orbs with blur effects - Glass morphism white card with backdrop-blur - Gradient buttons with shadow effects - 7 custom animations: fade-in, slide-in-right, slide-in-left, scale-in, shimmer, pulse, float - Added shadcn/ui components: - alert.tsx, dialog.tsx, input.tsx, label.tsx, select.tsx, tabs.tsx - Updated dependencies: - Added class-variance-authority ^0.7.0 - Added react-markdown ^9.0.1 - Updated frontend documentation: - Comprehensive README.md with feature list, tech stack, project structure - Quick start guide and deployment instructions Technical Details: - Tailwind v4 uses @import "tailwindcss" instead of @tailwind directives - All theme customization now in @theme block with CSS variables - Color system migrated to oklch for better perceptual uniformity - Animation definitions moved from config to CSS @layer utilities 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,73 +1,227 @@
|
||||
# React + TypeScript + Vite
|
||||
# Tool_OCR Frontend
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
> 現代化的 OCR 文件處理系統前端介面
|
||||
>
|
||||
> 基於 React 18 + Vite + TypeScript + Tailwind CSS + shadcn/ui
|
||||
|
||||
Currently, two official plugins are available:
|
||||
---
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
## 目錄
|
||||
|
||||
## React Compiler
|
||||
- [快速開始](#快速開始)
|
||||
- [功能特性](#功能特性)
|
||||
- [技術棧](#技術棧)
|
||||
- [專案結構](#專案結構)
|
||||
- [開發指南](#開發指南)
|
||||
- [部署說明](#部署說明)
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
---
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
## 快速開始
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
### 前置需求
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
- Node.js >= 18.0.0
|
||||
- npm >= 9.0.0
|
||||
- 後端 API 服務運行在 `http://localhost:12010`
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
### 安裝依賴
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
### 啟動開發伺服器
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
瀏覽器開啟: `http://localhost:12011`
|
||||
|
||||
### 建置生產版本
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
建置輸出目錄: `dist/`
|
||||
|
||||
---
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 1. 使用者認證
|
||||
- JWT Token 認證
|
||||
- 自動登出處理
|
||||
- 受保護路由
|
||||
|
||||
### 2. 批次檔案上傳
|
||||
- **拖放上傳介面** (react-dropzone)
|
||||
- **支援格式**: PNG, JPG, JPEG, PDF, DOC, DOCX, PPT, PPTX
|
||||
- **批次限制**: 單檔 50MB, 每批次最多 100 檔
|
||||
- **即時預覽**: 檔案清單、大小、狀態
|
||||
|
||||
### 3. OCR 處理追蹤
|
||||
- **即時進度顯示** (2 秒輪詢)
|
||||
- **統計儀表板**: 已完成/處理中/失敗數量
|
||||
- **檔案級別狀態**: 處理狀態、時間、錯誤訊息
|
||||
- **自動跳轉**: 處理完成後自動進入結果頁面
|
||||
|
||||
### 4. 結果預覽
|
||||
- **雙欄佈局**: 檔案清單 + 內容預覽
|
||||
- **多格式預覽**: Markdown 渲染 / JSON 資料
|
||||
- **統計資訊**: OCR 信心度、處理時間、文字區塊數
|
||||
|
||||
### 5. 靈活匯出
|
||||
- **匯出格式**: TXT, JSON, Excel, Markdown, PDF, ZIP
|
||||
- **自訂規則**: 建立、編輯、刪除匯出規則
|
||||
- **CSS 模板**: 選擇 PDF 樣式 (default, academic, business)
|
||||
|
||||
### 6. 設定管理
|
||||
- 使用者資訊顯示
|
||||
- 匯出規則 CRUD 操作
|
||||
- 系統設定
|
||||
|
||||
---
|
||||
|
||||
## 技術棧
|
||||
|
||||
### 核心框架
|
||||
- **React 18.2** - 使用者介面框架
|
||||
- **TypeScript 5.9** - 型別安全
|
||||
- **Vite 7.2** - 快速建置工具
|
||||
|
||||
### 狀態管理
|
||||
- **React Query (TanStack Query)** - 伺服器狀態管理
|
||||
- **Zustand** - 客戶端狀態管理
|
||||
|
||||
### UI 組件庫
|
||||
- **Tailwind CSS 4.1** - CSS 框架
|
||||
- **shadcn/ui** - React 組件庫
|
||||
- **Lucide React** - 圖示庫
|
||||
|
||||
### 路由與 HTTP
|
||||
- **React Router 7.9** - 客戶端路由
|
||||
- **Axios** - HTTP 請求
|
||||
|
||||
### 其他
|
||||
- **react-dropzone** - 拖放上傳
|
||||
- **react-markdown** - Markdown 渲染
|
||||
- **i18next** - 國際化
|
||||
|
||||
---
|
||||
|
||||
## 專案結構
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # shadcn/ui 組件
|
||||
│ │ ├── FileUpload.tsx
|
||||
│ │ ├── ResultsTable.tsx
|
||||
│ │ ├── MarkdownPreview.tsx
|
||||
│ │ └── Layout.tsx
|
||||
│ ├── pages/
|
||||
│ │ ├── LoginPage.tsx
|
||||
│ │ ├── UploadPage.tsx
|
||||
│ │ ├── ProcessingPage.tsx
|
||||
│ │ ├── ResultsPage.tsx
|
||||
│ │ ├── ExportPage.tsx
|
||||
│ │ └── SettingsPage.tsx
|
||||
│ ├── store/
|
||||
│ │ ├── authStore.ts
|
||||
│ │ └── uploadStore.ts
|
||||
│ ├── services/
|
||||
│ │ └── api.ts
|
||||
│ ├── types/
|
||||
│ │ └── api.ts
|
||||
│ ├── lib/
|
||||
│ │ └── utils.ts
|
||||
│ ├── i18n/
|
||||
│ ├── styles/
|
||||
│ ├── App.tsx
|
||||
│ └── main.tsx
|
||||
├── package.json
|
||||
├── vite.config.ts
|
||||
├── tailwind.config.js
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 開發指南
|
||||
|
||||
### 安裝新依賴
|
||||
|
||||
```bash
|
||||
# 安裝 class-variance-authority (如果尚未安裝)
|
||||
npm install class-variance-authority
|
||||
|
||||
# 安裝 react-markdown (如果尚未安裝)
|
||||
npm install react-markdown
|
||||
```
|
||||
|
||||
### 新增 shadcn/ui 組件
|
||||
|
||||
所有 shadcn/ui 組件已在 `src/components/ui/` 目錄中,包括:
|
||||
- Button, Card, Input, Label, Select
|
||||
- Alert, Dialog, Tabs, Badge
|
||||
- Progress, Toast, Table
|
||||
|
||||
### 整合新 API
|
||||
|
||||
1. 在 `src/types/api.ts` 定義型別
|
||||
2. 在 `src/services/api.ts` 新增方法
|
||||
3. 在頁面組件使用 React Query
|
||||
|
||||
---
|
||||
|
||||
## 部署說明
|
||||
|
||||
### 環境變數
|
||||
|
||||
建立 `.env.production`:
|
||||
|
||||
```bash
|
||||
VITE_API_BASE_URL=http://your-api-server.com:12010
|
||||
VITE_APP_NAME=Tool_OCR
|
||||
```
|
||||
|
||||
### 建置
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 部署到 Nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name tool-ocr.example.com;
|
||||
root /var/www/tool-ocr;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://127.0.0.1:12010;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 參考文件
|
||||
|
||||
- [FRONTEND_API.md](../FRONTEND_API.md) - 完整前端 API 文件
|
||||
- [API_REFERENCE.md](../API_REFERENCE.md) - 後端 API 參考
|
||||
|
||||
---
|
||||
|
||||
**版本**: 0.1.0 | **最後更新**: 2025-01-13
|
||||
|
||||
1208
frontend/package-lock.json
generated
1208
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^25.6.2",
|
||||
"lucide-react": "^0.553.0",
|
||||
@@ -19,6 +20,7 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-i18next": "^16.3.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-router-dom": "^7.9.5",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zustand": "^5.0.8"
|
||||
|
||||
76
frontend/src/components/ui/alert.tsx
Normal file
76
frontend/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AlertCircle, CheckCircle2, Info, AlertTriangle } from 'lucide-react'
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:pl-7',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background text-foreground border-border',
|
||||
info: 'bg-primary/10 text-primary border-primary/30',
|
||||
success: 'bg-success/10 text-success border-success/30',
|
||||
warning: 'bg-warning/10 text-warning border-warning/30',
|
||||
destructive: 'bg-destructive/10 text-destructive border-destructive/30',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const icons = {
|
||||
default: Info,
|
||||
info: Info,
|
||||
success: CheckCircle2,
|
||||
warning: AlertTriangle,
|
||||
destructive: AlertCircle,
|
||||
}
|
||||
|
||||
export interface AlertProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof alertVariants> {
|
||||
showIcon?: boolean
|
||||
}
|
||||
|
||||
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
|
||||
({ className, variant = 'default', showIcon = true, children, ...props }, ref) => {
|
||||
const Icon = icons[variant || 'default']
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && <Icon className="h-5 w-5" />}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Alert.displayName = 'Alert'
|
||||
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn('mb-1 font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
AlertTitle.displayName = 'AlertTitle'
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
|
||||
))
|
||||
AlertDescription.displayName = 'AlertDescription'
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
118
frontend/src/components/ui/dialog.tsx
Normal file
118
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
export interface DialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Dialog: React.FC<DialogProps> = ({ open, onClose, children }) => {
|
||||
React.useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Dialog */}
|
||||
<div className="relative z-50 w-full max-w-lg mx-4 animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-card rounded-xl border border-border shadow-xl',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
DialogContent.displayName = 'DialogContent'
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex items-center justify-between p-6 border-b border-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
const DialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h2
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
DialogTitle.displayName = 'DialogTitle'
|
||||
|
||||
const DialogBody = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('p-6', className)} {...props} />
|
||||
)
|
||||
DialogBody.displayName = 'DialogBody'
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex items-center justify-end gap-3 p-6 border-t border-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogClose = ({
|
||||
onClose,
|
||||
className,
|
||||
}: {
|
||||
onClose: () => void
|
||||
className?: string
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'p-2 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground transition-colors',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)
|
||||
|
||||
export { Dialog, DialogContent, DialogHeader, DialogTitle, DialogBody, DialogFooter, DialogClose }
|
||||
28
frontend/src/components/ui/input.tsx
Normal file
28
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm',
|
||||
'ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:border-primary',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-colors',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
23
frontend/src/components/ui/label.tsx
Normal file
23
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {}
|
||||
|
||||
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm font-medium leading-none text-foreground',
|
||||
'peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Label.displayName = 'Label'
|
||||
|
||||
export { Label }
|
||||
38
frontend/src/components/ui/select.tsx
Normal file
38
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
|
||||
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
options: Array<{ value: string; label: string }>
|
||||
}
|
||||
|
||||
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, options, ...props }, ref) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<select
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm',
|
||||
'ring-offset-background',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:border-primary',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-colors appearance-none pr-10',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Select.displayName = 'Select'
|
||||
|
||||
export { Select }
|
||||
108
frontend/src/components/ui/tabs.tsx
Normal file
108
frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface TabsProps {
|
||||
defaultValue?: string
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface TabsContextValue {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
}
|
||||
|
||||
const TabsContext = React.createContext<TabsContextValue | undefined>(undefined)
|
||||
|
||||
const Tabs: React.FC<TabsProps> = ({
|
||||
defaultValue,
|
||||
value: controlledValue,
|
||||
onValueChange,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
const [internalValue, setInternalValue] = React.useState(defaultValue || '')
|
||||
const value = controlledValue !== undefined ? controlledValue : internalValue
|
||||
const handleValueChange = onValueChange || setInternalValue
|
||||
|
||||
return (
|
||||
<TabsContext.Provider value={{ value, onValueChange: handleValueChange }}>
|
||||
<div className={className}>{children}</div>
|
||||
</TabsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const TabsList = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
TabsList.displayName = 'TabsList'
|
||||
|
||||
interface TabsTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
value: string
|
||||
}
|
||||
|
||||
const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
|
||||
({ className, value, ...props }, ref) => {
|
||||
const context = React.useContext(TabsContext)
|
||||
if (!context) throw new Error('TabsTrigger must be used within Tabs')
|
||||
|
||||
const isSelected = context.value === value
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
onClick={() => context.onValueChange(value)}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium',
|
||||
'ring-offset-background transition-all',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:ring-offset-2',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
isSelected
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
TabsTrigger.displayName = 'TabsTrigger'
|
||||
|
||||
interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value: string
|
||||
}
|
||||
|
||||
const TabsContent = React.forwardRef<HTMLDivElement, TabsContentProps>(
|
||||
({ className, value, ...props }, ref) => {
|
||||
const context = React.useContext(TabsContext)
|
||||
if (!context) throw new Error('TabsContent must be used within Tabs')
|
||||
|
||||
if (context.value !== value) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
TabsContent.displayName = 'TabsContent'
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -1,115 +1,172 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Clean, modern color palette */
|
||||
--background: 220 15% 97%;
|
||||
--foreground: 220 15% 15%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 220 15% 15%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 220 15% 15%;
|
||||
@theme {
|
||||
/* Clean, modern color palette */
|
||||
--color-background: oklch(95% 0.02 220);
|
||||
--color-foreground: oklch(20% 0.02 220);
|
||||
--color-card: oklch(100% 0 0);
|
||||
--color-card-foreground: oklch(20% 0.02 220);
|
||||
--color-popover: oklch(100% 0 0);
|
||||
--color-popover-foreground: oklch(20% 0.02 220);
|
||||
|
||||
/* Primary: Professional blue */
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--primary-hover: 217 91% 50%;
|
||||
/* Primary: Professional blue */
|
||||
--color-primary: oklch(65% 0.25 250);
|
||||
--color-primary-foreground: oklch(100% 0 0);
|
||||
--color-primary-hover: oklch(60% 0.25 250);
|
||||
|
||||
/* Secondary: Subtle gray-blue */
|
||||
--secondary: 220 15% 95%;
|
||||
--secondary-foreground: 220 15% 25%;
|
||||
/* Secondary: Subtle gray-blue */
|
||||
--color-secondary: oklch(95% 0.02 220);
|
||||
--color-secondary-foreground: oklch(30% 0.02 220);
|
||||
|
||||
/* Accent: Vibrant teal */
|
||||
--accent: 173 80% 50%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
/* Accent: Vibrant teal */
|
||||
--color-accent: oklch(65% 0.20 180);
|
||||
--color-accent-foreground: oklch(100% 0 0);
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: 220 25% 12%;
|
||||
--sidebar-foreground: 220 10% 90%;
|
||||
--sidebar-active: 217 91% 60%;
|
||||
/* Muted */
|
||||
--color-muted: oklch(93% 0.02 220);
|
||||
--color-muted-foreground: oklch(50% 0.02 220);
|
||||
|
||||
/* Muted */
|
||||
--muted: 220 15% 93%;
|
||||
--muted-foreground: 220 10% 45%;
|
||||
/* Destructive */
|
||||
--color-destructive: oklch(60% 0.22 25);
|
||||
--color-destructive-foreground: oklch(100% 0 0);
|
||||
|
||||
/* Destructive */
|
||||
--destructive: 0 85% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
/* Success */
|
||||
--color-success: oklch(55% 0.20 150);
|
||||
--color-success-foreground: oklch(100% 0 0);
|
||||
|
||||
/* Success */
|
||||
--success: 142 72% 45%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
/* Warning */
|
||||
--color-warning: oklch(65% 0.22 60);
|
||||
--color-warning-foreground: oklch(100% 0 0);
|
||||
|
||||
/* Warning */
|
||||
--warning: 38 92% 50%;
|
||||
--warning-foreground: 0 0% 100%;
|
||||
/* Borders and inputs */
|
||||
--color-border: oklch(88% 0.02 220);
|
||||
--color-input: oklch(88% 0.02 220);
|
||||
--color-ring: oklch(65% 0.25 250);
|
||||
|
||||
/* Borders and inputs */
|
||||
--border: 220 13% 88%;
|
||||
--input: 220 13% 88%;
|
||||
--ring: 217 91% 60%;
|
||||
--radius: 0.5rem;
|
||||
/* Border radius */
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-md: calc(0.5rem - 2px);
|
||||
--radius-sm: calc(0.5rem - 4px);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark mode with rich colors */
|
||||
--background: 240 20% 8%;
|
||||
--foreground: 240 5% 95%;
|
||||
--card: 240 15% 12%;
|
||||
--card-foreground: 240 5% 95%;
|
||||
--popover: 240 15% 12%;
|
||||
--popover-foreground: 240 5% 95%;
|
||||
|
||||
/* Primary: Brighter in dark mode */
|
||||
--primary: 250 85% 65%;
|
||||
--primary-foreground: 240 20% 8%;
|
||||
--primary-glow: 250 85% 65%;
|
||||
|
||||
/* Secondary */
|
||||
--secondary: 240 15% 18%;
|
||||
--secondary-foreground: 240 5% 95%;
|
||||
|
||||
/* Accent */
|
||||
--accent: 190 85% 55%;
|
||||
--accent-foreground: 240 20% 8%;
|
||||
|
||||
/* Muted */
|
||||
--muted: 240 15% 15%;
|
||||
--muted-foreground: 240 5% 65%;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: 0 80% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
/* Success */
|
||||
--success: 142 70% 50%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
|
||||
/* Borders */
|
||||
--border: 240 15% 20%;
|
||||
--input: 240 15% 20%;
|
||||
--ring: 250 85% 65%;
|
||||
}
|
||||
/* Animations */
|
||||
--animate-fade-in: fade-in 0.5s ease-out;
|
||||
--animate-slide-in-right: slide-in-right 0.5s ease-out;
|
||||
--animate-slide-in-left: slide-in-left 0.5s ease-out;
|
||||
--animate-scale-in: scale-in 0.3s ease-out;
|
||||
--animate-shimmer: shimmer 2s linear infinite;
|
||||
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
--animate-float: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Animations */
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-left {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.5s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 0.5s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in-left {
|
||||
animation: slide-in-left 0.5s ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scale-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s linear infinite;
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -117,17 +174,17 @@
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: hsl(var(--muted));
|
||||
background: var(--color-muted);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted-foreground) / 0.3);
|
||||
background: color-mix(in oklch, var(--color-muted-foreground) 30%, transparent);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.5);
|
||||
background: color-mix(in oklch, var(--color-muted-foreground) 50%, transparent);
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
@@ -149,31 +206,31 @@
|
||||
.page-title {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
color: var(--color-foreground);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
color: hsl(var(--muted-foreground));
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.section {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
color: var(--color-foreground);
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Status badge */
|
||||
@@ -187,22 +244,22 @@
|
||||
}
|
||||
|
||||
.status-badge-success {
|
||||
background: hsl(var(--success) / 0.1);
|
||||
color: hsl(var(--success));
|
||||
background: color-mix(in oklch, var(--color-success) 10%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.status-badge-warning {
|
||||
background: hsl(var(--warning) / 0.1);
|
||||
color: hsl(var(--warning));
|
||||
background: color-mix(in oklch, var(--color-warning) 10%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.status-badge-error {
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
color: hsl(var(--destructive));
|
||||
background: color-mix(in oklch, var(--color-destructive) 10%, transparent);
|
||||
color: var(--color-destructive);
|
||||
}
|
||||
|
||||
.status-badge-info {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
color: hsl(var(--primary));
|
||||
background: color-mix(in oklch, var(--color-primary) 10%, transparent);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { apiClient } from '@/services/api'
|
||||
import { Lock, User, LayoutDashboard, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import { Lock, User, LayoutDashboard, AlertCircle, Loader2, Sparkles, Zap, Shield } from 'lucide-react'
|
||||
|
||||
export default function LoginPage() {
|
||||
const { t } = useTranslation()
|
||||
@@ -21,13 +21,11 @@ export default function LoginPage() {
|
||||
|
||||
try {
|
||||
await apiClient.login({ username, password })
|
||||
// For now, just set a basic user object (backend doesn't return user info)
|
||||
setUser({ id: 1, username })
|
||||
navigate('/upload')
|
||||
} catch (err: any) {
|
||||
const errorDetail = err.response?.data?.detail
|
||||
if (Array.isArray(errorDetail)) {
|
||||
// Handle validation error array from backend
|
||||
setError(errorDetail.map((e: any) => e.msg || e.message || String(e)).join(', '))
|
||||
} else if (typeof errorDetail === 'string') {
|
||||
setError(errorDetail)
|
||||
@@ -40,197 +38,194 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
{/* Left side - Branding (hidden on mobile) */}
|
||||
<div className="hidden lg:flex lg:flex-1 bg-gradient-to-br from-primary via-primary/90 to-accent relative overflow-hidden">
|
||||
{/* Subtle background pattern */}
|
||||
<div className="min-h-screen relative overflow-hidden">
|
||||
{/* Full-screen Animated Background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-600 via-purple-600 to-pink-500">
|
||||
{/* Animated overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-blue-500/50 via-transparent to-purple-500/50 animate-pulse"></div>
|
||||
|
||||
{/* Floating orbs */}
|
||||
<div className="absolute top-20 left-20 w-72 h-72 bg-white/10 rounded-full blur-3xl animate-float"></div>
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-blue-400/20 rounded-full blur-3xl animate-float" style={{ animationDelay: '1s' }}></div>
|
||||
<div className="absolute top-1/2 left-1/2 w-64 h-64 bg-purple-400/10 rounded-full blur-3xl animate-float" style={{ animationDelay: '0.5s' }}></div>
|
||||
|
||||
{/* Grid pattern */}
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-0 left-0 w-full h-full"
|
||||
style={{ backgroundImage: 'radial-gradient(circle at 2px 2px, white 1px, transparent 0)', backgroundSize: '32px 32px' }}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 flex flex-col justify-center px-16 py-12 text-white">
|
||||
{/* Logo & Title */}
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-14 h-14 bg-white/20 backdrop-blur-sm rounded-xl flex items-center justify-center">
|
||||
<LayoutDashboard className="w-8 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{t('app.title')}</h1>
|
||||
<p className="text-white/80 text-sm mt-1">{t('app.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature highlights */}
|
||||
<div className="space-y-6 mb-12">
|
||||
<h2 className="text-2xl font-semibold mb-6">智能文件識別系統</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-white/20 backdrop-blur-sm rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">高精度識別</h3>
|
||||
<p className="text-white/70 text-sm">支援 10+ 種文件格式,識別準確率達 99%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-white/20 backdrop-blur-sm rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">快速處理</h3>
|
||||
<p className="text-white/70 text-sm">批量處理大量文件,節省時間成本</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-white/20 backdrop-blur-sm rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">安全可靠</h3>
|
||||
<p className="text-white/70 text-sm">企業級安全保障,數據加密傳輸</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-4">
|
||||
<div className="text-3xl font-bold mb-1">99%</div>
|
||||
<div className="text-white/70 text-sm">識別準確率</div>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-4">
|
||||
<div className="text-3xl font-bold mb-1">10+</div>
|
||||
<div className="text-white/70 text-sm">支援格式</div>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-4">
|
||||
<div className="text-3xl font-bold mb-1">1M+</div>
|
||||
<div className="text-white/70 text-sm">處理文件</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0" style={{ backgroundImage: 'linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px)', backgroundSize: '50px 50px' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Login form */}
|
||||
<div className="flex-1 flex items-center justify-center p-8 bg-background">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Mobile logo (shown only on small screens) */}
|
||||
<div className="lg:hidden text-center mb-8">
|
||||
<div className="inline-flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 bg-primary/20 rounded-lg flex items-center justify-center">
|
||||
<LayoutDashboard className="w-6 h-6 text-primary" />
|
||||
{/* Content Container */}
|
||||
<div className="relative z-10 min-h-screen flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-6xl flex flex-col lg:flex-row gap-8 items-center">
|
||||
|
||||
{/* Left Side - Branding */}
|
||||
<div className="flex-1 text-white text-center lg:text-left animate-fade-in">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-center lg:justify-start gap-4 mb-8">
|
||||
<div className="w-16 h-16 bg-white/20 backdrop-blur-md rounded-2xl flex items-center justify-center shadow-2xl animate-float">
|
||||
<LayoutDashboard className="w-10 h-10" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight">Tool_OCR</h1>
|
||||
<p className="text-white/80 text-sm mt-1">智能 OCR 處理平台</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features - Only show on larger screens */}
|
||||
<div className="hidden lg:block space-y-6 mb-12 animate-slide-in-left" style={{ animationDelay: '0.1s' }}>
|
||||
<h2 className="text-3xl font-bold mb-8">為什麼選擇我們?</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4 group hover:translate-x-2 transition-transform">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-white/20 to-white/10 backdrop-blur-sm rounded-xl flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform">
|
||||
<Sparkles className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-xl mb-1">高精度識別</h3>
|
||||
<p className="text-white/70">AI 驅動的 OCR 引擎,識別準確率高達 99%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 group hover:translate-x-2 transition-transform">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-white/20 to-white/10 backdrop-blur-sm rounded-xl flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform">
|
||||
<Zap className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-xl mb-1">閃電般快速</h3>
|
||||
<p className="text-white/70">批量處理數百份文件,大幅提升工作效率</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 group hover:translate-x-2 transition-transform">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-white/20 to-white/10 backdrop-blur-sm rounded-xl flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform">
|
||||
<Shield className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-xl mb-1">安全可靠</h3>
|
||||
<p className="text-white/70">企業級加密,確保您的資料絕對安全</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="hidden lg:grid grid-cols-3 gap-4 animate-slide-in-left" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 hover:bg-white/20 hover:scale-105 transition-all cursor-pointer border border-white/20">
|
||||
<div className="text-4xl font-bold mb-2">99%</div>
|
||||
<div className="text-white/70 text-sm">準確率</div>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 hover:bg-white/20 hover:scale-105 transition-all cursor-pointer border border-white/20">
|
||||
<div className="text-4xl font-bold mb-2">10+</div>
|
||||
<div className="text-white/70 text-sm">支援格式</div>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 hover:bg-white/20 hover:scale-105 transition-all cursor-pointer border border-white/20">
|
||||
<div className="text-4xl font-bold mb-2">24/7</div>
|
||||
<div className="text-white/70 text-sm">全天候</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('app.title')}</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t('app.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Form card */}
|
||||
<div className="bg-card rounded-xl border border-border p-8 shadow-lg">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">{t('auth.loginButton')}</h2>
|
||||
<p className="text-sm text-muted-foreground">請輸入您的帳號資訊以繼續</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Username field */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="username" className="block text-sm font-medium text-foreground">
|
||||
{t('auth.username')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-background border border-border rounded-lg
|
||||
text-foreground placeholder-muted-foreground
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary
|
||||
transition-colors"
|
||||
placeholder="輸入用戶名"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{/* Right Side - Login Form */}
|
||||
<div className="w-full lg:w-auto lg:min-w-[480px] animate-scale-in">
|
||||
<div className="bg-white/95 backdrop-blur-xl rounded-3xl p-8 md:p-10 shadow-2xl border border-white/20">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">歡迎回來</h2>
|
||||
<p className="text-gray-600">登入以開始使用 OCR 服務</p>
|
||||
</div>
|
||||
|
||||
{/* Password field */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-muted-foreground" />
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Username */}
|
||||
<div className="space-y-2 animate-slide-in-right" style={{ animationDelay: '0.1s' }}>
|
||||
<label htmlFor="username" className="block text-sm font-semibold text-gray-700">
|
||||
使用者名稱
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-gray-400 group-focus-within:text-blue-600 transition-colors" />
|
||||
</div>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full pl-11 pr-4 py-3 bg-gray-50 border-2 border-gray-200 rounded-xl
|
||||
text-gray-900 placeholder-gray-400
|
||||
focus:outline-none focus:ring-4 focus:ring-blue-500/20 focus:border-blue-500
|
||||
hover:border-gray-300
|
||||
transition-all duration-200"
|
||||
placeholder="輸入您的用戶名"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-background border border-border rounded-lg
|
||||
text-foreground placeholder-muted-foreground
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary
|
||||
transition-colors"
|
||||
placeholder="輸入密碼"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 p-4 bg-destructive/10 border border-destructive/30 rounded-lg">
|
||||
<AlertCircle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
{/* Password */}
|
||||
<div className="space-y-2 animate-slide-in-right" style={{ animationDelay: '0.2s' }}>
|
||||
<label htmlFor="password" className="block text-sm font-semibold text-gray-700">
|
||||
密碼
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400 group-focus-within:text-blue-600 transition-colors" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-11 pr-4 py-3 bg-gray-50 border-2 border-gray-200 rounded-xl
|
||||
text-gray-900 placeholder-gray-400
|
||||
focus:outline-none focus:ring-4 focus:ring-blue-500/20 focus:border-blue-500
|
||||
hover:border-gray-300
|
||||
transition-all duration-200"
|
||||
placeholder="輸入您的密碼"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 px-6 bg-primary text-white rounded-lg font-semibold
|
||||
shadow-md hover:bg-primary-hover hover:shadow-lg
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/20 focus:ring-offset-2
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-200
|
||||
flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span>{t('common.loading')}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{t('auth.loginButton')}</span>
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 p-4 bg-red-50 border-2 border-red-200 rounded-xl animate-scale-in">
|
||||
<AlertCircle className="h-5 w-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 font-medium">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Powered by <span className="font-semibold text-foreground">AI Technology</span>
|
||||
</p>
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-4 px-6 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-xl font-bold text-lg
|
||||
shadow-lg shadow-blue-500/50 hover:shadow-xl hover:shadow-blue-500/60 hover:scale-[1.02]
|
||||
focus:outline-none focus:ring-4 focus:ring-blue-500/50
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100
|
||||
transition-all duration-200
|
||||
relative overflow-hidden group
|
||||
animate-slide-in-right"
|
||||
style={{ animationDelay: '0.3s' }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-700 to-purple-700 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
<span className="relative flex items-center justify-center gap-2">
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
<span>登入中...</span>
|
||||
</>
|
||||
) : (
|
||||
<span>立即登入</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 text-center animate-fade-in" style={{ animationDelay: '0.4s' }}>
|
||||
<p className="text-xs text-gray-500">
|
||||
Powered by <span className="font-bold text-gray-700">AI Technology</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
Reference in New Issue
Block a user