feat:增加前端

This commit is contained in:
oliviamn 2025-05-25 00:37:20 +08:00
parent 900a614b09
commit 13ef24a3da
13 changed files with 17468 additions and 1 deletions

4
.gitignore vendored
View File

@ -70,4 +70,6 @@ app.log
__pycache__
data/doc_dest
data/doc_src
data/doc_intermediate
data/doc_intermediate
node_modules

55
frontend/README.md Normal file
View File

@ -0,0 +1,55 @@
# Legal Document Masker Frontend
This is the frontend application for the Legal Document Masker service. It provides a user interface for uploading legal documents, monitoring their processing status, and downloading the masked versions.
## Features
- Drag and drop file upload
- Real-time status updates
- File list with processing status
- Multi-file selection and download
- Modern Material-UI interface
## Prerequisites
- Node.js (v14 or higher)
- npm (v6 or higher)
## Installation
1. Install dependencies:
```bash
npm install
```
2. Start the development server:
```bash
npm start
```
The application will be available at http://localhost:3000
## Development
The frontend is built with:
- React 18
- TypeScript
- Material-UI
- React Query for data fetching
- React Dropzone for file uploads
## Building for Production
To create a production build:
```bash
npm run build
```
The build artifacts will be stored in the `build/` directory.
## Environment Variables
The following environment variables can be configured:
- `REACT_APP_API_URL`: The URL of the backend API (default: http://localhost:8000/api/v1)

16946
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
frontend/package.json Normal file
View File

@ -0,0 +1,50 @@
{
"name": "legal-doc-masker-frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.10",
"@mui/material": "^5.15.10",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.80",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"axios": "^1.6.7",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-query": "^3.39.3",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Legal Document Masker - Upload and process legal documents"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Legal Document Masker</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,15 @@
{
"short_name": "Legal Doc Masker",
"name": "Legal Document Masker",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

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

@ -0,0 +1,58 @@
import React, { useEffect, useState } from 'react';
import { Container, Typography, Box } from '@mui/material';
import { useQuery, useQueryClient } from 'react-query';
import FileUpload from './components/FileUpload';
import FileList from './components/FileList';
import { File } from './types/file';
import { api } from './services/api';
function App() {
const queryClient = useQueryClient();
const [files, setFiles] = useState<File[]>([]);
const { data, isLoading, error } = useQuery<File[]>('files', api.listFiles, {
refetchInterval: 5000, // Poll every 5 seconds
});
useEffect(() => {
if (data) {
setFiles(data);
}
}, [data]);
const handleUploadComplete = () => {
queryClient.invalidateQueries('files');
};
if (isLoading) {
return (
<Container>
<Typography>Loading...</Typography>
</Container>
);
}
if (error) {
return (
<Container>
<Typography color="error">Error loading files</Typography>
</Container>
);
}
return (
<Container maxWidth="lg">
<Box sx={{ my: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
Legal Document Masker
</Typography>
<Box sx={{ mb: 4 }}>
<FileUpload onUploadComplete={handleUploadComplete} />
</Box>
<FileList files={files} onFileStatusChange={handleUploadComplete} />
</Box>
</Container>
);
}
export default App;

View File

@ -0,0 +1,144 @@
import React, { useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
IconButton,
Checkbox,
Button,
Chip,
} from '@mui/material';
import { Download as DownloadIcon } from '@mui/icons-material';
import { File, FileStatus } from '../types/file';
import { api } from '../services/api';
interface FileListProps {
files: File[];
onFileStatusChange: () => void;
}
const FileList: React.FC<FileListProps> = ({ files, onFileStatusChange }) => {
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const handleSelectFile = (fileId: string) => {
setSelectedFiles((prev) =>
prev.includes(fileId)
? prev.filter((id) => id !== fileId)
: [...prev, fileId]
);
};
const handleSelectAll = () => {
setSelectedFiles((prev) =>
prev.length === files.length ? [] : files.map((file) => file.id)
);
};
const handleDownload = async (fileId: string) => {
try {
const blob = await api.downloadFile(fileId);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = files.find((f) => f.id === fileId)?.filename || 'downloaded-file';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error('Error downloading file:', error);
}
};
const handleDownloadSelected = async () => {
for (const fileId of selectedFiles) {
await handleDownload(fileId);
}
};
const getStatusColor = (status: FileStatus) => {
switch (status) {
case FileStatus.SUCCESS:
return 'success';
case FileStatus.FAILED:
return 'error';
case FileStatus.PROCESSING:
return 'warning';
default:
return 'default';
}
};
return (
<div>
<div style={{ marginBottom: '1rem' }}>
<Button
variant="contained"
color="primary"
onClick={handleDownloadSelected}
disabled={selectedFiles.length === 0}
>
Download Selected
</Button>
</div>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
checked={selectedFiles.length === files.length}
indeterminate={selectedFiles.length > 0 && selectedFiles.length < files.length}
onChange={handleSelectAll}
/>
</TableCell>
<TableCell>Filename</TableCell>
<TableCell>Status</TableCell>
<TableCell>Created At</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{files.map((file) => (
<TableRow key={file.id}>
<TableCell padding="checkbox">
<Checkbox
checked={selectedFiles.includes(file.id)}
onChange={() => handleSelectFile(file.id)}
/>
</TableCell>
<TableCell>{file.filename}</TableCell>
<TableCell>
<Chip
label={file.status}
color={getStatusColor(file.status) as any}
size="small"
/>
</TableCell>
<TableCell>
{new Date(file.created_at).toLocaleString()}
</TableCell>
<TableCell>
{file.status === FileStatus.SUCCESS && (
<IconButton
onClick={() => handleDownload(file.id)}
size="small"
>
<DownloadIcon />
</IconButton>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</div>
);
};
export default FileList;

View File

@ -0,0 +1,65 @@
import React, { useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import { Box, Typography, CircularProgress } from '@mui/material';
import { api } from '../services/api';
interface FileUploadProps {
onUploadComplete: () => void;
}
const FileUpload: React.FC<FileUploadProps> = ({ onUploadComplete }) => {
const [isUploading, setIsUploading] = React.useState(false);
const onDrop = useCallback(async (acceptedFiles: File[]) => {
setIsUploading(true);
try {
for (const file of acceptedFiles) {
await api.uploadFile(file);
}
onUploadComplete();
} catch (error) {
console.error('Error uploading files:', error);
} finally {
setIsUploading(false);
}
}, [onUploadComplete]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'application/pdf': ['.pdf'],
'application/msword': ['.doc'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
},
});
return (
<Box
{...getRootProps()}
sx={{
border: '2px dashed #ccc',
borderRadius: 2,
p: 3,
textAlign: 'center',
cursor: 'pointer',
bgcolor: isDragActive ? 'action.hover' : 'background.paper',
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
<input {...getInputProps()} />
{isUploading ? (
<CircularProgress />
) : (
<Typography>
{isDragActive
? 'Drop the files here...'
: 'Drag and drop files here, or click to select files'}
</Typography>
)}
</Box>
);
};
export default FileUpload;

29
frontend/src/index.tsx Normal file
View File

@ -0,0 +1,29 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from 'react-query';
import { ThemeProvider, createTheme } from '@mui/material';
import CssBaseline from '@mui/material/CssBaseline';
import App from './App';
const queryClient = new QueryClient();
const theme = createTheme({
palette: {
mode: 'light',
},
});
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
</QueryClientProvider>
</React.StrictMode>
);

View File

@ -0,0 +1,34 @@
import axios from 'axios';
import { File, FileUploadResponse } from '../types/file';
const API_BASE_URL = 'http://localhost:8000/api/v1';
export const api = {
uploadFile: async (file: globalThis.File): Promise<FileUploadResponse> => {
const formData = new FormData();
formData.append('file', file);
const response = await axios.post(`${API_BASE_URL}/files/upload`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
},
listFiles: async (): Promise<File[]> => {
const response = await axios.get(`${API_BASE_URL}/files/files`);
return response.data;
},
getFile: async (fileId: string): Promise<File> => {
const response = await axios.get(`${API_BASE_URL}/files/files/${fileId}`);
return response.data;
},
downloadFile: async (fileId: string): Promise<Blob> => {
const response = await axios.get(`${API_BASE_URL}/files/files/${fileId}/download`, {
responseType: 'blob',
});
return response.data;
},
};

View File

@ -0,0 +1,23 @@
export enum FileStatus {
NOT_STARTED = "not_started",
PROCESSING = "processing",
SUCCESS = "success",
FAILED = "failed"
}
export interface File {
id: string;
filename: string;
status: FileStatus;
error_message?: string;
created_at: string;
updated_at: string;
}
export interface FileUploadResponse {
id: string;
filename: string;
status: FileStatus;
created_at: string;
updated_at: string;
}

26
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}