初次提交

This commit is contained in:
2025-10-14 20:05:29 +08:00
commit 6e4e48fdd2
673 changed files with 437006 additions and 0 deletions

1
frontend/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8080/api/v1

19
frontend/Dockerfile.dev Normal file
View File

@@ -0,0 +1,19 @@
FROM node:20-alpine
# 设置工作目录
WORKDIR /app
# 复制package文件
COPY package*.json ./
# 安装依赖
RUN npm ci
# 复制应用代码
COPY . .
# 暴露端口
EXPOSE 3000
# 开发模式启动命令
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>云盘应用</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

7674
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

58
frontend/package.json Normal file
View File

@@ -0,0 +1,58 @@
{
"name": "cloud-drive-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"test": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"@hookform/resolvers": "^3.3.2",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.1.5",
"@tanstack/react-query": "^5.8.4",
"axios": "^1.6.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"lucide-react": "^0.294.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.48.2",
"react-router-dom": "^6.20.1",
"sonner": "^1.2.4",
"tailwind-merge": "^2.0.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.5.1",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^4.1.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.53.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"playwright": "^1.40.1",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2",
"vite": "^5.0.0",
"vitest": "^1.0.0"
}
}

43
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,43 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Toaster } from 'sonner'
// Pages
import HomePage from './pages/HomePage'
import LoginPage from './pages/LoginPage'
import RegisterPage from './pages/RegisterPage'
import CloudPage from './pages/CloudPage'
// Components
import Layout from './components/Layout'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
retry: 1,
},
},
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router>
<div className="min-h-screen bg-background font-sans antialiased">
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/cloud" element={<CloudPage />} />
</Route>
</Routes>
<Toaster />
</div>
</Router>
</QueryClientProvider>
)
}
export default App

View File

@@ -0,0 +1,45 @@
import { useNavigate, useLocation } from 'react-router-dom'
import { User, LogOut, ArrowLeft } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { getUserInfoFromCookie, clearAuthCookies } from '@/utils/cookie'
import { Cloud} from 'lucide-react'
export default function Header() {
const navigate = useNavigate()
const location = useLocation()
const userInfo = getUserInfoFromCookie()
// 只在 /cloud 路由下显示用户信息和登出按钮
const isCloudPage = location.pathname === '/cloud'
const handleLogout = () => {
clearAuthCookies()
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
navigate('/login')
}
const handleBack = () => {
navigate(-1)
}
return (
<header className="h-14 bg-white border-b border-gray-200 flex items-center justify-between px-4">
<div className="flex items-center space-x-4">
<Cloud className="h-12 w-12 text-primary mx-auto mb-4" />
<span className="font-semibold text-gray-900">Cloudaloud网盘</span>
</div>
{userInfo && isCloudPage && (
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<User className="h-4 w-4 text-gray-600" />
<span className="text-sm text-gray-700">{userInfo.username}</span>
<Button variant="ghost" size="sm" onClick={handleLogout}>
<span className="h-4 w-4"></span>
</Button>
</div>
</div>
)}
</header>
)
}

View File

@@ -0,0 +1,13 @@
import { Outlet } from 'react-router-dom'
import Header from './Header'
export default function Layout() {
return (
<div className="h-screen flex flex-col">
<Header />
<main className="flex-1 overflow-hidden">
<Outlet />
</main>
</div>
)
}

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/utils/cn"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/utils/cn"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm 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-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

59
frontend/src/index.css Normal file
View File

@@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 84% 4.9%;
--muted: 210 40% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96%;
--accent-foreground: 222.2 84% 4.9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 84% 4.9%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 94.1%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,399 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { authAPI, filesAPI } from '@/services/api'
import { toast } from 'sonner'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Button } from '@/components/ui/button'
import { Cloud, Upload, File, Folder, Download, Trash2, User } from 'lucide-react'
import { getUserIdFromCookie, getUserInfoFromCookie } from '@/utils/cookie'
// Toast 显示时长常量(毫秒)
const TOAST_DURATION = 3000 // 3秒可根据需要修改
// 文件信息类型
interface FileInfo {
id: number
filename: string
original_filename: string
file_path: string
file_size: number
mime_type: string
created_at: string
updated_at: string
}
export default function CloudPage() {
const navigate = useNavigate()
const fileInputRef = useRef<HTMLInputElement>(null)
const [files, setFiles] = useState<FileInfo[]>([])
const [loading, setLoading] = useState(true)
const [uploading, setUploading] = useState(false)
const [refreshingFileId, setRefreshingFileId] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null)
const userInfo = getUserInfoFromCookie()
// 获取当前用户ID
const getUserId = () => {
const userId = getUserIdFromCookie()
if (!userId) {
navigate('/login')
return null
}
return userId
}
// 更新单个文件信息
const updateSingleFile = async (fileId: number) => {
const userId = getUserId()
if (!userId) return
setRefreshingFileId(fileId)
try {
const response = await filesAPI.getFileInfo(fileId, userId)
if (response.success && response.data) {
// 更新文件列表中对应的文件信息
setFiles(prevFiles =>
prevFiles.map(file =>
file.id === fileId ? response.data : file
)
)
}
} catch (error: any) {
console.error('Update file info error:', error)
} finally {
setRefreshingFileId(null)
}
}
// 获取用户文件列表
const fetchFiles = async () => {
const userId = getUserId()
if (!userId) return
setLoading(true)
setError(null)
try {
const response = await filesAPI.getFiles(1, 100) // 获取前100个文件
if (response.success && response.data?.files) {
setFiles(response.data.files)
} else {
setError('获取文件列表失败')
}
} catch (error: any) {
console.error('Fetch files error:', error)
if (error.response?.status === 401) {
toast.error('登录已过期,请重新登录')
navigate('/login')
} else {
setError('获取文件列表失败')
}
} finally {
setLoading(false)
}
}
// 文件上传处理
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
setUploading(true)
setError(null)
try {
const formData = new FormData()
formData.append('file', file)
// 其他可选字段可以在这里添加
const response = await filesAPI.uploadFile(formData)
if (response.success) {
toast.success(`文件 "${file.name}" 上传成功!`)
// 上传成功后刷新文件列表
setLoading(true)
fetchFiles()
} else {
toast.error('文件上传失败')
}
} catch (error: any) {
console.error('Upload error:', error)
const errorMessage = error.response?.data?.detail?.message || '文件上传失败'
toast.error(errorMessage)
} finally {
setUploading(false)
// 清空文件输入框
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
}
// 触发文件选择
const handleUploadClick = () => {
fileInputRef.current?.click()
}
// 文件下载处理
const handleFileDownload = async (file: FileInfo) => {
const userId = getUserId()
if (!userId) {
toast.error('用户未登录')
return
}
try {
toast.loading('正在下载文件...', { duration: TOAST_DURATION })
// 调用下载API
const response = await filesAPI.downloadFile(file.id, userId)
// 创建下载链接
const blob = new Blob([response])
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = file.original_filename || file.filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
toast.success(`文件 "${file.original_filename}" 下载成功!`, { duration: TOAST_DURATION })
// 只更新单个文件信息,避免整个列表刷新
updateSingleFile(file.id)
} catch (error: any) {
console.error('Download error:', error)
let errorMessage = '下载失败'
if (error.response?.status === 401) {
errorMessage = '登录已过期,请重新登录'
navigate('/login')
} else if (error.response?.status === 403) {
errorMessage = '没有权限下载此文件'
} else if (error.response?.status === 404) {
errorMessage = '文件不存在'
} else if (error.response?.data?.detail?.message) {
errorMessage = error.response.data.detail.message
}
toast.error(errorMessage)
}
}
// 文件删除处理
const handleFileDelete = async (file: FileInfo) => {
const userId = getUserId()
if (!userId) {
toast.error('用户未登录')
return
}
try {
toast.loading('正在删除文件...', { duration: TOAST_DURATION })
// 调用删除API
const response = await filesAPI.deleteFile(file.id, userId)
if (response.success) {
toast.success(`文件 "${file.original_filename}" 删除成功!`, { duration: TOAST_DURATION })
// 删除操作后需要刷新整个列表,因为文件被移除了
fetchFiles()
} else {
toast.error('文件删除失败', { duration: TOAST_DURATION })
}
} catch (error: any) {
console.error('Delete error:', error)
let errorMessage = '删除失败'
if (error.response?.status === 401) {
errorMessage = '登录已过期,请重新登录'
navigate('/login')
} else if (error.response?.status === 403) {
errorMessage = '没有权限删除此文件'
} else if (error.response?.status === 404) {
errorMessage = '文件不存在'
} else if (error.response?.data?.detail?.message) {
errorMessage = error.response.data.detail.message
}
toast.error(errorMessage)
}
}
// 组件加载时检查用户状态并获取文件列表
useEffect(() => {
// 检查用户是否已登录
if (!userInfo) {
navigate('/login')
return
}
fetchFiles()
}, [])
// 格式化文件大小
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 格式化日期
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('zh-CN')
}
// 获取文件图标
const getFileIcon = (mimeType: string) => {
if (mimeType.startsWith('image/')) {
return <File className="h-4 w-4 text-green-600" />
} else if (mimeType.startsWith('video/')) {
return <File className="h-4 w-4 text-blue-600" />
} else if (mimeType.startsWith('audio/')) {
return <File className="h-4 w-4 text-purple-600" />
} else if (mimeType.includes('pdf')) {
return <File className="h-4 w-4 text-red-600" />
} else if (mimeType.includes('zip') || mimeType.includes('rar')) {
return <File className="h-4 w-4 text-yellow-600" />
} else {
return <File className="h-4 w-4 text-gray-600" />
}
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
</h1>
<p className="text-gray-600"></p>
</div>
</div>
{/* 上传按钮 */}
<div className="mb-6">
<input
ref={fileInputRef}
type="file"
onChange={handleFileUpload}
className="hidden"
disabled={uploading}
/>
</div>
{/* 文件列表 */}
<div className="bg-white rounded-lg border border-gray-200">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<Cloud className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600">...</p>
</div>
</div>
) : error ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<p className="text-red-600 mb-4">{error}</p>
<Button onClick={fetchFiles} variant="outline">
</Button>
</div>
</div>
) : files.length === 0 ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<Cloud className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 mb-4"></p>
<Button
onClick={handleUploadClick}
disabled={uploading}
className="flex items-center space-x-2 mx-auto"
>
<Upload className="h-4 w-4" />
<span>{uploading ? '上传中...' : '上传文件'}</span>
</Button>
</div>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{files.map((file) => (
<TableRow key={file.id}>
<TableCell className="font-medium">
<div className="flex items-center space-x-2">
{getFileIcon(file.mime_type)}
<span>{file.filename}</span>
</div>
</TableCell>
<TableCell>
<span className="text-blue-600 font-medium">{file.original_filename}</span>
</TableCell>
<TableCell>{formatFileSize(file.file_size)}</TableCell>
<TableCell>
<span className="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800">
{file.mime_type.split('/')[1]?.toUpperCase() || 'FILE'}
</span>
</TableCell>
<TableCell className="text-gray-600">
{formatDate(file.updated_at)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleFileDownload(file)}
title="下载文件"
disabled={refreshingFileId === file.id}
>
{refreshingFileId === file.id ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleFileDelete(file)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
title="删除文件"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { Link } from 'react-router-dom'
import { Cloud, Upload, Shield, FolderOpen } from 'lucide-react'
import { Button } from '@/components/ui/button'
export default function HomePage() {
return (
<div className="text-center py-12">
<div className="mb-8">
<Cloud className="h-16 w-16 mx-auto text-primary mb-4" />
<h1 className="text-4xl font-bold tracking-tight mb-4">
使
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
访
</p>
</div>
<div className="grid md:grid-cols-3 gap-6 max-w-4xl mx-auto mb-12">
<div className="p-6 border rounded-lg">
<Upload className="h-12 w-12 text-primary mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-muted-foreground">
</p>
</div>
<div className="p-6 border rounded-lg">
<Shield className="h-12 w-12 text-primary mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2"></h3>
<p className="text-muted-foreground">
</p>
</div>
<div className="p-6 border rounded-lg">
<FolderOpen className="h-12 w-12 text-primary mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">便</h3>
<p className="text-muted-foreground">
</p>
</div>
</div>
<div className="space-x-4">
<Button asChild size="lg">
<Link to="/cloud"></Link>
</Button>
<Button variant="outline" size="lg" asChild>
<Link to="/register"></Link>
</Button>
<Button variant="outline" size="lg" asChild>
<Link to="/login"></Link>
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,186 @@
import { Link, useNavigate } from 'react-router-dom'
import { useState } from 'react'
import { Cloud, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { authAPI } from '@/services/api'
import { toast } from 'sonner'
import { setUserInfoInCookie, setTokenInCookie, deleteCookie } from '@/utils/cookie'
import '@/styles/auth.css'
export default function LoginPage() {
const navigate = useNavigate()
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [formData, setFormData] = useState({
username: '',
password: ''
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.username || !formData.password) {
toast.error('请填写用户名和密码')
return
}
setIsLoading(true)
try {
const response = await authAPI.login({
username: formData.username,
password: formData.password
})
if (response.success) {
// 保存用户信息到cookie
if (response.data?.user) {
setUserInfoInCookie(response.data.user)
}
// 保存JWT token到cookie
if (response.data?.tokens?.access_token) {
setTokenInCookie(response.data.tokens.access_token)
// 同时也保存到localStorage作为备份
localStorage.setItem('access_token', response.data.tokens.access_token)
}
if (response.data?.tokens?.refresh_token) {
localStorage.setItem('refresh_token', response.data.tokens.refresh_token)
}
toast.success('登录成功!正在跳转...')
// 延迟跳转,让用户看到成功提示
setTimeout(() => {
navigate('/cloud')
}, 1000)
} else {
toast.error(response.detail?.message || '登录失败')
}
} catch (error: any) {
console.error('Login error:', error)
// 尝试从错误响应中提取具体错误信息
let errorMessage = '登录失败,请稍后重试'
if (error.response?.data) {
const errorData = error.response.data
if (errorData.detail?.message) {
errorMessage = errorData.detail.message
} else if (errorData.detail?.code) {
// 根据错误码显示更友好的消息
switch (errorData.detail.code) {
case 'INVALID_CREDENTIALS':
errorMessage = '用户名或密码错误'
break
case 'USER_NOT_FOUND':
errorMessage = '用户不存在'
break
default:
errorMessage = errorData.detail.message || '登录失败'
}
}
}
toast.error(errorMessage)
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-cyan-50 relative overflow-hidden">
{/* 背景装饰 */}
<div className="absolute inset-0">
<div className="absolute top-0 -left-4 w-72 h-72 bg-blue-300 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-blob"></div>
<div className="absolute top-0 -right-4 w-72 h-72 bg-cyan-300 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-blob animation-delay-2000"></div>
<div className="absolute -bottom-8 left-20 w-72 h-72 bg-blue-200 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-blob animation-delay-4000"></div>
<div className="absolute bottom-0 right-20 w-72 h-72 bg-sky-300 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-blob animation-delay-6000"></div>
</div>
<div className="relative min-h-screen flex items-center justify-center px-4">
<div className="max-w-md w-full">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-blue-500 to-cyan-600 rounded-2xl mb-4 shadow-lg">
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2"></h1>
<p className="text-gray-600">
</p>
</div>
<div className="bg-white/80 backdrop-blur-xl rounded-2xl shadow-xl border border-white/20 p-8">
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="relative">
<input
type="text"
className="w-full px-4 py-3 bg-white/50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200 backdrop-blur-sm"
placeholder="请输入用户名或邮箱"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
className="w-full px-4 py-3 pr-12 bg-white/50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200 backdrop-blur-sm"
placeholder="请输入密码"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required
/>
<button
type="button"
className="absolute right-3 top-3.5 text-gray-400 hover:text-gray-600 transition-colors duration-200"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
</div>
<Button
type="submit"
className="w-full bg-gradient-to-r from-blue-500 to-cyan-600 hover:from-blue-600 hover:to-cyan-700 text-white font-medium py-3 rounded-lg transition-all duration-200 transform hover:scale-[1.02] shadow-lg"
disabled={isLoading}
>
{isLoading ? (
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
...
</div>
) : (
'登录'
)}
</Button>
</form>
<div className="mt-8 text-center">
<p className="text-sm text-gray-600">
{' '}
<Link
to="/register"
className="text-blue-600 hover:text-blue-700 font-medium hover:underline transition-colors duration-200"
>
</Link>
</p>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,243 @@
import { Link, useNavigate } from 'react-router-dom'
import { useState } from 'react'
import { Cloud, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { authAPI } from '@/services/api'
import { toast } from 'sonner'
import '@/styles/auth.css'
// API响应类型
interface ApiResponse {
success: boolean
message: string
data?: any
detail?: {
code?: string
message?: string
}
}
export default function RegisterPage() {
const navigate = useNavigate()
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
confirmPassword: ''
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// 基本验证 - 只有两个规则
if (formData.password !== formData.confirmPassword) {
toast.error('两次输入的密码不一致')
return
}
if (formData.password.length <= 5) {
toast.error('密码长度必须大于5个字符')
return
}
setIsLoading(true)
try {
const response = await authAPI.register({
username: formData.username,
email: formData.email,
password: formData.password,
confirm_password: formData.confirmPassword
}) as unknown as ApiResponse
if (response.success) {
// 保存tokens到localStorage
if (response.data?.tokens?.access_token) {
localStorage.setItem('access_token', response.data.tokens.access_token)
}
if (response.data?.tokens?.refresh_token) {
localStorage.setItem('refresh_token', response.data.tokens.refresh_token)
}
toast.success('注册成功!正在跳转...')
// 延迟跳转,让用户看到成功提示
setTimeout(() => {
navigate('/cloud')
}, 1500)
} else {
// 处理API返回的错误
const errorMessage = response.detail?.message || '注册失败'
toast.error(errorMessage)
}
} catch (error: any) {
console.error('Registration error:', error)
// 尝试从错误响应中提取具体错误信息
let errorMessage = '注册失败,请稍后重试'
if (error.response?.data) {
const errorData = error.response.data
if (errorData.detail?.message) {
errorMessage = errorData.detail.message
} else if (errorData.detail?.code) {
// 根据错误码显示更友好的消息
switch (errorData.detail.code) {
case 'USERNAME_EXISTS':
errorMessage = '用户名已存在'
break
case 'CREATION_FAILED':
errorMessage = '用户创建失败,请检查输入信息'
break
default:
errorMessage = errorData.detail.message || '注册失败'
}
} else if (Array.isArray(errorData.detail)) {
// 处理字段验证错误
errorMessage = errorData.detail.map((err: any) => err.msg).join(', ')
}
}
toast.error(errorMessage)
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-cyan-50 via-white to-blue-50 relative overflow-hidden">
{/* 背景装饰 */}
<div className="absolute inset-0">
<div className="absolute top-0 -left-4 w-72 h-72 bg-cyan-300 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-blob"></div>
<div className="absolute top-0 -right-4 w-72 h-72 bg-blue-300 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-blob animation-delay-2000"></div>
<div className="absolute -bottom-8 left-20 w-72 h-72 bg-sky-200 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-blob animation-delay-4000"></div>
<div className="absolute bottom-0 right-20 w-72 h-72 bg-cyan-200 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-blob animation-delay-6000"></div>
</div>
<div className="relative min-h-screen flex items-center justify-center px-4">
<div className="max-w-md w-full">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-cyan-500 to-blue-600 rounded-2xl mb-4 shadow-lg">
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2"></h1>
<p className="text-gray-600">
使
</p>
</div>
<div className="bg-white/80 backdrop-blur-xl rounded-2xl shadow-xl border border-white/20 p-8">
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="relative">
<input
type="text"
className="w-full px-4 py-3 bg-white/50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition-all duration-200 backdrop-blur-sm"
placeholder="请输入用户名"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="relative">
<input
type="email"
className="w-full px-4 py-3 bg-white/50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition-all duration-200 backdrop-blur-sm"
placeholder="请输入邮箱地址"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
className="w-full px-4 py-3 pr-12 bg-white/50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition-all duration-200 backdrop-blur-sm"
placeholder="请输入密码至少6个字符"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required
/>
<button
type="button"
className="absolute right-3 top-3 text-gray-400 hover:text-gray-600 transition-colors duration-200 p-1"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<div className="relative">
<input
type={showConfirmPassword ? 'text' : 'password'}
className="w-full px-4 py-3 pr-12 bg-white/50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition-all duration-200 backdrop-blur-sm"
placeholder="请再次输入密码"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
required
/>
<button
type="button"
className="absolute right-3 top-3 text-gray-400 hover:text-gray-600 transition-colors duration-200 p-1"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<Button
type="submit"
className="w-full bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-600 hover:to-blue-700 text-white font-medium py-3 rounded-lg transition-all duration-200 transform hover:scale-[1.02] shadow-lg"
disabled={isLoading}
>
{isLoading ? (
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
...
</div>
) : (
'注册'
)}
</Button>
</form>
<div className="mt-8 text-center">
<p className="text-sm text-gray-600">
{' '}
<Link
to="/login"
className="text-cyan-600 hover:text-cyan-700 font-medium hover:underline transition-colors duration-200"
>
</Link>
</p>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,150 @@
import axios from 'axios'
import { getTokenFromCookie, getUserIdFromCookie } from '@/utils/cookie'
const API_BASE_URL = 'http://101.126.85.76:8002/api/v1'
console.log('API_BASE_URL:',API_BASE_URL)
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器
api.interceptors.request.use(
(config) => {
// 从cookie中获取token和userId
const token = getTokenFromCookie()
const userId = getUserIdFromCookie()
// 如果token不存在尝试从localStorage获取兼容性
const fallbackToken = localStorage.getItem('access_token')
const finalToken = token || fallbackToken
if (finalToken) {
config.headers.Authorization = `Bearer ${finalToken}`
config.headers.token = finalToken
}
if (userId) {
config.headers.userId = userId.toString()
}
return config
},
(error) => Promise.reject(error)
)
// 响应拦截器
api.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
// 清除localStorage中的token
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
// 导入并清除cookie
import('@/utils/cookie').then(({ clearAuthCookies }) => {
clearAuthCookies()
})
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export const healthAPI = {
checkHealth: () => api.get('/health'),
checkReadiness: () => api.get('/ready'),
}
// 文件相关API
// 认证相关API
export const authAPI = {
// 用户注册
register: (userData: {
username: string
email: string
password: string
confirm_password: string
}) => {
return api.post('/auth/register', userData)
},
// 用户登录
login: (credentials: {
username: string
password: string
}) => {
return api.post('/auth/login', credentials)
},
// 刷新令牌
refreshToken: (refreshToken: string) => {
return api.post('/auth/refresh', { refresh_token: refreshToken })
},
// 获取当前用户信息
getCurrentUser: () => {
return api.get('/auth/me')
},
// 用户登出
logout: () => {
return api.post('/auth/logout')
},
}
// 文件相关API
export const filesAPI = {
// 上传文件
uploadFile: (formData: FormData) => {
return api.post('/files/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
},
// 获取文件列表 - 现在使用Query参数而不是POST body
getFiles: (page: number = 1, size: number = 10) => {
return api.get('/files/list', {
params: { page, size }
})
},
// 获取文件信息
getFileInfo: (fileId: number, userId: number) => {
return api.post('/files/info', { file_id: fileId, user_id: userId })
},
// 更新文件信息
updateFile: (fileId: number, userId: number, updateData: any) => {
return api.post('/files/update', {
file_id_request: { file_id: fileId, user_id: userId },
update_request: updateData
})
},
// 删除文件
deleteFile: (fileId: number, userId: number) => {
return api.post('/files/delete', { file_id: fileId, user_id: userId })
},
// 下载文件
downloadFile: (fileId: number, userId: number) => {
return api.post('/files/download', { file_id: fileId, user_id: userId }, {
responseType: 'blob',
})
},
// 获取存储信息
getStorageInfo: (userId: number) => {
return api.post('/files/storage/info', { user_id: userId })
},
}
export default api

View File

@@ -0,0 +1,30 @@
@keyframes blob {
0% {
transform: translate(0px, 0px) scale(1);
}
33% {
transform: translate(30px, -50px) scale(1.1);
}
66% {
transform: translate(-20px, 20px) scale(0.9);
}
100% {
transform: translate(0px, 0px) scale(1);
}
}
.animate-blob {
animation: blob 7s infinite;
}
.animation-delay-2000 {
animation-delay: 2s;
}
.animation-delay-4000 {
animation-delay: 4s;
}
.animation-delay-6000 {
animation-delay: 6s;
}

6
frontend/src/utils/cn.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,94 @@
/**
* Cookie 工具类
* 用于管理用户信息的存储和读取
*/
// 用户信息类型
export interface UserInfo {
id: number
username: string
email: string
avatar_url?: string
}
// 设置cookie
export const setCookie = (name: string, value: string, days: number = 7): void => {
const expires = new Date()
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000)
const cookieOptions = [
`${name}=${value}`,
`expires=${expires.toUTCString()}`,
'path=/',
'SameSite=Lax'
]
// 在生产环境下添加 Secure 标志
if (window.location.protocol === 'https:') {
cookieOptions.push('Secure')
}
document.cookie = cookieOptions.join('; ')
}
// 获取cookie值
export const getCookie = (name: string): string | null => {
const nameEQ = name + '='
const cookies = document.cookie.split(';')
for (let cookie of cookies) {
cookie = cookie.trim()
if (cookie.indexOf(nameEQ) === 0) {
return cookie.substring(nameEQ.length)
}
}
return null
}
// 删除cookie
export const deleteCookie = (name: string): void => {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`
}
// 存储用户信息到cookie
export const setUserInfoInCookie = (userInfo: UserInfo): void => {
const userInfoString = JSON.stringify(userInfo)
setCookie('userInfo', userInfoString, 7) // 7天过期
}
// 从cookie获取用户信息
export const getUserInfoFromCookie = (): UserInfo | null => {
const userInfoString = getCookie('userInfo')
if (!userInfoString) return null
try {
return JSON.parse(userInfoString)
} catch (error) {
console.error('解析用户信息失败:', error)
deleteCookie('userInfo') // 清除无效的cookie
return null
}
}
// 存储JWT token到cookie
export const setTokenInCookie = (token: string): void => {
setCookie('token', token, 7) // 7天过期
}
// 从cookie获取JWT token
export const getTokenFromCookie = (): string | null => {
return getCookie('token')
}
// 获取用户ID
export const getUserIdFromCookie = (): number | null => {
const userInfo = getUserInfoFromCookie()
return userInfo?.id || null
}
// 清除所有认证相关的cookie
export const clearAuthCookies = (): void => {
deleteCookie('userInfo')
deleteCookie('token')
}

10
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
// more env variables...
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -0,0 +1,76 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
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)",
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}

26
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": false,
"noImplicitAny": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

22
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': {
target: 'http://backend:8000',
changeOrigin: true,
},
},
},
})