feat: complete external auth V2 migration with advanced features
This commit implements comprehensive external Azure AD authentication with complete task management, file download, and admin monitoring systems. ## Core Features Implemented (80% Complete) ### 1. Token Auto-Refresh Mechanism ✅ - Backend: POST /api/v2/auth/refresh endpoint - Frontend: Auto-refresh 5 minutes before expiration - Auto-retry on 401 errors with seamless token refresh ### 2. File Download System ✅ - Three format support: JSON / Markdown / PDF - Endpoints: GET /api/v2/tasks/{id}/download/{format} - File access control with ownership validation - Frontend download buttons in TaskHistoryPage ### 3. Complete Task Management ✅ Backend Endpoints: - POST /api/v2/tasks/{id}/start - Start task - POST /api/v2/tasks/{id}/cancel - Cancel task - POST /api/v2/tasks/{id}/retry - Retry failed task - GET /api/v2/tasks - List with filters (status, filename, date range) - GET /api/v2/tasks/stats - User statistics Frontend Features: - Status-based action buttons (Start/Cancel/Retry) - Advanced search and filtering (status, filename, date range) - Pagination and sorting - Task statistics dashboard (5 stat cards) ### 4. Admin Monitoring System ✅ (Backend) Admin APIs: - GET /api/v2/admin/stats - System statistics - GET /api/v2/admin/users - User list with stats - GET /api/v2/admin/users/top - User leaderboard - GET /api/v2/admin/audit-logs - Audit log query system - GET /api/v2/admin/audit-logs/user/{id}/summary Admin Features: - Email-based admin check (ymirliu@panjit.com.tw) - Comprehensive system metrics (users, tasks, sessions, activity) - Audit logging service for security tracking ### 5. User Isolation & Security ✅ - Row-level security on all task queries - File access control with ownership validation - Strict user_id filtering on all operations - Session validation and expiry checking - Admin privilege verification ## New Files Created Backend: - backend/app/models/user_v2.py - User model for external auth - backend/app/models/task.py - Task model with user isolation - backend/app/models/session.py - Session management - backend/app/models/audit_log.py - Audit log model - backend/app/services/external_auth_service.py - External API client - backend/app/services/task_service.py - Task CRUD with isolation - backend/app/services/file_access_service.py - File access control - backend/app/services/admin_service.py - Admin operations - backend/app/services/audit_service.py - Audit logging - backend/app/routers/auth_v2.py - V2 auth endpoints - backend/app/routers/tasks.py - Task management endpoints - backend/app/routers/admin.py - Admin endpoints - backend/alembic/versions/5e75a59fb763_*.py - DB migration Frontend: - frontend/src/services/apiV2.ts - Complete V2 API client - frontend/src/types/apiV2.ts - V2 type definitions - frontend/src/pages/TaskHistoryPage.tsx - Task history UI Modified Files: - backend/app/core/deps.py - Added get_current_admin_user_v2 - backend/app/main.py - Registered admin router - frontend/src/pages/LoginPage.tsx - V2 login integration - frontend/src/components/Layout.tsx - User display and logout - frontend/src/App.tsx - Added /tasks route ## Documentation - openspec/changes/.../PROGRESS_UPDATE.md - Detailed progress report ## Pending Items (20%) 1. Database migration execution for audit_logs table 2. Frontend admin dashboard page 3. Frontend audit log viewer ## Testing Status - Manual testing: ✅ Authentication flow verified - Unit tests: ⏳ Pending - Integration tests: ⏳ Pending ## Security Enhancements - ✅ User isolation (row-level security) - ✅ File access control - ✅ Token expiry validation - ✅ Admin privilege verification - ✅ Audit logging infrastructure - ⏳ Token encryption (noted, low priority) - ⏳ Rate limiting (noted, low priority) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
26
frontend/package-lock.json
generated
26
frontend/package-lock.json
generated
@@ -2280,9 +2280,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.8.27",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.27.tgz",
|
||||
"integrity": "sha512-2CXFpkjVnY2FT+B6GrSYxzYf65BJWEqz5tIRHCvNsZZ2F3CmsCB37h8SpYgKG7y9C4YAeTipIPWG7EmFmhAeXA==",
|
||||
"version": "2.8.28",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.28.tgz",
|
||||
"integrity": "sha512-gYjt7OIqdM0PcttNYP2aVrr2G0bMALkBaoehD4BuRGjAOtipg0b6wHg1yNL+s5zSnLZZrGHOw4IrND8CD+3oIQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -5001,9 +5001,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.3.1.tgz",
|
||||
"integrity": "sha512-HbYaBeA58Hg38OzdEvJp4kLIvk10rp9F9Jq+wNkqtqxDXObtdYMSsQnegWgdUVcpZjZuK9ZxehM+Z9BW2Vqgqw==",
|
||||
"version": "16.3.3",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.3.3.tgz",
|
||||
"integrity": "sha512-IaY2W+ueVd/fe7H6Wj2S4bTuLNChnajFUlZFfCTrTHWzGcOrUHlVzW55oXRSl+J51U8Onn6EvIhQ+Bar9FUcjw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6",
|
||||
@@ -5071,9 +5071,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.9.5",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz",
|
||||
"integrity": "sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A==",
|
||||
"version": "7.9.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz",
|
||||
"integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
@@ -5093,12 +5093,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.9.5",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.5.tgz",
|
||||
"integrity": "sha512-mkEmq/K8tKN63Ae2M7Xgz3c9l9YNbY+NHH6NNeUmLA3kDkhKXRsNb/ZpxaEunvGo2/3YXdk5EJU3Hxp3ocaBPw==",
|
||||
"version": "7.9.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz",
|
||||
"integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.9.5"
|
||||
"react-router": "7.9.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
||||
@@ -6,6 +6,7 @@ import ProcessingPage from '@/pages/ProcessingPage'
|
||||
import ResultsPage from '@/pages/ResultsPage'
|
||||
import ExportPage from '@/pages/ExportPage'
|
||||
import SettingsPage from '@/pages/SettingsPage'
|
||||
import TaskHistoryPage from '@/pages/TaskHistoryPage'
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
/**
|
||||
@@ -41,6 +42,7 @@ function App() {
|
||||
<Route path="processing" element={<ProcessingPage />} />
|
||||
<Route path="results" element={<ResultsPage />} />
|
||||
<Route path="export" element={<ExportPage />} />
|
||||
<Route path="tasks" element={<TaskHistoryPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Outlet, NavLink } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { apiClient } from '@/services/api'
|
||||
import { apiClientV2 } from '@/services/apiV2'
|
||||
import {
|
||||
Upload,
|
||||
Settings,
|
||||
@@ -12,7 +13,8 @@ import {
|
||||
LayoutDashboard,
|
||||
ChevronRight,
|
||||
Bell,
|
||||
Search
|
||||
Search,
|
||||
History
|
||||
} from 'lucide-react'
|
||||
|
||||
export default function Layout() {
|
||||
@@ -20,15 +22,26 @@ export default function Layout() {
|
||||
const logout = useAuthStore((state) => state.logout)
|
||||
const user = useAuthStore((state) => state.user)
|
||||
|
||||
const handleLogout = () => {
|
||||
apiClient.logout()
|
||||
logout()
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
// Use V2 API if authenticated with V2
|
||||
if (apiClientV2.isAuthenticated()) {
|
||||
await apiClientV2.logout()
|
||||
} else {
|
||||
apiClient.logout()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
} finally {
|
||||
logout()
|
||||
}
|
||||
}
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/upload', label: t('nav.upload'), icon: Upload, description: '上傳檔案' },
|
||||
{ to: '/processing', label: t('nav.processing'), icon: Activity, description: '處理進度' },
|
||||
{ to: '/results', label: t('nav.results'), icon: FileText, description: '查看結果' },
|
||||
{ to: '/tasks', label: '任務歷史', icon: History, description: '查看任務記錄' },
|
||||
{ to: '/export', label: t('nav.export'), icon: Download, description: '導出文件' },
|
||||
{ to: '/settings', label: t('nav.settings'), icon: Settings, description: '系統設定' },
|
||||
]
|
||||
@@ -86,8 +99,8 @@ export default function Layout() {
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{user.username}</div>
|
||||
<div className="text-xs text-sidebar-foreground/60">管理員</div>
|
||||
<div className="text-sm font-medium truncate">{user.displayName || user.username}</div>
|
||||
<div className="text-xs text-sidebar-foreground/60 truncate">{user.email || user.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { apiClient } from '@/services/api'
|
||||
import { apiClientV2 } from '@/services/apiV2'
|
||||
import { Lock, User, LayoutDashboard, AlertCircle, Loader2, Sparkles, Zap, Shield } from 'lucide-react'
|
||||
|
||||
export default function LoginPage() {
|
||||
@@ -20,8 +20,17 @@ export default function LoginPage() {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await apiClient.login({ username, password })
|
||||
setUser({ id: 1, username })
|
||||
// Use V2 API with external authentication
|
||||
const response = await apiClientV2.login({ username, password })
|
||||
|
||||
// Store user info from V2 API response
|
||||
setUser({
|
||||
id: response.user.id,
|
||||
username: response.user.email,
|
||||
email: response.user.email,
|
||||
displayName: response.user.display_name
|
||||
})
|
||||
|
||||
navigate('/upload')
|
||||
} catch (err: any) {
|
||||
const errorDetail = err.response?.data?.detail
|
||||
|
||||
569
frontend/src/pages/TaskHistoryPage.tsx
Normal file
569
frontend/src/pages/TaskHistoryPage.tsx
Normal file
@@ -0,0 +1,569 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { apiClientV2 } from '@/services/apiV2'
|
||||
import type { Task, TaskStats, TaskStatus } from '@/types/apiV2'
|
||||
import {
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Download,
|
||||
Trash2,
|
||||
Eye,
|
||||
FileText,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Filter,
|
||||
Play,
|
||||
X,
|
||||
RotateCcw,
|
||||
} from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
export default function TaskHistoryPage() {
|
||||
const navigate = useNavigate()
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [stats, setStats] = useState<TaskStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Filters
|
||||
const [statusFilter, setStatusFilter] = useState<TaskStatus | 'all'>('all')
|
||||
const [filenameSearch, setFilenameSearch] = useState('')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize] = useState(20)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
|
||||
// Fetch tasks
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
const response = await apiClientV2.listTasks({
|
||||
status: statusFilter === 'all' ? undefined : statusFilter,
|
||||
filename: filenameSearch || undefined,
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
order_by: 'created_at',
|
||||
order_desc: true,
|
||||
})
|
||||
|
||||
setTasks(response.tasks)
|
||||
setTotal(response.total)
|
||||
setHasMore(response.has_more)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || '載入任務失敗')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset to page 1 when filters change
|
||||
const handleFilterChange = () => {
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
// Fetch stats
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const statsData = await apiClientV2.getTaskStats()
|
||||
setStats(statsData)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch stats:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
fetchTasks()
|
||||
}, [statusFilter, filenameSearch, dateFrom, dateTo, page])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats()
|
||||
}, [])
|
||||
|
||||
// Delete task
|
||||
const handleDelete = async (taskId: string) => {
|
||||
if (!confirm('確定要刪除此任務嗎?')) return
|
||||
|
||||
try {
|
||||
await apiClientV2.deleteTask(taskId)
|
||||
fetchTasks()
|
||||
fetchStats()
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || '刪除任務失敗')
|
||||
}
|
||||
}
|
||||
|
||||
// View task details
|
||||
const handleViewDetails = (taskId: string) => {
|
||||
navigate(`/tasks/${taskId}`)
|
||||
}
|
||||
|
||||
// Download handlers
|
||||
const handleDownload = async (taskId: string, format: 'json' | 'markdown' | 'pdf') => {
|
||||
try {
|
||||
if (format === 'json') {
|
||||
await apiClientV2.downloadJSON(taskId)
|
||||
} else if (format === 'markdown') {
|
||||
await apiClientV2.downloadMarkdown(taskId)
|
||||
} else if (format === 'pdf') {
|
||||
await apiClientV2.downloadPDF(taskId)
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || `下載 ${format.toUpperCase()} 檔案失敗`)
|
||||
}
|
||||
}
|
||||
|
||||
// Task management handlers
|
||||
const handleStartTask = async (taskId: string) => {
|
||||
try {
|
||||
await apiClientV2.startTask(taskId)
|
||||
fetchTasks()
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || '啟動任務失敗')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelTask = async (taskId: string) => {
|
||||
if (!confirm('確定要取消此任務嗎?')) return
|
||||
try {
|
||||
await apiClientV2.cancelTask(taskId)
|
||||
fetchTasks()
|
||||
fetchStats()
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || '取消任務失敗')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRetryTask = async (taskId: string) => {
|
||||
try {
|
||||
await apiClientV2.retryTask(taskId)
|
||||
fetchTasks()
|
||||
fetchStats()
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || '重試任務失敗')
|
||||
}
|
||||
}
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-TW')
|
||||
}
|
||||
|
||||
// Format processing time
|
||||
const formatProcessingTime = (ms: number | null) => {
|
||||
if (!ms) return '-'
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
// Get status badge
|
||||
const getStatusBadge = (status: TaskStatus) => {
|
||||
const variants: Record<TaskStatus, { variant: any; icon: any; label: string }> = {
|
||||
pending: {
|
||||
variant: 'secondary',
|
||||
icon: Clock,
|
||||
label: '待處理',
|
||||
},
|
||||
processing: {
|
||||
variant: 'default',
|
||||
icon: Loader2,
|
||||
label: '處理中',
|
||||
},
|
||||
completed: {
|
||||
variant: 'default',
|
||||
icon: CheckCircle2,
|
||||
label: '已完成',
|
||||
},
|
||||
failed: {
|
||||
variant: 'destructive',
|
||||
icon: XCircle,
|
||||
label: '失敗',
|
||||
},
|
||||
}
|
||||
|
||||
const config = variants[status]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<Badge variant={config.variant} className="flex items-center gap-1">
|
||||
<Icon className={`w-3 h-3 ${status === 'processing' ? 'animate-spin' : ''}`} />
|
||||
{config.label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">任務歷史</h1>
|
||||
<p className="text-gray-600 mt-1">查看和管理您的 OCR 任務</p>
|
||||
</div>
|
||||
<Button onClick={() => fetchTasks()} variant="outline">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">總計</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.total}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">待處理</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-gray-600">{stats.pending}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">處理中</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-600">{stats.processing}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">已完成</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">{stats.completed}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">失敗</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">{stats.failed}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Filter className="w-5 h-5" />
|
||||
篩選條件
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">狀態</label>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(value) => {
|
||||
setStatusFilter(value as any)
|
||||
handleFilterChange()
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
<SelectItem value="pending">待處理</SelectItem>
|
||||
<SelectItem value="processing">處理中</SelectItem>
|
||||
<SelectItem value="completed">已完成</SelectItem>
|
||||
<SelectItem value="failed">失敗</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">檔案名稱</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filenameSearch}
|
||||
onChange={(e) => {
|
||||
setFilenameSearch(e.target.value)
|
||||
handleFilterChange()
|
||||
}}
|
||||
placeholder="搜尋檔案名稱"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">開始日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => {
|
||||
setDateFrom(e.target.value)
|
||||
handleFilterChange()
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">結束日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => {
|
||||
setDateTo(e.target.value)
|
||||
handleFilterChange()
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(statusFilter !== 'all' || filenameSearch || dateFrom || dateTo) && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStatusFilter('all')
|
||||
setFilenameSearch('')
|
||||
setDateFrom('')
|
||||
setDateTo('')
|
||||
handleFilterChange()
|
||||
}}
|
||||
>
|
||||
清除篩選
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
<p className="text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">任務列表</CardTitle>
|
||||
<CardDescription>
|
||||
共 {total} 個任務 {hasMore && `(顯示第 ${page} 頁)`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>暫無任務</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>檔案名稱</TableHead>
|
||||
<TableHead>狀態</TableHead>
|
||||
<TableHead>建立時間</TableHead>
|
||||
<TableHead>完成時間</TableHead>
|
||||
<TableHead>處理時間</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks.map((task) => (
|
||||
<TableRow key={task.id}>
|
||||
<TableCell className="font-medium">
|
||||
{task.filename || '未命名檔案'}
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(task.status)}</TableCell>
|
||||
<TableCell className="text-sm text-gray-600">
|
||||
{formatDate(task.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-600">
|
||||
{task.completed_at ? formatDate(task.completed_at) : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-600">
|
||||
{formatProcessingTime(task.processing_time_ms)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{/* Task management actions */}
|
||||
{task.status === 'pending' && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleStartTask(task.task_id)}
|
||||
title="開始處理"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCancelTask(task.task_id)}
|
||||
title="取消"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{task.status === 'processing' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCancelTask(task.task_id)}
|
||||
title="取消"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{task.status === 'failed' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRetryTask(task.task_id)}
|
||||
title="重試"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* Download actions for completed tasks */}
|
||||
{task.status === 'completed' && (
|
||||
<>
|
||||
{task.result_json_path && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(task.task_id, 'json')}
|
||||
title="下載 JSON"
|
||||
>
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
JSON
|
||||
</Button>
|
||||
)}
|
||||
{task.result_markdown_path && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(task.task_id, 'markdown')}
|
||||
title="下載 Markdown"
|
||||
>
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
MD
|
||||
</Button>
|
||||
)}
|
||||
{task.result_pdf_path && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(task.task_id, 'pdf')}
|
||||
title="下載 PDF"
|
||||
>
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
PDF
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleViewDetails(task.task_id)}
|
||||
title="查看詳情"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{/* Delete button for all statuses */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(task.task_id)}
|
||||
title="刪除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
顯示 {(page - 1) * pageSize + 1} - {Math.min(page * pageSize, total)} / 共{' '}
|
||||
{total} 個
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
上一頁
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={!hasMore}
|
||||
>
|
||||
下一頁
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
431
frontend/src/services/apiV2.ts
Normal file
431
frontend/src/services/apiV2.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* API V2 Client - External Authentication & Task Management
|
||||
*
|
||||
* Features:
|
||||
* - External Azure AD authentication
|
||||
* - Task history and management
|
||||
* - User task isolation
|
||||
* - Session management
|
||||
*/
|
||||
|
||||
import axios, { AxiosError, AxiosInstance } from 'axios'
|
||||
import type {
|
||||
LoginRequest,
|
||||
ApiError,
|
||||
} from '@/types/api'
|
||||
import type {
|
||||
LoginResponseV2,
|
||||
UserInfo,
|
||||
TaskCreate,
|
||||
TaskUpdate,
|
||||
Task,
|
||||
TaskDetail,
|
||||
TaskListResponse,
|
||||
TaskStats,
|
||||
SessionInfo,
|
||||
} from '@/types/apiV2'
|
||||
|
||||
/**
|
||||
* API Client Configuration
|
||||
* - In Docker: VITE_API_BASE_URL is empty string, use relative path
|
||||
* - In development: Use VITE_API_BASE_URL from .env or default to localhost:8000
|
||||
*/
|
||||
const envApiBaseUrl = import.meta.env.VITE_API_BASE_URL
|
||||
const API_BASE_URL = envApiBaseUrl !== undefined ? envApiBaseUrl : 'http://localhost:8000'
|
||||
const API_VERSION = 'v2'
|
||||
|
||||
class ApiClientV2 {
|
||||
private client: AxiosInstance
|
||||
private token: string | null = null
|
||||
private userInfo: UserInfo | null = null
|
||||
private tokenExpiresAt: number | null = null
|
||||
private refreshTimer: NodeJS.Timeout | null = null
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: `${API_BASE_URL}/api/${API_VERSION}`,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor to add auth token
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
if (this.token) {
|
||||
config.headers.Authorization = `Bearer ${this.token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// Response interceptor for error handling
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError<ApiError>) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Token expired or invalid
|
||||
const detail = error.response?.data?.detail
|
||||
if (detail?.includes('Session expired') || detail?.includes('Invalid session')) {
|
||||
console.warn('Session expired, attempting refresh')
|
||||
// Try to refresh token once
|
||||
try {
|
||||
await this.refreshToken()
|
||||
// Retry the original request
|
||||
if (error.config) {
|
||||
return this.client.request(error.config)
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error('Token refresh failed, redirecting to login')
|
||||
this.clearAuth()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
} else {
|
||||
this.clearAuth()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Load auth data from localStorage
|
||||
this.loadAuth()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set authentication data
|
||||
*/
|
||||
setAuth(token: string, user: UserInfo, expiresIn?: number) {
|
||||
this.token = token
|
||||
this.userInfo = user
|
||||
localStorage.setItem('auth_token_v2', token)
|
||||
localStorage.setItem('user_info_v2', JSON.stringify(user))
|
||||
|
||||
// Schedule token refresh if expiresIn is provided
|
||||
if (expiresIn) {
|
||||
this.tokenExpiresAt = Date.now() + expiresIn * 1000
|
||||
localStorage.setItem('token_expires_at', this.tokenExpiresAt.toString())
|
||||
this.scheduleTokenRefresh(expiresIn)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear authentication data
|
||||
*/
|
||||
clearAuth() {
|
||||
this.token = null
|
||||
this.userInfo = null
|
||||
this.tokenExpiresAt = null
|
||||
|
||||
// Clear refresh timer
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer)
|
||||
this.refreshTimer = null
|
||||
}
|
||||
|
||||
localStorage.removeItem('auth_token_v2')
|
||||
localStorage.removeItem('user_info_v2')
|
||||
localStorage.removeItem('token_expires_at')
|
||||
}
|
||||
|
||||
/**
|
||||
* Load auth data from localStorage
|
||||
*/
|
||||
private loadAuth() {
|
||||
const token = localStorage.getItem('auth_token_v2')
|
||||
const userInfoStr = localStorage.getItem('user_info_v2')
|
||||
const expiresAtStr = localStorage.getItem('token_expires_at')
|
||||
|
||||
if (token && userInfoStr) {
|
||||
try {
|
||||
this.token = token
|
||||
this.userInfo = JSON.parse(userInfoStr)
|
||||
|
||||
// Load and check token expiry
|
||||
if (expiresAtStr) {
|
||||
this.tokenExpiresAt = parseInt(expiresAtStr, 10)
|
||||
const timeUntilExpiry = this.tokenExpiresAt - Date.now()
|
||||
|
||||
// If token is expired, clear auth
|
||||
if (timeUntilExpiry <= 0) {
|
||||
console.warn('Token expired, clearing auth')
|
||||
this.clearAuth()
|
||||
return
|
||||
}
|
||||
|
||||
// Schedule refresh if token is expiring soon
|
||||
const refreshBuffer = 5 * 60 * 1000 // 5 minutes
|
||||
if (timeUntilExpiry < refreshBuffer) {
|
||||
console.log('Token expiring soon, refreshing immediately')
|
||||
this.refreshToken().catch(() => this.clearAuth())
|
||||
} else {
|
||||
// Schedule refresh for later
|
||||
this.scheduleTokenRefresh(Math.floor(timeUntilExpiry / 1000))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse user info from localStorage:', error)
|
||||
this.clearAuth()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
return this.token !== null && this.userInfo !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user info
|
||||
*/
|
||||
getCurrentUser(): UserInfo | null {
|
||||
return this.userInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule token refresh before expiration
|
||||
* @param expiresIn - Token expiry time in seconds
|
||||
*/
|
||||
private scheduleTokenRefresh(expiresIn: number): void {
|
||||
// Clear existing timer
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer)
|
||||
}
|
||||
|
||||
// Schedule refresh 5 minutes before expiry
|
||||
const refreshBuffer = 5 * 60 // 5 minutes in seconds
|
||||
const refreshTime = Math.max(0, expiresIn - refreshBuffer) * 1000 // Convert to milliseconds
|
||||
|
||||
console.log(`Scheduling token refresh in ${refreshTime / 1000} seconds`)
|
||||
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
console.log('Auto-refreshing token')
|
||||
this.refreshToken().catch((error) => {
|
||||
console.error('Auto token refresh failed:', error)
|
||||
// Don't redirect on auto-refresh failure, let user continue
|
||||
// Redirect will happen on next API call with 401
|
||||
})
|
||||
}, refreshTime)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token
|
||||
*/
|
||||
private async refreshToken(): Promise<void> {
|
||||
try {
|
||||
const response = await this.client.post<LoginResponseV2>('/auth/refresh')
|
||||
|
||||
// Update token and schedule next refresh
|
||||
this.setAuth(response.data.access_token, response.data.user, response.data.expires_in)
|
||||
|
||||
console.log('Token refreshed successfully')
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Authentication ====================
|
||||
|
||||
/**
|
||||
* Login via external Azure AD API
|
||||
*/
|
||||
async login(data: LoginRequest): Promise<LoginResponseV2> {
|
||||
const response = await this.client.post<LoginResponseV2>('/auth/login', {
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
})
|
||||
|
||||
// Store token and user info with auto-refresh
|
||||
this.setAuth(response.data.access_token, response.data.user, response.data.expires_in)
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout (invalidate session)
|
||||
*/
|
||||
async logout(sessionId?: number): Promise<void> {
|
||||
try {
|
||||
await this.client.post('/auth/logout', { session_id: sessionId })
|
||||
} finally {
|
||||
// Always clear local auth data
|
||||
this.clearAuth()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user info from server
|
||||
*/
|
||||
async getMe(): Promise<UserInfo> {
|
||||
const response = await this.client.get<UserInfo>('/auth/me')
|
||||
this.userInfo = response.data
|
||||
localStorage.setItem('user_info_v2', JSON.stringify(response.data))
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* List user sessions
|
||||
*/
|
||||
async listSessions(): Promise<SessionInfo[]> {
|
||||
const response = await this.client.get<{ sessions: SessionInfo[] }>('/auth/sessions')
|
||||
return response.data.sessions
|
||||
}
|
||||
|
||||
// ==================== Task Management ====================
|
||||
|
||||
/**
|
||||
* Create a new task
|
||||
*/
|
||||
async createTask(data: TaskCreate): Promise<Task> {
|
||||
const response = await this.client.post<Task>('/tasks/', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* List tasks with pagination and filtering
|
||||
*/
|
||||
async listTasks(params: {
|
||||
status?: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
filename?: string
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
order_by?: string
|
||||
order_desc?: boolean
|
||||
} = {}): Promise<TaskListResponse> {
|
||||
const response = await this.client.get<TaskListResponse>('/tasks/', { params })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task statistics
|
||||
*/
|
||||
async getTaskStats(): Promise<TaskStats> {
|
||||
const response = await this.client.get<TaskStats>('/tasks/stats')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task details by ID
|
||||
*/
|
||||
async getTask(taskId: string): Promise<TaskDetail> {
|
||||
const response = await this.client.get<TaskDetail>(`/tasks/${taskId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update task
|
||||
*/
|
||||
async updateTask(taskId: string, data: TaskUpdate): Promise<Task> {
|
||||
const response = await this.client.patch<Task>(`/tasks/${taskId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete task
|
||||
*/
|
||||
async deleteTask(taskId: string): Promise<void> {
|
||||
await this.client.delete(`/tasks/${taskId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start task processing
|
||||
*/
|
||||
async startTask(taskId: string): Promise<Task> {
|
||||
const response = await this.client.post<Task>(`/tasks/${taskId}/start`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel task
|
||||
*/
|
||||
async cancelTask(taskId: string): Promise<Task> {
|
||||
const response = await this.client.post<Task>(`/tasks/${taskId}/cancel`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry failed task
|
||||
*/
|
||||
async retryTask(taskId: string): Promise<Task> {
|
||||
const response = await this.client.post<Task>(`/tasks/${taskId}/retry`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ==================== Helper Methods ====================
|
||||
|
||||
/**
|
||||
* Download file from task result
|
||||
*/
|
||||
async downloadTaskFile(url: string, filename: string): Promise<void> {
|
||||
const response = await this.client.get(url, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
|
||||
// Create download link
|
||||
const blob = new Blob([response.data])
|
||||
const link = document.createElement('a')
|
||||
link.href = window.URL.createObjectURL(blob)
|
||||
link.download = filename
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
/**
|
||||
* Download task result as JSON
|
||||
*/
|
||||
async downloadJSON(taskId: string): Promise<void> {
|
||||
const response = await this.client.get(`/tasks/${taskId}/download/json`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
|
||||
const blob = new Blob([response.data], { type: 'application/json' })
|
||||
const link = document.createElement('a')
|
||||
link.href = window.URL.createObjectURL(blob)
|
||||
link.download = `${taskId}_result.json`
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
/**
|
||||
* Download task result as Markdown
|
||||
*/
|
||||
async downloadMarkdown(taskId: string): Promise<void> {
|
||||
const response = await this.client.get(`/tasks/${taskId}/download/markdown`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
|
||||
const blob = new Blob([response.data], { type: 'text/markdown' })
|
||||
const link = document.createElement('a')
|
||||
link.href = window.URL.createObjectURL(blob)
|
||||
link.download = `${taskId}_result.md`
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
/**
|
||||
* Download task result as PDF
|
||||
*/
|
||||
async downloadPDF(taskId: string): Promise<void> {
|
||||
const response = await this.client.get(`/tasks/${taskId}/download/pdf`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
|
||||
const blob = new Blob([response.data], { type: 'application/pdf' })
|
||||
const link = document.createElement('a')
|
||||
link.href = window.URL.createObjectURL(blob)
|
||||
link.download = `${taskId}_result.pdf`
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(link.href)
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const apiClientV2 = new ApiClientV2()
|
||||
@@ -18,6 +18,8 @@ export interface LoginResponse {
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
email?: string
|
||||
displayName?: string | null
|
||||
}
|
||||
|
||||
// File Upload
|
||||
|
||||
117
frontend/src/types/apiV2.ts
Normal file
117
frontend/src/types/apiV2.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* API V2 Type Definitions
|
||||
* External Authentication & Task Management
|
||||
*/
|
||||
|
||||
// ==================== Authentication ====================
|
||||
|
||||
export interface UserInfo {
|
||||
id: number
|
||||
email: string
|
||||
display_name: string | null
|
||||
}
|
||||
|
||||
export interface LoginResponseV2 {
|
||||
access_token: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
user: UserInfo
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id: number
|
||||
email: string
|
||||
display_name: string | null
|
||||
created_at: string
|
||||
last_login: string | null
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
id: number
|
||||
token_type: string
|
||||
expires_at: string
|
||||
issued_at: string
|
||||
ip_address: string | null
|
||||
user_agent: string | null
|
||||
created_at: string
|
||||
last_accessed_at: string
|
||||
is_expired: boolean
|
||||
time_until_expiry: number
|
||||
}
|
||||
|
||||
// ==================== Task Management ====================
|
||||
|
||||
export type TaskStatus = 'pending' | 'processing' | 'completed' | 'failed'
|
||||
|
||||
export interface TaskCreate {
|
||||
filename?: string
|
||||
file_type?: string
|
||||
}
|
||||
|
||||
export interface TaskUpdate {
|
||||
status?: TaskStatus
|
||||
error_message?: string
|
||||
processing_time_ms?: number
|
||||
result_json_path?: string
|
||||
result_markdown_path?: string
|
||||
result_pdf_path?: string
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: number
|
||||
user_id: number
|
||||
task_id: string
|
||||
filename: string | null
|
||||
file_type: string | null
|
||||
status: TaskStatus
|
||||
result_json_path: string | null
|
||||
result_markdown_path: string | null
|
||||
result_pdf_path: string | null
|
||||
error_message: string | null
|
||||
processing_time_ms: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
completed_at: string | null
|
||||
file_deleted: boolean
|
||||
}
|
||||
|
||||
export interface TaskFile {
|
||||
id: number
|
||||
original_name: string | null
|
||||
stored_path: string | null
|
||||
file_size: number | null
|
||||
mime_type: string | null
|
||||
file_hash: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface TaskDetail extends Task {
|
||||
files: TaskFile[]
|
||||
}
|
||||
|
||||
export interface TaskListResponse {
|
||||
tasks: Task[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
export interface TaskStats {
|
||||
total: number
|
||||
pending: number
|
||||
processing: number
|
||||
completed: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
// ==================== Task Filters ====================
|
||||
|
||||
export interface TaskFilters {
|
||||
status?: TaskStatus
|
||||
page: number
|
||||
page_size: number
|
||||
order_by: string
|
||||
order_desc: boolean
|
||||
}
|
||||
Reference in New Issue
Block a user