feat:增加前端
This commit is contained in:
parent
900a614b09
commit
13ef24a3da
|
|
@ -70,4 +70,6 @@ app.log
|
||||||
__pycache__
|
__pycache__
|
||||||
data/doc_dest
|
data/doc_dest
|
||||||
data/doc_src
|
data/doc_src
|
||||||
data/doc_intermediate
|
data/doc_intermediate
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
|
@ -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)
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
|
@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue