feat: Initial commit of PDF Tools project
This commit is contained in:
20937
client/package-lock.json
generated
Normal file
20937
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
58
client/package.json
Normal file
58
client/package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "pdf-tools-client",
|
||||
"version": "1.0.0",
|
||||
"description": "PDF转换工具前端应用",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-scripts": "^5.0.1",
|
||||
"typescript": "^4.9.5",
|
||||
"antd": "^5.12.8",
|
||||
"zustand": "^4.4.7",
|
||||
"react-router-dom": "^6.18.0",
|
||||
"axios": "^1.6.2",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-query": "^3.39.3",
|
||||
"recharts": "^2.8.0",
|
||||
"lucide-react": "^0.294.0"
|
||||
},
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^14.5.1",
|
||||
"@types/jest": "^29.5.8"
|
||||
},
|
||||
"proxy": "http://localhost:3001"
|
||||
}
|
||||
6
client/postcss.config.js
Normal file
6
client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
381
client/src/App.css
Normal file
381
client/src/App.css
Normal file
@@ -0,0 +1,381 @@
|
||||
/* 全局样式重置和基础设置 */
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
padding: 20px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 自定义Ant Design组件样式 */
|
||||
.ant-layout-header {
|
||||
background: #fff !important;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.ant-menu-horizontal {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.ant-menu-horizontal > .ant-menu-item-selected {
|
||||
color: #1890ff !important;
|
||||
border-bottom-color: #1890ff !important;
|
||||
}
|
||||
|
||||
/* 上传区域样式 */
|
||||
.ant-upload-drag {
|
||||
border: 2px dashed #d9d9d9 !important;
|
||||
border-radius: 8px !important;
|
||||
background: #fafafa !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
.ant-upload-drag:hover {
|
||||
border-color: #1890ff !important;
|
||||
background: #f0f8ff !important;
|
||||
}
|
||||
|
||||
.ant-upload-drag.ant-upload-drag-hover {
|
||||
border-color: #1890ff !important;
|
||||
background: #f0f8ff !important;
|
||||
}
|
||||
|
||||
/* 进度条样式 */
|
||||
.ant-progress-line {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.ant-progress-bg {
|
||||
border-radius: 100px !important;
|
||||
}
|
||||
|
||||
/* 卡片样式 */
|
||||
.ant-card {
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06) !important;
|
||||
border: 1px solid #f0f0f0 !important;
|
||||
}
|
||||
|
||||
.ant-card-head {
|
||||
border-bottom: 1px solid #f0f0f0 !important;
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.ant-btn {
|
||||
border-radius: 6px !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
.ant-btn-primary {
|
||||
background: #1890ff !important;
|
||||
border-color: #1890ff !important;
|
||||
box-shadow: 0 2px 4px rgba(24, 144, 255, 0.2) !important;
|
||||
}
|
||||
|
||||
.ant-btn-primary:hover {
|
||||
background: #40a9ff !important;
|
||||
border-color: #40a9ff !important;
|
||||
box-shadow: 0 4px 8px rgba(24, 144, 255, 0.3) !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ant-btn-lg {
|
||||
height: 48px !important;
|
||||
padding: 6px 24px !important;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
.ant-table {
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
background: #fafafa !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background: #f5f5f5 !important;
|
||||
}
|
||||
|
||||
/* 标签样式 */
|
||||
.ant-tag {
|
||||
border-radius: 4px !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
/* 步骤条样式 */
|
||||
.ant-steps-item-finish .ant-steps-item-icon {
|
||||
background-color: #52c41a !important;
|
||||
border-color: #52c41a !important;
|
||||
}
|
||||
|
||||
.ant-steps-item-process .ant-steps-item-icon {
|
||||
background-color: #1890ff !important;
|
||||
border-color: #1890ff !important;
|
||||
}
|
||||
|
||||
/* 表单样式 */
|
||||
.ant-form-item-label > label {
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.ant-input,
|
||||
.ant-select-selector {
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
.ant-input:focus,
|
||||
.ant-select-focused .ant-select-selector {
|
||||
border-color: #1890ff !important;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
/* 消息提示样式 */
|
||||
.ant-message {
|
||||
top: 20px !important;
|
||||
}
|
||||
|
||||
.ant-message-notice {
|
||||
border-radius: 6px !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
/* 抽屉样式 */
|
||||
.ant-drawer-header {
|
||||
border-bottom: 1px solid #f0f0f0 !important;
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
padding: 24px !important;
|
||||
}
|
||||
|
||||
/* 模态框样式 */
|
||||
.ant-modal {
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
border-bottom: 1px solid #f0f0f0 !important;
|
||||
border-radius: 8px 8px 0 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
border-top: 1px solid #f0f0f0 !important;
|
||||
border-radius: 0 0 8px 8px !important;
|
||||
}
|
||||
|
||||
/* 自定义工具提示样式 */
|
||||
.ant-tooltip-inner {
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
/* 分页器样式 */
|
||||
.ant-pagination-item {
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
.ant-pagination-item-active {
|
||||
background: #1890ff !important;
|
||||
border-color: #1890ff !important;
|
||||
}
|
||||
|
||||
/* 开关样式 */
|
||||
.ant-switch {
|
||||
border-radius: 100px !important;
|
||||
}
|
||||
|
||||
/* 滑块样式 */
|
||||
.ant-slider-rail {
|
||||
border-radius: 2px !important;
|
||||
}
|
||||
|
||||
.ant-slider-track {
|
||||
border-radius: 2px !important;
|
||||
}
|
||||
|
||||
.ant-slider-handle {
|
||||
border: 2px solid #1890ff !important;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.ant-card {
|
||||
margin: 8px !important;
|
||||
}
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 16px !important;
|
||||
}
|
||||
|
||||
.ant-btn-lg {
|
||||
height: 44px !important;
|
||||
font-size: 15px !important;
|
||||
}
|
||||
|
||||
.ant-table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.ant-card-body {
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.ant-form-item-label {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.ant-steps {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* 深色模式支持 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.ant-layout {
|
||||
background: #141414 !important;
|
||||
}
|
||||
|
||||
.ant-layout-content {
|
||||
background: #141414 !important;
|
||||
}
|
||||
|
||||
.ant-card {
|
||||
background: #1f1f1f !important;
|
||||
border-color: #303030 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* 拖拽上传区域 */
|
||||
.upload-dragger-active {
|
||||
border-color: #1890ff !important;
|
||||
background: #f0f8ff !important;
|
||||
}
|
||||
|
||||
/* 文件列表项 */
|
||||
.file-list-item {
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.file-list-item:hover {
|
||||
background: #f5f5f5;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 状态指示器 */
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.status-indicator.success {
|
||||
background-color: #52c41a;
|
||||
}
|
||||
|
||||
.status-indicator.error {
|
||||
background-color: #ff4d4f;
|
||||
}
|
||||
|
||||
.status-indicator.warning {
|
||||
background-color: #faad14;
|
||||
}
|
||||
|
||||
.status-indicator.processing {
|
||||
background-color: #1890ff;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 0.7;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
49
client/src/App.tsx
Normal file
49
client/src/App.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
|
||||
// 页面组件
|
||||
import HomePage from './pages/HomePage';
|
||||
import ConversionPage from './pages/ConversionPage';
|
||||
import HistoryPage from './pages/HistoryPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
|
||||
// 布局组件
|
||||
import Layout from './components/Layout/Layout';
|
||||
|
||||
// 样式
|
||||
import './App.css';
|
||||
|
||||
// 创建React Query客户端
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
staleTime: 5 * 60 * 1000, // 5分钟
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<Router>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/convert" element={<ConversionPage />} />
|
||||
<Route path="/history" element={<HistoryPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</Router>
|
||||
</ConfigProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
302
client/src/components/ConversionOptions/ConversionOptions.tsx
Normal file
302
client/src/components/ConversionOptions/ConversionOptions.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Form, Select, Switch, Slider, Button, Row, Col, Typography, Space, Alert } from 'antd';
|
||||
import { PlayCircleOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface ConversionOptionsProps {
|
||||
onConversionStart: (options: any) => void;
|
||||
}
|
||||
|
||||
const ConversionOptions: React.FC<ConversionOptionsProps> = ({ onConversionStart }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [selectedFormat, setSelectedFormat] = useState<string>('docx');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const formatOptions = [
|
||||
{ value: 'docx', label: 'Word文档 (.docx)', icon: '📄' },
|
||||
{ value: 'html', label: 'HTML网页 (.html)', icon: '🌐' },
|
||||
{ value: 'txt', label: '纯文本 (.txt)', icon: '📝' },
|
||||
{ value: 'png', label: 'PNG图片 (.png)', icon: '🖼️' },
|
||||
{ value: 'jpg', label: 'JPG图片 (.jpg)', icon: '📸' },
|
||||
];
|
||||
|
||||
const handleFormatChange = (format: string) => {
|
||||
setSelectedFormat(format);
|
||||
};
|
||||
|
||||
const handleStartConversion = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
const options = {
|
||||
outputFormat: selectedFormat,
|
||||
...values
|
||||
};
|
||||
|
||||
// 模拟处理延迟
|
||||
setTimeout(() => {
|
||||
onConversionStart(options);
|
||||
setLoading(false);
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('表单验证失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const renderFormatSpecificOptions = () => {
|
||||
switch (selectedFormat) {
|
||||
case 'docx':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Form.Item
|
||||
label="保持原始布局"
|
||||
name="preserveLayout"
|
||||
valuePropName="checked"
|
||||
help="尽可能保持PDF的原始布局和格式"
|
||||
>
|
||||
<Switch defaultChecked />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="包含图片"
|
||||
name="includeImages"
|
||||
valuePropName="checked"
|
||||
help="在转换后的Word文档中包含图片"
|
||||
>
|
||||
<Switch defaultChecked />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="图片质量"
|
||||
name="imageQuality"
|
||||
>
|
||||
<Select defaultValue="medium">
|
||||
<Select.Option value="low">低质量 (较小文件)</Select.Option>
|
||||
<Select.Option value="medium">中等质量</Select.Option>
|
||||
<Select.Option value="high">高质量 (较大文件)</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="启用OCR"
|
||||
name="ocrEnabled"
|
||||
valuePropName="checked"
|
||||
help="对扫描版PDF启用光学字符识别"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'html':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Form.Item
|
||||
label="响应式设计"
|
||||
name="responsive"
|
||||
valuePropName="checked"
|
||||
help="生成适配移动设备的响应式HTML"
|
||||
>
|
||||
<Switch defaultChecked />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="嵌入图片"
|
||||
name="embedImages"
|
||||
valuePropName="checked"
|
||||
help="将图片嵌入HTML文件中(Base64编码)"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="CSS框架"
|
||||
name="cssFramework"
|
||||
>
|
||||
<Select defaultValue="none">
|
||||
<Select.Option value="none">无框架</Select.Option>
|
||||
<Select.Option value="bootstrap">Bootstrap</Select.Option>
|
||||
<Select.Option value="tailwind">Tailwind CSS</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'png':
|
||||
case 'jpg':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Form.Item
|
||||
label="分辨率 (DPI)"
|
||||
name="resolution"
|
||||
>
|
||||
<Slider
|
||||
min={72}
|
||||
max={300}
|
||||
defaultValue={150}
|
||||
marks={{
|
||||
72: '72 (网页)',
|
||||
150: '150 (标准)',
|
||||
300: '300 (打印)'
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{selectedFormat === 'jpg' && (
|
||||
<Form.Item
|
||||
label="图片质量"
|
||||
name="jpgQuality"
|
||||
>
|
||||
<Slider
|
||||
min={1}
|
||||
max={100}
|
||||
defaultValue={85}
|
||||
marks={{
|
||||
1: '1',
|
||||
50: '50',
|
||||
85: '85',
|
||||
100: '100'
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label="页面范围"
|
||||
name="pageRange"
|
||||
>
|
||||
<Select defaultValue="all">
|
||||
<Select.Option value="all">所有页面</Select.Option>
|
||||
<Select.Option value="first">仅第一页</Select.Option>
|
||||
<Select.Option value="custom">自定义范围</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'txt':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Form.Item
|
||||
label="保留换行"
|
||||
name="preserveLineBreaks"
|
||||
valuePropName="checked"
|
||||
help="保持原文档的换行格式"
|
||||
>
|
||||
<Switch defaultChecked />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="字符编码"
|
||||
name="encoding"
|
||||
>
|
||||
<Select defaultValue="utf-8">
|
||||
<Select.Option value="utf-8">UTF-8</Select.Option>
|
||||
<Select.Option value="gbk">GBK</Select.Option>
|
||||
<Select.Option value="ascii">ASCII</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="text-center mb-6">
|
||||
<Title level={3}>选择转换格式</Title>
|
||||
<Text type="secondary">
|
||||
配置输出格式和转换选项
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Form form={form} layout="vertical">
|
||||
{/* 格式选择 */}
|
||||
<Card title="输出格式" className="mb-6">
|
||||
<Form.Item name="outputFormat" initialValue="docx">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{formatOptions.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`p-4 border-2 rounded-lg cursor-pointer transition-all ${
|
||||
selectedFormat === option.value
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
onClick={() => handleFormatChange(option.value)}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl mb-2">{option.icon}</div>
|
||||
<div className="font-medium">{option.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 格式特定选项 */}
|
||||
<Card title="转换选项" className="mb-6">
|
||||
{renderFormatSpecificOptions()}
|
||||
</Card>
|
||||
|
||||
{/* 批量处理选项 */}
|
||||
<Card title="批量处理" className="mb-6">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="合并输出"
|
||||
name="mergeOutput"
|
||||
valuePropName="checked"
|
||||
help="将多页转换结果合并为单个文件"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="压缩下载"
|
||||
name="zipResults"
|
||||
valuePropName="checked"
|
||||
help="将转换结果打包为ZIP文件"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<Alert
|
||||
message="转换提示"
|
||||
description="根据文件大小和复杂程度,转换可能需要几秒到几分钟不等。我们会在转换完成后通知您。"
|
||||
type="info"
|
||||
showIcon
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
{/* 开始转换按钮 */}
|
||||
<div className="text-center">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={loading}
|
||||
onClick={handleStartConversion}
|
||||
className="px-8 py-6 h-auto text-lg"
|
||||
>
|
||||
{loading ? '准备转换中...' : '开始转换'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversionOptions;
|
||||
205
client/src/components/ConversionProgress/ConversionProgress.tsx
Normal file
205
client/src/components/ConversionProgress/ConversionProgress.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Progress, Typography, Space, Spin, Button } from 'antd';
|
||||
import { CheckCircleOutlined, CloseCircleOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface ConversionProgressProps {
|
||||
onConversionComplete: () => void;
|
||||
}
|
||||
|
||||
const ConversionProgress: React.FC<ConversionProgressProps> = ({ onConversionComplete }) => {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [currentStep, setCurrentStep] = useState('');
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
const steps = [
|
||||
{ key: 'upload', label: '文件上传中...', duration: 1000 },
|
||||
{ key: 'parse', label: '解析PDF文档...', duration: 2000 },
|
||||
{ key: 'convert', label: '格式转换中...', duration: 3000 },
|
||||
{ key: 'optimize', label: '优化输出文件...', duration: 1500 },
|
||||
{ key: 'complete', label: '转换完成!', duration: 500 }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
let currentStepIndex = 0;
|
||||
let currentProgress = 0;
|
||||
|
||||
const runStep = () => {
|
||||
if (currentStepIndex >= steps.length) {
|
||||
setIsCompleted(true);
|
||||
setTimeout(() => {
|
||||
onConversionComplete();
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
const step = steps[currentStepIndex];
|
||||
setCurrentStep(step.label);
|
||||
|
||||
const stepProgress = 100 / steps.length;
|
||||
const targetProgress = (currentStepIndex + 1) * stepProgress;
|
||||
|
||||
const progressInterval = setInterval(() => {
|
||||
currentProgress += 2;
|
||||
if (currentProgress >= targetProgress) {
|
||||
currentProgress = targetProgress;
|
||||
clearInterval(progressInterval);
|
||||
currentStepIndex++;
|
||||
setTimeout(runStep, 300);
|
||||
}
|
||||
setProgress(Math.min(currentProgress, 100));
|
||||
}, step.duration / (stepProgress / 2));
|
||||
};
|
||||
|
||||
// 模拟转换过程
|
||||
const timeout = setTimeout(() => {
|
||||
runStep();
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [onConversionComplete]);
|
||||
|
||||
const handleRetry = () => {
|
||||
setProgress(0);
|
||||
setCurrentStep('');
|
||||
setIsCompleted(false);
|
||||
setHasError(false);
|
||||
// 重新开始转换逻辑
|
||||
};
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="p-6 text-center">
|
||||
<div className="mb-6">
|
||||
<CloseCircleOutlined className="text-6xl text-red-500 mb-4" />
|
||||
<Title level={3} type="danger">转换失败</Title>
|
||||
<Text type="secondary">
|
||||
转换过程中发生错误,请检查文件格式或稍后重试
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
<Button type="primary" onClick={handleRetry}>
|
||||
重新转换
|
||||
</Button>
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
重新开始
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCompleted) {
|
||||
return (
|
||||
<div className="p-6 text-center">
|
||||
<div className="mb-6">
|
||||
<CheckCircleOutlined className="text-6xl text-green-500 mb-4" />
|
||||
<Title level={3} type="success">转换完成!</Title>
|
||||
<Text type="secondary">
|
||||
您的文件已成功转换,可以下载了
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="text-center mb-8">
|
||||
<Title level={3}>正在转换文件</Title>
|
||||
<Text type="secondary">
|
||||
请耐心等待,转换过程可能需要几分钟时间
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<div className="text-center mb-6">
|
||||
<Spin
|
||||
indicator={<LoadingOutlined style={{ fontSize: 48 }} spin />}
|
||||
className="text-blue-500 mb-4"
|
||||
/>
|
||||
<div className="text-lg font-medium mb-2">{currentStep}</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<Progress
|
||||
percent={Math.round(progress)}
|
||||
status="active"
|
||||
strokeColor={{
|
||||
'0%': '#108ee9',
|
||||
'100%': '#87d068',
|
||||
}}
|
||||
className="conversion-progress"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Text type="secondary">
|
||||
{Math.round(progress)}% 完成
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 转换步骤说明 */}
|
||||
<Card title="转换步骤" size="small">
|
||||
<div className="space-y-3">
|
||||
{steps.map((step, index) => {
|
||||
const stepProgress = (index + 1) * (100 / steps.length);
|
||||
const isActive = progress >= stepProgress - (100 / steps.length) && progress < stepProgress;
|
||||
const isCompleted = progress >= stepProgress;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.key}
|
||||
className={`flex items-center space-x-3 p-2 rounded ${
|
||||
isActive ? 'bg-blue-50 border-l-4 border-blue-500' : ''
|
||||
}`}
|
||||
>
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${
|
||||
isCompleted
|
||||
? 'bg-green-500 text-white'
|
||||
: isActive
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 text-gray-500'
|
||||
}`}>
|
||||
{isCompleted ? '✓' : index + 1}
|
||||
</div>
|
||||
<div className={`flex-1 ${
|
||||
isCompleted
|
||||
? 'text-green-600'
|
||||
: isActive
|
||||
? 'text-blue-600 font-medium'
|
||||
: 'text-gray-500'
|
||||
}`}>
|
||||
{step.label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 取消按钮 */}
|
||||
<div className="text-center mt-6">
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
onClick={() => {
|
||||
if (window.confirm('确定要取消转换吗?')) {
|
||||
window.location.reload();
|
||||
}
|
||||
}}
|
||||
>
|
||||
取消转换
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversionProgress;
|
||||
166
client/src/components/FileUploader/FileUploader.tsx
Normal file
166
client/src/components/FileUploader/FileUploader.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Upload, Button, message, Card, Typography, Space, Progress } from 'antd';
|
||||
import { InboxOutlined, CloudUploadOutlined } from '@ant-design/icons';
|
||||
import type { UploadProps } from 'antd';
|
||||
import { useConversionStore } from '../../stores/conversionStore';
|
||||
|
||||
const { Dragger } = Upload;
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface FileUploaderProps {
|
||||
onFileUploaded: (file: File) => void;
|
||||
}
|
||||
|
||||
const FileUploader: React.FC<FileUploaderProps> = ({ onFileUploaded }) => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const { setUploading: setStoreUploading } = useConversionStore();
|
||||
|
||||
const uploadProps: UploadProps = {
|
||||
name: 'file',
|
||||
multiple: false,
|
||||
accept: '.pdf',
|
||||
beforeUpload: (file) => {
|
||||
const isPDF = file.type === 'application/pdf';
|
||||
if (!isPDF) {
|
||||
message.error('只能上传PDF文件!');
|
||||
return false;
|
||||
}
|
||||
|
||||
const isLt50M = file.size / 1024 / 1024 < 50;
|
||||
if (!isLt50M) {
|
||||
message.error('文件大小不能超过50MB!');
|
||||
return false;
|
||||
}
|
||||
|
||||
handleUpload(file);
|
||||
return false; // 阻止自动上传
|
||||
},
|
||||
onDrop(e) {
|
||||
console.log('Dropped files', e.dataTransfer.files);
|
||||
},
|
||||
};
|
||||
|
||||
const handleUpload = useCallback(async (file: File) => {
|
||||
setUploading(true);
|
||||
setStoreUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
// 模拟上传进度
|
||||
const progressInterval = setInterval(() => {
|
||||
setUploadProgress(prev => {
|
||||
if (prev >= 90) {
|
||||
clearInterval(progressInterval);
|
||||
return 90;
|
||||
}
|
||||
return prev + 10;
|
||||
});
|
||||
}, 200);
|
||||
|
||||
// 模拟文件上传
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
// 这里应该是实际的API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setUploadProgress(100);
|
||||
|
||||
message.success(`${file.name} 上传成功!`);
|
||||
onFileUploaded(file);
|
||||
|
||||
} catch (error) {
|
||||
message.error('文件上传失败,请重试!');
|
||||
console.error('上传错误:', error);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setStoreUploading(false);
|
||||
setTimeout(() => setUploadProgress(0), 1000);
|
||||
}
|
||||
}, [onFileUploaded, setStoreUploading]);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="text-center mb-6">
|
||||
<Title level={3}>上传PDF文件</Title>
|
||||
<Text type="secondary">
|
||||
支持拖拽上传,文件大小不超过50MB
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<Dragger {...uploadProps} className="upload-area">
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined className="text-6xl text-blue-500 mb-4" />
|
||||
</p>
|
||||
<p className="ant-upload-text text-xl mb-2">
|
||||
点击或拖拽文件到此区域上传
|
||||
</p>
|
||||
<p className="ant-upload-hint text-gray-500">
|
||||
支持单个PDF文件上传,文件大小不超过50MB
|
||||
</p>
|
||||
</Dragger>
|
||||
|
||||
{uploading && (
|
||||
<div className="mt-6">
|
||||
<Progress
|
||||
percent={uploadProgress}
|
||||
status="active"
|
||||
className="conversion-progress"
|
||||
/>
|
||||
<div className="text-center mt-2">
|
||||
<Text type="secondary">正在上传文件...</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 上传说明 */}
|
||||
<Card title="上传说明" size="small">
|
||||
<div className="space-y-2 text-sm text-gray-600">
|
||||
<div>• 仅支持PDF格式文件</div>
|
||||
<div>• 文件大小限制:50MB以内</div>
|
||||
<div>• 支持拖拽上传和点击选择</div>
|
||||
<div>• 我们承诺不保存您的文件内容,转换完成后自动删除</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 快捷上传按钮 */}
|
||||
<div className="text-center mt-6">
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudUploadOutlined />}
|
||||
size="large"
|
||||
onClick={() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.pdf';
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
const isPDF = file.type === 'application/pdf';
|
||||
const isLt50M = file.size / 1024 / 1024 < 50;
|
||||
if (isPDF && isLt50M) {
|
||||
handleUpload(file);
|
||||
} else {
|
||||
if (!isPDF) message.error('只能上传PDF文件!');
|
||||
if (!isLt50M) message.error('文件大小不能超过50MB!');
|
||||
}
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
loading={uploading}
|
||||
>
|
||||
{uploading ? '上传中...' : '选择文件'}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUploader;
|
||||
152
client/src/components/Layout/Layout.tsx
Normal file
152
client/src/components/Layout/Layout.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import { Layout as AntLayout, Menu, Button, Dropdown, Space } from 'antd';
|
||||
import {
|
||||
HomeOutlined,
|
||||
SwapOutlined,
|
||||
HistoryOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
MenuOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
|
||||
const { Header, Content, Footer } = AntLayout;
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: '/',
|
||||
icon: <HomeOutlined />,
|
||||
label: '首页',
|
||||
},
|
||||
{
|
||||
key: '/convert',
|
||||
icon: <SwapOutlined />,
|
||||
label: '文件转换',
|
||||
},
|
||||
{
|
||||
key: '/history',
|
||||
icon: <HistoryOutlined />,
|
||||
label: '转换历史',
|
||||
},
|
||||
{
|
||||
key: '/settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: '设置',
|
||||
},
|
||||
];
|
||||
|
||||
const userMenuItems = [
|
||||
{
|
||||
key: 'profile',
|
||||
label: '个人资料',
|
||||
icon: <UserOutlined />,
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
label: '退出登录',
|
||||
},
|
||||
];
|
||||
|
||||
const handleMenuClick = ({ key }: { key: string }) => {
|
||||
navigate(key);
|
||||
};
|
||||
|
||||
const handleUserMenuClick = ({ key }: { key: string }) => {
|
||||
if (key === 'logout') {
|
||||
// 处理退出登录
|
||||
console.log('退出登录');
|
||||
} else if (key === 'profile') {
|
||||
// 处理个人资料
|
||||
console.log('个人资料');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AntLayout className="min-h-screen">
|
||||
<Header className="bg-white shadow-sm border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="text-xl font-bold text-blue-600 cursor-pointer mr-8"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
PDF转换工具
|
||||
</div>
|
||||
|
||||
{/* 桌面端导航 */}
|
||||
<div className="hidden md:block">
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
className="border-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧操作区 */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* 移动端菜单 */}
|
||||
<div className="md:hidden">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: menuItems,
|
||||
onClick: handleMenuClick,
|
||||
}}
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button icon={<MenuOutlined />} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{/* 用户菜单 */}
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: userMenuItems,
|
||||
onClick: handleUserMenuClick,
|
||||
}}
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button icon={<UserOutlined />} type="text">
|
||||
用户
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<Content className="flex-1">
|
||||
{children}
|
||||
</Content>
|
||||
|
||||
<Footer className="bg-gray-50 border-t text-center">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center py-6">
|
||||
<div className="text-gray-600 text-sm mb-4 md:mb-0">
|
||||
© 2024 PDF转换工具. 保留所有权利.
|
||||
</div>
|
||||
<Space className="text-sm text-gray-500">
|
||||
<a href="#" className="hover:text-blue-600">隐私政策</a>
|
||||
<span>|</span>
|
||||
<a href="#" className="hover:text-blue-600">服务条款</a>
|
||||
<span>|</span>
|
||||
<a href="#" className="hover:text-blue-600">联系我们</a>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</Footer>
|
||||
</AntLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
199
client/src/components/ResultDownload/ResultDownload.tsx
Normal file
199
client/src/components/ResultDownload/ResultDownload.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Button, Typography, Space, Divider, message, Row, Col } from 'antd';
|
||||
import {
|
||||
DownloadOutlined,
|
||||
EyeOutlined,
|
||||
ShareAltOutlined,
|
||||
ReloadOutlined,
|
||||
FileTextOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
const ResultDownload: React.FC = () => {
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [previewVisible, setPreviewVisible] = useState(false);
|
||||
|
||||
// 模拟转换结果数据
|
||||
const resultData = {
|
||||
originalFile: {
|
||||
name: '示例文档.pdf',
|
||||
size: '2.5MB'
|
||||
},
|
||||
convertedFile: {
|
||||
name: '示例文档.docx',
|
||||
size: '1.8MB',
|
||||
format: 'Word文档',
|
||||
downloadUrl: '/api/download/example-doc.docx'
|
||||
},
|
||||
conversionTime: '23秒',
|
||||
quality: '高质量'
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
// 模拟下载过程
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// 这里应该是实际的下载逻辑
|
||||
// window.open(resultData.convertedFile.downloadUrl);
|
||||
|
||||
message.success('文件下载成功!');
|
||||
} catch (error) {
|
||||
message.error('下载失败,请重试!');
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = () => {
|
||||
setPreviewVisible(true);
|
||||
message.info('预览功能开发中...');
|
||||
};
|
||||
|
||||
const handleShare = () => {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
message.success('下载链接已复制到剪贴板!');
|
||||
};
|
||||
|
||||
const handleNewConversion = () => {
|
||||
window.location.href = '/convert';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="text-center mb-8">
|
||||
<div className="mb-4">
|
||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<FileTextOutlined className="text-4xl text-green-600" />
|
||||
</div>
|
||||
<Title level={3} type="success">转换成功!</Title>
|
||||
<Text type="secondary">
|
||||
您的PDF文件已成功转换为 {resultData.convertedFile.format}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 转换结果信息 */}
|
||||
<Card className="mb-6">
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} md={12}>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div className="text-sm text-gray-600 mb-1">原始文件</div>
|
||||
<div className="font-medium">{resultData.originalFile.name}</div>
|
||||
<div className="text-sm text-gray-500">{resultData.originalFile.size}</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div className="text-center p-4 bg-green-50 rounded-lg">
|
||||
<div className="text-sm text-gray-600 mb-1">转换结果</div>
|
||||
<div className="font-medium">{resultData.convertedFile.name}</div>
|
||||
<div className="text-sm text-gray-500">{resultData.convertedFile.size}</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Row gutter={[16, 16]} className="text-center">
|
||||
<Col xs={12} sm={6}>
|
||||
<div className="text-sm text-gray-600">转换时间</div>
|
||||
<div className="font-medium">{resultData.conversionTime}</div>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<div className="text-sm text-gray-600">转换质量</div>
|
||||
<div className="font-medium">{resultData.quality}</div>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<div className="text-sm text-gray-600">文件格式</div>
|
||||
<div className="font-medium">{resultData.convertedFile.format}</div>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<div className="text-sm text-gray-600">文件大小</div>
|
||||
<div className="font-medium">{resultData.convertedFile.size}</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<Card>
|
||||
<div className="text-center">
|
||||
<Space direction="vertical" size="large" className="w-full">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={downloading}
|
||||
onClick={handleDownload}
|
||||
className="w-full sm:w-auto px-8 py-6 h-auto text-lg"
|
||||
>
|
||||
{downloading ? '下载中...' : '下载文件'}
|
||||
</Button>
|
||||
|
||||
<Space wrap className="justify-center">
|
||||
<Button
|
||||
icon={<EyeOutlined />}
|
||||
onClick={handlePreview}
|
||||
>
|
||||
预览文件
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ShareAltOutlined />}
|
||||
onClick={handleShare}
|
||||
>
|
||||
分享链接
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleNewConversion}
|
||||
>
|
||||
转换新文件
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 下载说明 */}
|
||||
<Card className="mt-6" title="下载说明" size="small">
|
||||
<div className="space-y-2 text-sm text-gray-600">
|
||||
<Paragraph>
|
||||
• 转换后的文件将保留24小时,请及时下载保存
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
• 我们承诺不会保存您的文件内容,确保隐私安全
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
• 如果下载遇到问题,请刷新页面或联系客服
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
• 建议使用现代浏览器以获得最佳下载体验
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 用户反馈 */}
|
||||
<Card className="mt-6" title="转换满意吗?" size="small">
|
||||
<div className="text-center">
|
||||
<Space>
|
||||
<Button type="primary" ghost>
|
||||
😊 很满意
|
||||
</Button>
|
||||
<Button>
|
||||
😐 一般
|
||||
</Button>
|
||||
<Button>
|
||||
😞 需要改进
|
||||
</Button>
|
||||
</Space>
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
您的反馈有助于我们改进服务质量
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResultDownload;
|
||||
103
client/src/index.css
Normal file
103
client/src/index.css
Normal file
@@ -0,0 +1,103 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 全局样式 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* 应用容器 */
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 拖拽上传区域样式 */
|
||||
.upload-area {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.upload-area.dragover {
|
||||
border-color: #1890ff;
|
||||
background-color: #f0f8ff;
|
||||
}
|
||||
|
||||
/* 转换进度条自定义样式 */
|
||||
.conversion-progress {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.conversion-progress .ant-progress-bg {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 文件列表样式 */
|
||||
.file-list-item {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.file-list-item:hover {
|
||||
background-color: #fafafa;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
14
client/src/index.tsx
Normal file
14
client/src/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
114
client/src/pages/ConversionPage.tsx
Normal file
114
client/src/pages/ConversionPage.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Row, Col, Typography, Steps, message } from 'antd';
|
||||
import FileUploader from '../components/FileUploader/FileUploader';
|
||||
import ConversionOptions from '../components/ConversionOptions/ConversionOptions';
|
||||
import ConversionProgress from '../components/ConversionProgress/ConversionProgress';
|
||||
import ResultDownload from '../components/ResultDownload/ResultDownload';
|
||||
import { useConversionStore } from '../stores/conversionStore';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const ConversionPage: React.FC = () => {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const { currentTask } = useConversionStore();
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: '上传文件',
|
||||
description: '选择要转换的PDF文件'
|
||||
},
|
||||
{
|
||||
title: '选择格式',
|
||||
description: '配置转换选项和输出格式'
|
||||
},
|
||||
{
|
||||
title: '开始转换',
|
||||
description: '处理文件转换'
|
||||
},
|
||||
{
|
||||
title: '下载结果',
|
||||
description: '下载转换后的文件'
|
||||
}
|
||||
];
|
||||
|
||||
const handleFileUploaded = () => {
|
||||
setCurrentStep(1);
|
||||
message.success('文件上传成功!');
|
||||
};
|
||||
|
||||
const handleConversionStarted = () => {
|
||||
setCurrentStep(2);
|
||||
message.info('转换已开始...');
|
||||
};
|
||||
|
||||
const handleConversionCompleted = () => {
|
||||
setCurrentStep(3);
|
||||
message.success('转换完成!');
|
||||
};
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
return <FileUploader onFileUploaded={handleFileUploaded} />;
|
||||
case 1:
|
||||
return <ConversionOptions onConversionStart={handleConversionStarted} />;
|
||||
case 2:
|
||||
return <ConversionProgress onConversionComplete={handleConversionCompleted} />;
|
||||
case 3:
|
||||
return <ResultDownload />;
|
||||
default:
|
||||
return <FileUploader onFileUploaded={handleFileUploaded} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-8">
|
||||
<Title level={2}>PDF文件转换</Title>
|
||||
<p className="text-gray-600">按照步骤完成PDF文件格式转换</p>
|
||||
</div>
|
||||
|
||||
{/* 步骤指示器 */}
|
||||
<Card className="mb-8">
|
||||
<Steps
|
||||
current={currentStep}
|
||||
items={steps}
|
||||
className="mb-8"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<Row gutter={24}>
|
||||
<Col span={24}>
|
||||
<Card className="min-h-96">
|
||||
{renderStepContent()}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 转换状态信息 */}
|
||||
{currentTask && (
|
||||
<Card className="mt-6" title="当前转换任务">
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<div className="text-sm text-gray-600">任务ID</div>
|
||||
<div className="font-mono">{currentTask.taskId}</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div className="text-sm text-gray-600">状态</div>
|
||||
<div className="font-medium">{currentTask.status}</div>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div className="text-sm text-gray-600">进度</div>
|
||||
<div className="font-medium">{currentTask.progress}%</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversionPage;
|
||||
263
client/src/pages/HistoryPage.tsx
Normal file
263
client/src/pages/HistoryPage.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Table, Button, Tag, Space, Typography, Input, DatePicker, Select } from 'antd';
|
||||
import { DownloadOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
const { Title } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
interface ConversionRecord {
|
||||
key: string;
|
||||
fileName: string;
|
||||
sourceFormat: string;
|
||||
targetFormat: string;
|
||||
status: 'completed' | 'failed' | 'processing';
|
||||
createdAt: string;
|
||||
fileSize: string;
|
||||
downloadUrl?: string;
|
||||
}
|
||||
|
||||
const HistoryPage: React.FC = () => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
|
||||
// 模拟数据
|
||||
const mockData: ConversionRecord[] = [
|
||||
{
|
||||
key: '1',
|
||||
fileName: '项目报告.pdf',
|
||||
sourceFormat: 'PDF',
|
||||
targetFormat: 'Word',
|
||||
status: 'completed',
|
||||
createdAt: '2024-01-15 14:30:25',
|
||||
fileSize: '2.5MB',
|
||||
downloadUrl: '/download/1'
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
fileName: '用户手册.pdf',
|
||||
sourceFormat: 'PDF',
|
||||
targetFormat: 'HTML',
|
||||
status: 'completed',
|
||||
createdAt: '2024-01-15 13:20:10',
|
||||
fileSize: '1.8MB',
|
||||
downloadUrl: '/download/2'
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
fileName: '技术文档.pdf',
|
||||
sourceFormat: 'PDF',
|
||||
targetFormat: 'TXT',
|
||||
status: 'failed',
|
||||
createdAt: '2024-01-15 12:15:45',
|
||||
fileSize: '3.2MB'
|
||||
},
|
||||
{
|
||||
key: '4',
|
||||
fileName: '演示文稿.pdf',
|
||||
sourceFormat: 'PDF',
|
||||
targetFormat: 'PNG',
|
||||
status: 'processing',
|
||||
createdAt: '2024-01-15 11:45:30',
|
||||
fileSize: '5.1MB'
|
||||
}
|
||||
];
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'success';
|
||||
case 'failed':
|
||||
return 'error';
|
||||
case 'processing':
|
||||
return 'processing';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return '已完成';
|
||||
case 'failed':
|
||||
return '转换失败';
|
||||
case 'processing':
|
||||
return '处理中';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ConversionRecord> = [
|
||||
{
|
||||
title: '文件名',
|
||||
dataIndex: 'fileName',
|
||||
key: 'fileName',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '转换类型',
|
||||
key: 'conversion',
|
||||
width: 150,
|
||||
render: (_, record) => (
|
||||
<span>
|
||||
{record.sourceFormat} → {record.targetFormat}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '文件大小',
|
||||
dataIndex: 'fileSize',
|
||||
key: 'fileSize',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status) => (
|
||||
<Tag color={getStatusColor(status)}>
|
||||
{getStatusText(status)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
{record.status === 'completed' && record.downloadUrl && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => handleDownload(record)}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDelete(record.key)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleDownload = (record: ConversionRecord) => {
|
||||
// 实现下载逻辑
|
||||
console.log('下载文件:', record.fileName);
|
||||
};
|
||||
|
||||
const handleDelete = (key: string) => {
|
||||
// 实现删除逻辑
|
||||
console.log('删除记录:', key);
|
||||
};
|
||||
|
||||
const filteredData = mockData.filter(item => {
|
||||
const matchesSearch = item.fileName.toLowerCase().includes(searchText.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || item.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<Title level={2}>转换历史</Title>
|
||||
<p className="text-gray-600">查看和管理您的文件转换记录</p>
|
||||
</div>
|
||||
|
||||
{/* 过滤器 */}
|
||||
<Card className="mb-6">
|
||||
<Space wrap>
|
||||
<Input
|
||||
placeholder="搜索文件名"
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
style={{ width: 120 }}
|
||||
>
|
||||
<Select.Option value="all">全部状态</Select.Option>
|
||||
<Select.Option value="completed">已完成</Select.Option>
|
||||
<Select.Option value="processing">处理中</Select.Option>
|
||||
<Select.Option value="failed">失败</Select.Option>
|
||||
</Select>
|
||||
<RangePicker />
|
||||
<Button type="primary">筛选</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* 历史记录表格 */}
|
||||
<Card>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
pagination={{
|
||||
total: filteredData.length,
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) =>
|
||||
`第 ${range[0]}-${range[1]} 条,共 ${total} 条记录`,
|
||||
}}
|
||||
scroll={{ x: 800 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<Card className="mt-6" title="统计信息">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{mockData.length}
|
||||
</div>
|
||||
<div className="text-gray-600">总转换次数</div>
|
||||
</div>
|
||||
<div className="text-center p-4">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{mockData.filter(item => item.status === 'completed').length}
|
||||
</div>
|
||||
<div className="text-gray-600">成功转换</div>
|
||||
</div>
|
||||
<div className="text-center p-4">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{mockData.filter(item => item.status === 'processing').length}
|
||||
</div>
|
||||
<div className="text-gray-600">处理中</div>
|
||||
</div>
|
||||
<div className="text-center p-4">
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{mockData.filter(item => item.status === 'failed').length}
|
||||
</div>
|
||||
<div className="text-gray-600">转换失败</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryPage;
|
||||
145
client/src/pages/HomePage.tsx
Normal file
145
client/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React from 'react';
|
||||
import { Card, Button, Row, Col, Typography, Space } from 'antd';
|
||||
import {
|
||||
FileTextOutlined,
|
||||
Html5Outlined,
|
||||
FilePdfOutlined,
|
||||
FileImageOutlined,
|
||||
ArrowRightOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
const HomePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <FileTextOutlined className="text-4xl text-blue-500" />,
|
||||
title: 'PDF转Word',
|
||||
description: '高质量地将PDF文档转换为可编辑的Word文档,保持原有格式和布局'
|
||||
},
|
||||
{
|
||||
icon: <Html5Outlined className="text-4xl text-green-500" />,
|
||||
title: 'PDF转HTML',
|
||||
description: '将PDF转换为响应式HTML网页,支持在线浏览和分享'
|
||||
},
|
||||
{
|
||||
icon: <FileTextOutlined className="text-4xl text-purple-500" />,
|
||||
title: 'PDF转TXT',
|
||||
description: '提取PDF中的纯文本内容,去除格式信息,便于文本处理'
|
||||
},
|
||||
{
|
||||
icon: <FileImageOutlined className="text-4xl text-orange-500" />,
|
||||
title: 'PDF转图片',
|
||||
description: '将PDF页面转换为高清图片,支持PNG、JPG等多种格式'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* 头部介绍 */}
|
||||
<div className="text-center mb-12">
|
||||
<Title level={1} className="text-5xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||
PDF转换工具
|
||||
</Title>
|
||||
<Paragraph className="text-xl text-gray-600 mt-4 max-w-2xl mx-auto">
|
||||
一站式PDF文档处理平台,支持多种格式转换,让文档处理更简单高效
|
||||
</Paragraph>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<ArrowRightOutlined />}
|
||||
onClick={() => navigate('/convert')}
|
||||
className="mt-6 px-8 py-6 h-auto text-lg"
|
||||
>
|
||||
立即开始转换
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 功能特性 */}
|
||||
<Row gutter={[24, 24]} className="mb-16">
|
||||
{features.map((feature, index) => (
|
||||
<Col xs={24} sm={12} lg={6} key={index}>
|
||||
<Card
|
||||
className="h-full text-center hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1"
|
||||
bodyStyle={{ padding: '24px' }}
|
||||
>
|
||||
<div className="mb-4">{feature.icon}</div>
|
||||
<Title level={4} className="mb-3">{feature.title}</Title>
|
||||
<Paragraph className="text-gray-600 text-sm leading-relaxed">
|
||||
{feature.description}
|
||||
</Paragraph>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{/* 使用统计 */}
|
||||
<Row gutter={[32, 32]} className="text-center">
|
||||
<Col xs={24} sm={8}>
|
||||
<div className="p-6">
|
||||
<div className="text-3xl font-bold text-blue-600 mb-2">1000+</div>
|
||||
<div className="text-gray-600">每日转换文件数</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<div className="p-6">
|
||||
<div className="text-3xl font-bold text-green-600 mb-2">99.9%</div>
|
||||
<div className="text-gray-600">转换成功率</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<div className="p-6">
|
||||
<div className="text-3xl font-bold text-purple-600 mb-2">50MB</div>
|
||||
<div className="text-gray-600">最大文件支持</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 使用说明 */}
|
||||
<Card className="mt-16" title="如何使用">
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} md={8} className="text-center">
|
||||
<div className="mb-4">
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-2xl font-bold text-blue-600">1</span>
|
||||
</div>
|
||||
<Title level={4}>上传文件</Title>
|
||||
<Paragraph className="text-gray-600">
|
||||
拖拽或点击上传您的PDF文件
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={8} className="text-center">
|
||||
<div className="mb-4">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-2xl font-bold text-green-600">2</span>
|
||||
</div>
|
||||
<Title level={4}>选择格式</Title>
|
||||
<Paragraph className="text-gray-600">
|
||||
选择您需要的输出格式和选项
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} md={8} className="text-center">
|
||||
<div className="mb-4">
|
||||
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-2xl font-bold text-purple-600">3</span>
|
||||
</div>
|
||||
<Title level={4}>下载结果</Title>
|
||||
<Paragraph className="text-gray-600">
|
||||
等待转换完成后下载结果文件
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
188
client/src/pages/SettingsPage.tsx
Normal file
188
client/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React from 'react';
|
||||
import { Card, Form, Input, Select, Switch, Button, Typography, Divider, Space } from 'antd';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const onFinish = (values: any) => {
|
||||
console.log('设置保存:', values);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<Title level={2}>系统设置</Title>
|
||||
<Paragraph className="text-gray-600">
|
||||
配置您的转换偏好和系统选项
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onFinish}
|
||||
initialValues={{
|
||||
defaultOutputFormat: 'docx',
|
||||
imageQuality: 'high',
|
||||
preserveLayout: true,
|
||||
enableOCR: false,
|
||||
autoDownload: true,
|
||||
language: 'zh-CN'
|
||||
}}
|
||||
>
|
||||
{/* 转换设置 */}
|
||||
<Card title="转换设置" className="mb-6">
|
||||
<Form.Item
|
||||
label="默认输出格式"
|
||||
name="defaultOutputFormat"
|
||||
help="选择PDF转换的默认输出格式"
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="docx">Word文档 (.docx)</Select.Option>
|
||||
<Select.Option value="html">网页 (.html)</Select.Option>
|
||||
<Select.Option value="txt">纯文本 (.txt)</Select.Option>
|
||||
<Select.Option value="png">PNG图片</Select.Option>
|
||||
<Select.Option value="jpg">JPG图片</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="图片质量"
|
||||
name="imageQuality"
|
||||
help="转换为图片时的质量设置"
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="low">低质量 (快速)</Select.Option>
|
||||
<Select.Option value="medium">中等质量</Select.Option>
|
||||
<Select.Option value="high">高质量 (推荐)</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="保持原始布局"
|
||||
name="preserveLayout"
|
||||
valuePropName="checked"
|
||||
help="转换时尽可能保持原始文档的布局和格式"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="启用OCR文字识别"
|
||||
name="enableOCR"
|
||||
valuePropName="checked"
|
||||
help="对图片型PDF启用OCR文字识别功能"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 用户偏好 */}
|
||||
<Card title="用户偏好" className="mb-6">
|
||||
<Form.Item
|
||||
label="界面语言"
|
||||
name="language"
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="zh-CN">简体中文</Select.Option>
|
||||
<Select.Option value="en-US">English</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="自动下载"
|
||||
name="autoDownload"
|
||||
valuePropName="checked"
|
||||
help="转换完成后自动下载结果文件"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="文件保存路径"
|
||||
name="downloadPath"
|
||||
help="设置转换后文件的默认保存位置"
|
||||
>
|
||||
<Input placeholder="留空使用浏览器默认下载路径" />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 安全设置 */}
|
||||
<Card title="安全与隐私" className="mb-6">
|
||||
<Form.Item
|
||||
label="自动删除文件"
|
||||
name="autoDelete"
|
||||
valuePropName="checked"
|
||||
help="转换完成后自动删除服务器上的文件"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="删除延时 (小时)"
|
||||
name="deleteDelay"
|
||||
help="文件在服务器上保留的时间"
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value={1}>1小时</Select.Option>
|
||||
<Select.Option value={6}>6小时</Select.Option>
|
||||
<Select.Option value={24}>24小时</Select.Option>
|
||||
<Select.Option value={72}>3天</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Divider />
|
||||
<Paragraph className="text-sm text-gray-600">
|
||||
<strong>隐私承诺:</strong>我们承诺不会保存或分享您的文件内容。
|
||||
所有文件仅用于转换处理,并在指定时间后自动删除。
|
||||
</Paragraph>
|
||||
</Card>
|
||||
|
||||
{/* 高级设置 */}
|
||||
<Card title="高级设置" className="mb-6">
|
||||
<Form.Item
|
||||
label="并发转换任务数"
|
||||
name="maxConcurrentTasks"
|
||||
help="同时进行的最大转换任务数量"
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value={1}>1个任务</Select.Option>
|
||||
<Select.Option value={2}>2个任务</Select.Option>
|
||||
<Select.Option value={3}>3个任务</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="转换超时时间 (分钟)"
|
||||
name="conversionTimeout"
|
||||
help="单个转换任务的最大等待时间"
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value={5}>5分钟</Select.Option>
|
||||
<Select.Option value={10}>10分钟</Select.Option>
|
||||
<Select.Option value={30}>30分钟</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<Card>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" size="large">
|
||||
保存设置
|
||||
</Button>
|
||||
<Button size="large" onClick={() => form.resetFields()}>
|
||||
重置为默认
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
272
client/src/services/apiClient.ts
Normal file
272
client/src/services/apiClient.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
||||
import { ApiResponse, ConversionTask, FileInfo, User, SystemHealth, SystemStats } from '../types';
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: process.env.REACT_APP_API_URL || 'http://localhost:3001/api',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 请求拦截器 - 添加认证token
|
||||
this.client.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// 响应拦截器 - 处理错误
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Token过期,清除本地存储并重定向到登录页
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 文件上传
|
||||
async uploadFile(file: File): Promise<ApiResponse<FileInfo>> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await this.client.post('/files/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 获取文件信息
|
||||
async getFileInfo(fileId: string): Promise<ApiResponse<FileInfo>> {
|
||||
const response = await this.client.get(`/files/${fileId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
async deleteFile(fileId: string): Promise<ApiResponse> {
|
||||
const response = await this.client.delete(`/files/${fileId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 开始转换
|
||||
async startConversion(data: {
|
||||
fileId: string;
|
||||
outputFormat: string;
|
||||
options?: any;
|
||||
}): Promise<ApiResponse<ConversionTask>> {
|
||||
const response = await this.client.post('/convert/start', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 查询转换状态
|
||||
async getConversionStatus(taskId: string): Promise<ApiResponse<ConversionTask>> {
|
||||
const response = await this.client.get(`/convert/status/${taskId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 获取转换结果
|
||||
async getConversionResult(taskId: string): Promise<ApiResponse> {
|
||||
const response = await this.client.get(`/convert/result/${taskId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 批量转换
|
||||
async startBatchConversion(data: {
|
||||
fileIds: string[];
|
||||
outputFormat: string;
|
||||
options?: any;
|
||||
}): Promise<ApiResponse> {
|
||||
const response = await this.client.post('/convert/batch', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 取消转换
|
||||
async cancelConversion(taskId: string): Promise<ApiResponse> {
|
||||
const response = await this.client.post(`/convert/cancel/${taskId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 用户注册
|
||||
async register(data: {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<ApiResponse<{ user: User; token: string }>> {
|
||||
const response = await this.client.post('/users/register', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 用户登录
|
||||
async login(data: {
|
||||
email: string;
|
||||
password: string;
|
||||
}): Promise<ApiResponse<{ user: User; token: string }>> {
|
||||
const response = await this.client.post('/users/login', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
async getUserProfile(): Promise<ApiResponse<User>> {
|
||||
const response = await this.client.get('/users/profile');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 更新用户设置
|
||||
async updateUserSettings(settings: any): Promise<ApiResponse> {
|
||||
const response = await this.client.put('/users/settings', settings);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 获取转换历史
|
||||
async getConversionHistory(params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
status?: string;
|
||||
}): Promise<ApiResponse> {
|
||||
const response = await this.client.get('/users/history', { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 系统健康检查
|
||||
async getSystemHealth(): Promise<SystemHealth> {
|
||||
const response = await this.client.get('/system/health');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 获取系统统计
|
||||
async getSystemStats(): Promise<ApiResponse<SystemStats>> {
|
||||
const response = await this.client.get('/system/stats');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 获取支持的格式
|
||||
async getSupportedFormats(): Promise<ApiResponse> {
|
||||
const response = await this.client.get('/system/formats');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 获取系统配置
|
||||
async getSystemConfig(): Promise<ApiResponse> {
|
||||
const response = await this.client.get('/system/config');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 清理临时文件
|
||||
async cleanupTempFiles(): Promise<ApiResponse> {
|
||||
const response = await this.client.post('/system/cleanup');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
async downloadFile(fileName: string): Promise<Blob> {
|
||||
const response = await this.client.get(`/files/download/${fileName}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 轮询转换状态
|
||||
pollConversionStatus(
|
||||
taskId: string,
|
||||
callback: (task: ConversionTask) => void,
|
||||
interval: number = 2000
|
||||
): () => void {
|
||||
const poll = async () => {
|
||||
try {
|
||||
const response = await this.getConversionStatus(taskId);
|
||||
const task = response.data;
|
||||
if (task) {
|
||||
callback(task);
|
||||
|
||||
if (task.status === 'completed' || task.status === 'failed') {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('轮询转换状态失败:', error);
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
};
|
||||
|
||||
const intervalId = setInterval(poll, interval);
|
||||
poll(); // 立即执行一次
|
||||
|
||||
// 返回取消函数
|
||||
return () => clearInterval(intervalId);
|
||||
}
|
||||
|
||||
// 上传文件带进度
|
||||
async uploadFileWithProgress(
|
||||
file: File,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<ApiResponse<FileInfo>> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await this.client.post('/files/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const progress = Math.round(
|
||||
(progressEvent.loaded * 100) / progressEvent.total
|
||||
);
|
||||
onProgress(progress);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 批量下载
|
||||
async downloadMultipleFiles(fileNames: string[]): Promise<Blob> {
|
||||
const response = await this.client.post(
|
||||
'/files/download/batch',
|
||||
{ fileNames },
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 获取转换预览
|
||||
async getConversionPreview(taskId: string): Promise<ApiResponse> {
|
||||
const response = await this.client.get(`/convert/preview/${taskId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 验证文件
|
||||
async validateFile(file: File): Promise<ApiResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await this.client.post('/files/validate', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
const apiClient = new ApiClient();
|
||||
|
||||
export default apiClient;
|
||||
71
client/src/stores/conversionStore.ts
Normal file
71
client/src/stores/conversionStore.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface ConversionTask {
|
||||
taskId: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
progress: number;
|
||||
sourceFile: {
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
};
|
||||
targetFormat: string;
|
||||
options: any;
|
||||
resultUrl?: string;
|
||||
errorMessage?: string;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
interface ConversionState {
|
||||
currentTask: ConversionTask | null;
|
||||
taskHistory: ConversionTask[];
|
||||
isUploading: boolean;
|
||||
isConverting: boolean;
|
||||
|
||||
// Actions
|
||||
setCurrentTask: (task: ConversionTask | null) => void;
|
||||
updateTaskProgress: (taskId: string, progress: number) => void;
|
||||
updateTaskStatus: (taskId: string, status: ConversionTask['status']) => void;
|
||||
addToHistory: (task: ConversionTask) => void;
|
||||
setUploading: (uploading: boolean) => void;
|
||||
setConverting: (converting: boolean) => void;
|
||||
clearCurrentTask: () => void;
|
||||
}
|
||||
|
||||
export const useConversionStore = create<ConversionState>((set, get) => ({
|
||||
currentTask: null,
|
||||
taskHistory: [],
|
||||
isUploading: false,
|
||||
isConverting: false,
|
||||
|
||||
setCurrentTask: (task) => set({ currentTask: task }),
|
||||
|
||||
updateTaskProgress: (taskId, progress) => set((state) => ({
|
||||
currentTask: state.currentTask?.taskId === taskId
|
||||
? { ...state.currentTask, progress }
|
||||
: state.currentTask,
|
||||
taskHistory: state.taskHistory.map(task =>
|
||||
task.taskId === taskId ? { ...task, progress } : task
|
||||
)
|
||||
})),
|
||||
|
||||
updateTaskStatus: (taskId, status) => set((state) => ({
|
||||
currentTask: state.currentTask?.taskId === taskId
|
||||
? { ...state.currentTask, status }
|
||||
: state.currentTask,
|
||||
taskHistory: state.taskHistory.map(task =>
|
||||
task.taskId === taskId ? { ...task, status } : task
|
||||
)
|
||||
})),
|
||||
|
||||
addToHistory: (task) => set((state) => ({
|
||||
taskHistory: [task, ...state.taskHistory]
|
||||
})),
|
||||
|
||||
setUploading: (uploading) => set({ isUploading: uploading }),
|
||||
|
||||
setConverting: (converting) => set({ isConverting: converting }),
|
||||
|
||||
clearCurrentTask: () => set({ currentTask: null })
|
||||
}));
|
||||
358
client/src/tests/App.test.tsx
Normal file
358
client/src/tests/App.test.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// 导入组件
|
||||
import App from '../App';
|
||||
import HomePage from '../pages/HomePage';
|
||||
import ConversionPage from '../pages/ConversionPage';
|
||||
import FileUploader from '../components/FileUploader/FileUploader';
|
||||
import ConversionOptions from '../components/ConversionOptions/ConversionOptions';
|
||||
|
||||
// Mock API客户端
|
||||
jest.mock('../services/apiClient', () => ({
|
||||
uploadFile: jest.fn(),
|
||||
startConversion: jest.fn(),
|
||||
getConversionStatus: jest.fn(),
|
||||
getSystemHealth: jest.fn(),
|
||||
}));
|
||||
|
||||
// 测试工具组件
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
{children}
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('PDF转换工具前端测试', () => {
|
||||
|
||||
describe('App组件', () => {
|
||||
test('应该正确渲染App组件', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<App />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('PDF转换工具')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('应该包含导航菜单', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<App />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('首页')).toBeInTheDocument();
|
||||
expect(screen.getByText('文件转换')).toBeInTheDocument();
|
||||
expect(screen.getByText('转换历史')).toBeInTheDocument();
|
||||
expect(screen.getByText('设置')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HomePage组件', () => {
|
||||
test('应该显示主页内容', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<HomePage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('PDF转换工具')).toBeInTheDocument();
|
||||
expect(screen.getByText('一站式PDF文档处理平台,支持多种格式转换,让文档处理更简单高效')).toBeInTheDocument();
|
||||
expect(screen.getByText('立即开始转换')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('应该显示功能特性', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<HomePage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('PDF转Word')).toBeInTheDocument();
|
||||
expect(screen.getByText('PDF转HTML')).toBeInTheDocument();
|
||||
expect(screen.getByText('PDF转TXT')).toBeInTheDocument();
|
||||
expect(screen.getByText('PDF转图片')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('点击开始转换按钮应该导航到转换页面', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<HomePage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const startButton = screen.getByText('立即开始转换');
|
||||
await user.click(startButton);
|
||||
|
||||
// 这里应该测试路由跳转,但需要mock useNavigate
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileUploader组件', () => {
|
||||
const mockOnFileUploaded = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnFileUploaded.mockClear();
|
||||
});
|
||||
|
||||
test('应该显示上传区域', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<FileUploader onFileUploaded={mockOnFileUploaded} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('上传PDF文件')).toBeInTheDocument();
|
||||
expect(screen.getByText('点击或拖拽文件到此区域上传')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('应该显示文件类型限制', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<FileUploader onFileUploaded={mockOnFileUploaded} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('仅支持PDF格式文件')).toBeInTheDocument();
|
||||
expect(screen.getByText('文件大小限制:50MB以内')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('应该有选择文件按钮', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<FileUploader onFileUploaded={mockOnFileUploaded} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('选择文件')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 文件上传测试比较复杂,需要mock文件对象
|
||||
test('应该处理文件上传', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<FileUploader onFileUploaded={mockOnFileUploaded} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// 创建模拟PDF文件
|
||||
const file = new File(['pdf content'], 'test.pdf', {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
// 模拟文件上传
|
||||
const input = document.querySelector('input[type="file"]');
|
||||
if (input) {
|
||||
await user.upload(input as HTMLElement, file);
|
||||
}
|
||||
|
||||
// 验证上传处理
|
||||
await waitFor(() => {
|
||||
// 这里应该验证上传逻辑,但需要更详细的mock
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConversionOptions组件', () => {
|
||||
const mockOnConversionStart = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnConversionStart.mockClear();
|
||||
});
|
||||
|
||||
test('应该显示格式选择', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ConversionOptions onConversionStart={mockOnConversionStart} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('选择转换格式')).toBeInTheDocument();
|
||||
expect(screen.getByText('Word文档 (.docx)')).toBeInTheDocument();
|
||||
expect(screen.getByText('HTML网页 (.html)')).toBeInTheDocument();
|
||||
expect(screen.getByText('纯文本 (.txt)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('应该有开始转换按钮', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ConversionOptions onConversionStart={mockOnConversionStart} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('开始转换')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('选择不同格式应该显示相应选项', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ConversionOptions onConversionStart={mockOnConversionStart} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// 选择Word格式
|
||||
const wordOption = screen.getByText('Word文档 (.docx)').closest('div');
|
||||
if (wordOption) {
|
||||
await user.click(wordOption);
|
||||
expect(screen.getByText('保持原始布局')).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test('点击开始转换应该调用回调函数', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ConversionOptions onConversionStart={mockOnConversionStart} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const startButton = screen.getByText('开始转换');
|
||||
await user.click(startButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnConversionStart).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('响应式设计', () => {
|
||||
test('应该在移动设备上正确显示', () => {
|
||||
// 模拟移动设备视口
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 375,
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<HomePage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// 验证移动端布局
|
||||
expect(screen.getByText('PDF转换工具')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('错误处理', () => {
|
||||
test('应该处理网络错误', async () => {
|
||||
// Mock API错误
|
||||
const apiClient = require('../services/apiClient');
|
||||
apiClient.uploadFile.mockRejectedValue(new Error('网络错误'));
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<FileUploader onFileUploaded={() => {}} />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// 模拟文件上传失败
|
||||
// 这里需要更详细的错误处理测试
|
||||
});
|
||||
|
||||
test('应该显示用户友好的错误消息', () => {
|
||||
// 测试错误边界和错误消息显示
|
||||
});
|
||||
});
|
||||
|
||||
describe('无障碍访问', () => {
|
||||
test('应该有正确的ARIA标签', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<HomePage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// 检查重要元素的无障碍属性
|
||||
const startButton = screen.getByText('立即开始转换');
|
||||
expect(startButton).toHaveAttribute('type', 'button');
|
||||
});
|
||||
|
||||
test('应该支持键盘导航', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<HomePage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// 测试Tab键导航
|
||||
await user.tab();
|
||||
// 验证焦点状态
|
||||
});
|
||||
});
|
||||
|
||||
describe('性能测试', () => {
|
||||
test('组件渲染应该足够快', () => {
|
||||
const startTime = performance.now();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<HomePage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
const endTime = performance.now();
|
||||
const renderTime = endTime - startTime;
|
||||
|
||||
// 渲染时间应该少于100ms
|
||||
expect(renderTime).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 集成测试
|
||||
describe('用户流程集成测试', () => {
|
||||
test('完整的文件转换流程', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<ConversionPage />
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// 1. 上传文件
|
||||
// 2. 选择转换格式
|
||||
// 3. 配置选项
|
||||
// 4. 开始转换
|
||||
// 5. 等待完成
|
||||
// 6. 下载结果
|
||||
|
||||
// 这里需要mock整个流程的API调用
|
||||
});
|
||||
|
||||
test('批量转换流程', async () => {
|
||||
// 测试批量文件转换流程
|
||||
});
|
||||
|
||||
test('用户注册登录流程', async () => {
|
||||
// 测试用户认证流程
|
||||
});
|
||||
});
|
||||
281
client/src/types/index.ts
Normal file
281
client/src/types/index.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
// 转换任务相关类型
|
||||
export interface ConversionTask {
|
||||
taskId: string;
|
||||
fileId: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled';
|
||||
progress: number;
|
||||
outputFormat: string;
|
||||
options: ConversionOptions;
|
||||
sourceFile: FileInfo;
|
||||
resultUrl?: string;
|
||||
errorMessage?: string;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 文件信息类型
|
||||
export interface FileInfo {
|
||||
fileId: string;
|
||||
originalName: string;
|
||||
fileName?: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
uploadTime: string;
|
||||
status: 'uploading' | 'ready' | 'processing' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
// 转换选项类型
|
||||
export interface ConversionOptions {
|
||||
outputFormat: 'docx' | 'html' | 'txt' | 'png' | 'jpg';
|
||||
|
||||
// Word转换选项
|
||||
preserveLayout?: boolean;
|
||||
includeImages?: boolean;
|
||||
imageQuality?: 'low' | 'medium' | 'high';
|
||||
ocrEnabled?: boolean;
|
||||
|
||||
// HTML转换选项
|
||||
responsive?: boolean;
|
||||
embedImages?: boolean;
|
||||
cssFramework?: 'none' | 'bootstrap' | 'tailwind';
|
||||
includeMetadata?: boolean;
|
||||
|
||||
// 图片转换选项
|
||||
resolution?: number; // DPI
|
||||
jpgQuality?: number; // 1-100
|
||||
pageRange?: 'all' | 'first' | 'custom';
|
||||
customRange?: {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
// 文本转换选项
|
||||
preserveLineBreaks?: boolean;
|
||||
encoding?: 'utf8' | 'gbk' | 'ascii';
|
||||
|
||||
// 批量处理选项
|
||||
mergeOutput?: boolean;
|
||||
zipResults?: boolean;
|
||||
namePattern?: string;
|
||||
}
|
||||
|
||||
// 用户信息类型
|
||||
export interface User {
|
||||
userId: string;
|
||||
email: string;
|
||||
username: string;
|
||||
createdAt: string;
|
||||
lastLoginAt?: string;
|
||||
settings: UserSettings;
|
||||
}
|
||||
|
||||
// 用户设置类型
|
||||
export interface UserSettings {
|
||||
defaultOutputFormat: string;
|
||||
imageQuality: 'low' | 'medium' | 'high';
|
||||
autoDownload: boolean;
|
||||
language: 'zh-CN' | 'en-US';
|
||||
autoDelete?: boolean;
|
||||
deleteDelay?: number;
|
||||
maxConcurrentTasks?: number;
|
||||
conversionTimeout?: number;
|
||||
}
|
||||
|
||||
// API响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: T;
|
||||
timestamp: string;
|
||||
errorCode?: string;
|
||||
}
|
||||
|
||||
// 分页响应类型
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
pages: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 系统健康状态类型
|
||||
export interface SystemHealth {
|
||||
status: 'OK' | 'WARNING' | 'ERROR';
|
||||
timestamp: string;
|
||||
uptime: {
|
||||
seconds: number;
|
||||
readable: string;
|
||||
};
|
||||
memory: {
|
||||
rss: string;
|
||||
heapTotal: string;
|
||||
heapUsed: string;
|
||||
external: string;
|
||||
};
|
||||
system: {
|
||||
platform: string;
|
||||
arch: string;
|
||||
nodeVersion: string;
|
||||
totalMemory: string;
|
||||
freeMemory: string;
|
||||
cpuCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 系统统计类型
|
||||
export interface SystemStats {
|
||||
files: {
|
||||
count: number;
|
||||
totalSize: string;
|
||||
};
|
||||
conversions: {
|
||||
total: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
inProgress: number;
|
||||
};
|
||||
users: {
|
||||
total: number;
|
||||
active: number;
|
||||
newToday: number;
|
||||
};
|
||||
performance: {
|
||||
averageConversionTime: string;
|
||||
queueLength: number;
|
||||
errorRate: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 支持格式类型
|
||||
export interface SupportedFormat {
|
||||
format: string;
|
||||
mimeType: string;
|
||||
description: string;
|
||||
features?: string[];
|
||||
maxSize?: string;
|
||||
}
|
||||
|
||||
export interface SupportedFormats {
|
||||
input: SupportedFormat[];
|
||||
output: SupportedFormat[];
|
||||
}
|
||||
|
||||
// 转换历史记录类型
|
||||
export interface ConversionHistory {
|
||||
taskId: string;
|
||||
fileName: string;
|
||||
sourceFormat: string;
|
||||
targetFormat: string;
|
||||
status: 'completed' | 'failed' | 'processing';
|
||||
createdAt: string;
|
||||
fileSize: string;
|
||||
downloadUrl?: string;
|
||||
}
|
||||
|
||||
// 错误类型
|
||||
export interface AppError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// 上传进度类型
|
||||
export interface UploadProgress {
|
||||
loaded: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
// 表单验证错误类型
|
||||
export interface FormError {
|
||||
field: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// 组件Props类型
|
||||
export interface FileUploaderProps {
|
||||
onFileUploaded: (file: File) => void;
|
||||
accept?: string;
|
||||
maxSize?: number;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
export interface ConversionOptionsProps {
|
||||
onConversionStart: (options: ConversionOptions) => void;
|
||||
initialOptions?: Partial<ConversionOptions>;
|
||||
}
|
||||
|
||||
export interface ConversionProgressProps {
|
||||
task?: ConversionTask;
|
||||
onConversionComplete: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export interface ResultDownloadProps {
|
||||
task: ConversionTask;
|
||||
onNewConversion?: () => void;
|
||||
}
|
||||
|
||||
// 路由参数类型
|
||||
export interface RouteParams {
|
||||
taskId?: string;
|
||||
fileId?: string;
|
||||
}
|
||||
|
||||
// 查询参数类型
|
||||
export interface QueryParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
status?: string;
|
||||
format?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// Zustand Store类型
|
||||
export interface ConversionStore {
|
||||
currentTask: ConversionTask | null;
|
||||
taskHistory: ConversionTask[];
|
||||
isUploading: boolean;
|
||||
isConverting: boolean;
|
||||
|
||||
setCurrentTask: (task: ConversionTask | null) => void;
|
||||
updateTaskProgress: (taskId: string, progress: number) => void;
|
||||
updateTaskStatus: (taskId: string, status: ConversionTask['status']) => void;
|
||||
addToHistory: (task: ConversionTask) => void;
|
||||
setUploading: (uploading: boolean) => void;
|
||||
setConverting: (converting: boolean) => void;
|
||||
clearCurrentTask: () => void;
|
||||
}
|
||||
|
||||
export interface UserStore {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
|
||||
setUser: (user: User) => void;
|
||||
setToken: (token: string) => void;
|
||||
logout: () => void;
|
||||
updateSettings: (settings: Partial<UserSettings>) => void;
|
||||
}
|
||||
|
||||
// HTTP客户端配置类型
|
||||
export interface ApiConfig {
|
||||
baseURL: string;
|
||||
timeout: number;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
// WebSocket消息类型
|
||||
export interface WebSocketMessage {
|
||||
type: 'progress' | 'status' | 'error' | 'complete';
|
||||
taskId: string;
|
||||
data: any;
|
||||
timestamp: string;
|
||||
}
|
||||
67
client/tailwind.config.js
Normal file
67
client/tailwind.config.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{js,jsx,ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
},
|
||||
gray: {
|
||||
50: '#f9fafb',
|
||||
100: '#f3f4f6',
|
||||
200: '#e5e7eb',
|
||||
300: '#d1d5db',
|
||||
400: '#9ca3af',
|
||||
500: '#6b7280',
|
||||
600: '#4b5563',
|
||||
700: '#374151',
|
||||
800: '#1f2937',
|
||||
900: '#111827',
|
||||
}
|
||||
},
|
||||
spacing: {
|
||||
'18': '4.5rem',
|
||||
'88': '22rem',
|
||||
'128': '32rem',
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
'bounce-light': 'bounceLight 2s infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(20px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
bounceLight: {
|
||||
'0%, 20%, 50%, 80%, 100%': { transform: 'translateY(0)' },
|
||||
'40%': { transform: 'translateY(-10px)' },
|
||||
'60%': { transform: 'translateY(-5px)' },
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
],
|
||||
corePlugins: {
|
||||
preflight: false, // 禁用Tailwind的基础样式重置,避免与Ant Design冲突
|
||||
}
|
||||
}
|
||||
26
client/tsconfig.json
Normal file
26
client/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user