This commit is contained in:
beabigegg
2025-11-12 22:53:17 +08:00
commit da700721fa
130 changed files with 23393 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
import { useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Card } from '@/components/ui/card'
interface FileUploadProps {
onFilesSelected: (files: File[]) => void
accept?: Record<string, string[]>
maxSize?: number
maxFiles?: number
disabled?: boolean
}
export default function FileUpload({
onFilesSelected,
accept = {
'image/*': ['.png', '.jpg', '.jpeg'],
'application/pdf': ['.pdf'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
'application/msword': ['.doc'],
'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
'application/vnd.ms-powerpoint': ['.ppt'],
},
maxSize = 50 * 1024 * 1024, // 50MB
maxFiles = 100,
disabled = false,
}: FileUploadProps) {
const { t } = useTranslation()
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
onFilesSelected(acceptedFiles)
}
},
[onFilesSelected]
)
const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({
onDrop,
accept,
maxSize,
maxFiles,
disabled,
})
return (
<div>
<Card
{...getRootProps()}
className={cn(
'border-2 border-dashed transition-colors cursor-pointer hover:border-primary/50',
{
'border-primary bg-primary/5': isDragActive && !isDragReject,
'border-destructive bg-destructive/5': isDragReject,
'opacity-50 cursor-not-allowed': disabled,
}
)}
>
<div className="p-12 text-center">
<input {...getInputProps()} />
<div className="mb-4">
<svg
className="mx-auto h-12 w-12 text-muted-foreground"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
aria-hidden="true"
>
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<div className="space-y-2">
{isDragActive ? (
<p className="text-lg font-medium text-primary">
{isDragReject ? t('upload.invalidFiles') : t('upload.dropFilesHere')}
</p>
) : (
<>
<p className="text-lg font-medium text-foreground">
{t('upload.dragAndDrop')}
</p>
<p className="text-sm text-muted-foreground">{t('upload.supportedFormats')}</p>
<p className="text-sm text-muted-foreground">{t('upload.maxFileSize')}</p>
</>
)}
</div>
</div>
</Card>
{fileRejections.length > 0 && (
<div className="mt-4 p-4 bg-destructive/10 border border-destructive rounded-md">
<p className="text-sm font-medium text-destructive mb-2">
{t('errors.uploadFailed')}
</p>
<ul className="text-sm text-destructive space-y-1">
{fileRejections.map(({ file, errors }) => (
<li key={file.name}>
{file.name}:{' '}
{errors.map((e) => {
if (e.code === 'file-too-large') return t('errors.fileTooBig')
if (e.code === 'file-invalid-type') return t('errors.unsupportedFormat')
return e.message
})}
</li>
))}
</ul>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,71 @@
import { Outlet, NavLink } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuthStore } from '@/store/authStore'
import { apiClient } from '@/services/api'
export default function Layout() {
const { t } = useTranslation()
const logout = useAuthStore((state) => state.logout)
const handleLogout = () => {
apiClient.logout()
logout()
}
const navLinks = [
{ to: '/upload', label: t('nav.upload') },
{ to: '/processing', label: t('nav.processing') },
{ to: '/results', label: t('nav.results') },
{ to: '/export', label: t('nav.export') },
{ to: '/settings', label: t('nav.settings') },
]
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b bg-card">
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">{t('app.title')}</h1>
<p className="text-sm text-muted-foreground">{t('app.subtitle')}</p>
</div>
<button
onClick={handleLogout}
className="px-4 py-2 text-sm font-medium text-foreground hover:text-primary transition-colors"
>
{t('nav.logout')}
</button>
</div>
</header>
{/* Navigation */}
<nav className="border-b bg-card">
<div className="container mx-auto px-4">
<ul className="flex space-x-1">
{navLinks.map((link) => (
<li key={link.to}>
<NavLink
to={link.to}
className={({ isActive }) =>
`block px-4 py-3 text-sm font-medium transition-colors ${
isActive
? 'text-primary border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'
}`
}
>
{link.label}
</NavLink>
</li>
))}
</ul>
</div>
</nav>
{/* Main Content */}
<main className="container mx-auto px-4 py-8">
<Outlet />
</main>
</div>
)
}

View File

@@ -0,0 +1,26 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface MarkdownPreviewProps {
title?: string
content: string
className?: string
}
export default function MarkdownPreview({ title, content, className }: MarkdownPreviewProps) {
return (
<Card className={className}>
{title && (
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
)}
<CardContent>
<div className="prose prose-sm max-w-none dark:prose-invert">
<pre className="whitespace-pre-wrap break-words bg-muted p-4 rounded-md overflow-auto max-h-[600px]">
{content}
</pre>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,90 @@
import { useTranslation } from 'react-i18next'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { FileResult } from '@/types/api'
interface ResultsTableProps {
files: FileResult[]
onViewResult?: (fileId: number) => void
onDownloadPDF?: (fileId: number) => void
}
export default function ResultsTable({ files, onViewResult, onDownloadPDF }: ResultsTableProps) {
const { t } = useTranslation()
const getStatusBadge = (status: FileResult['status']) => {
switch (status) {
case 'completed':
return <Badge variant="success">{t('processing.completed')}</Badge>
case 'processing':
return <Badge variant="default">{t('processing.processing')}</Badge>
case 'failed':
return <Badge variant="destructive">{t('processing.failed')}</Badge>
default:
return <Badge variant="secondary">{t('processing.pending')}</Badge>
}
}
const formatTime = (seconds?: number) => {
if (!seconds) return 'N/A'
return `${seconds.toFixed(2)}s`
}
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('results.filename')}</TableHead>
<TableHead>{t('results.status')}</TableHead>
<TableHead>{t('results.processingTime')}</TableHead>
<TableHead className="text-right">{t('results.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{files.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
{t('results.noResults')}
</TableCell>
</TableRow>
) : (
files.map((file) => (
<TableRow key={file.id}>
<TableCell className="font-medium">{file.filename}</TableCell>
<TableCell>{getStatusBadge(file.status)}</TableCell>
<TableCell>{formatTime(file.processing_time)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
{file.status === 'completed' && (
<>
<Button
variant="outline"
size="sm"
onClick={() => onViewResult?.(file.id)}
>
{t('results.viewMarkdown')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onDownloadPDF?.(file.id)}
>
{t('results.downloadPDF')}
</Button>
</>
)}
{file.status === 'failed' && file.error && (
<span className="text-sm text-destructive">{file.error}</span>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)
}

View File

@@ -0,0 +1,30 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'success'
}
function Badge({ className, variant = 'default', ...props }: BadgeProps) {
return (
<div
className={cn(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80':
variant === 'default',
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80':
variant === 'secondary',
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80':
variant === 'destructive',
'border-transparent bg-green-500 text-white hover:bg-green-600': variant === 'success',
'text-foreground': variant === 'outline',
},
className
)}
{...props}
/>
)
}
export { Badge }

View File

@@ -0,0 +1,42 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
size?: 'default' | 'sm' | 'lg' | 'icon'
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'default', size = 'default', ...props }, ref) => {
return (
<button
className={cn(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background',
{
'bg-primary text-primary-foreground hover:bg-primary/90': variant === 'default',
'bg-destructive text-destructive-foreground hover:bg-destructive/90':
variant === 'destructive',
'border border-input hover:bg-accent hover:text-accent-foreground':
variant === 'outline',
'bg-secondary text-secondary-foreground hover:bg-secondary/80':
variant === 'secondary',
'hover:bg-accent hover:text-accent-foreground': variant === 'ghost',
'underline-offset-4 hover:underline text-primary': variant === 'link',
},
{
'h-10 py-2 px-4': size === 'default',
'h-9 px-3 rounded-md': size === 'sm',
'h-11 px-8 rounded-md': size === 'lg',
'h-10 w-10': size === 'icon',
},
className
)}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button }

View File

@@ -0,0 +1,55 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
)
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
))
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,29 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
value?: number
max?: number
}
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ className, value = 0, max = 100, ...props }, ref) => {
const percentage = Math.min(Math.max((value / max) * 100, 0), 100)
return (
<div
ref={ref}
className={cn('relative h-4 w-full overflow-hidden rounded-full bg-secondary', className)}
{...props}
>
<div
className="h-full w-full flex-1 bg-primary transition-all duration-300 ease-in-out"
style={{ transform: `translateX(-${100 - percentage}%)` }}
/>
</div>
)
}
)
Progress.displayName = 'Progress'
export { Progress }

View File

@@ -0,0 +1,70 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="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 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'
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }

View File

@@ -0,0 +1,116 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export type ToastProps = {
id: string
title?: string
description?: string
variant?: 'default' | 'destructive' | 'success'
duration?: number
}
type ToastContextType = {
toasts: ToastProps[]
toast: (props: Omit<ToastProps, 'id'>) => void
dismiss: (id: string) => void
}
const ToastContext = React.createContext<ToastContextType | undefined>(undefined)
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = React.useState<ToastProps[]>([])
const toast = React.useCallback((props: Omit<ToastProps, 'id'>) => {
const id = Math.random().toString(36).substr(2, 9)
const duration = props.duration ?? 3000
setToasts((prev) => [...prev, { ...props, id }])
if (duration > 0) {
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, duration)
}
}, [])
const dismiss = React.useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
return (
<ToastContext.Provider value={{ toasts, toast, dismiss }}>
{children}
<ToastViewport toasts={toasts} dismiss={dismiss} />
</ToastContext.Provider>
)
}
export function useToast() {
const context = React.useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within ToastProvider')
}
return context
}
function ToastViewport({
toasts,
dismiss,
}: {
toasts: ToastProps[]
dismiss: (id: string) => void
}) {
return (
<div className="fixed top-0 right-0 z-50 w-full max-w-md p-4 space-y-4 pointer-events-none">
{toasts.map((toast) => (
<Toast key={toast.id} {...toast} onDismiss={() => dismiss(toast.id)} />
))}
</div>
)
}
function Toast({
title,
description,
variant = 'default',
onDismiss,
}: ToastProps & { onDismiss: () => void }) {
return (
<div
className={cn(
'pointer-events-auto w-full rounded-lg border p-4 shadow-lg transition-all',
'bg-background text-foreground',
{
'border-destructive': variant === 'destructive',
'border-green-500': variant === 'success',
}
)}
>
<div className="flex items-start gap-3">
<div className="flex-1 space-y-1">
{title && <div className="text-sm font-semibold">{title}</div>}
{description && <div className="text-sm text-muted-foreground">{description}</div>}
</div>
<button
onClick={onDismiss}
className="text-foreground/50 hover:text-foreground transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
)
}