feat: Initial commit of PDF Tools project

This commit is contained in:
2025-08-25 02:29:48 +08:00
parent af6827cd9e
commit 30180e50a2
48 changed files with 36364 additions and 1 deletions

20937
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

58
client/package.json Normal file
View 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
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

381
client/src/App.css Normal file
View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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
View 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>
);

View 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;

View 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;

View 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;

View 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;

View 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;

View 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 })
}));

View 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
View 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
View 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
View File

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