|
@@ -3,6 +3,7 @@ import { Button } from '@/client/components/ui/button';
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/client/components/ui/card';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/client/components/ui/card';
|
|
|
import { toast } from 'sonner';
|
|
import { toast } from 'sonner';
|
|
|
import { Upload, Download, Trash2, FileText, GitMerge } from 'lucide-react';
|
|
import { Upload, Download, Trash2, FileText, GitMerge } from 'lucide-react';
|
|
|
|
|
+import { documentsClient } from '@/client/api';
|
|
|
|
|
|
|
|
interface WordFile {
|
|
interface WordFile {
|
|
|
id: string;
|
|
id: string;
|
|
@@ -16,6 +17,7 @@ const WordMergePage: React.FC = () => {
|
|
|
const [files, setFiles] = useState<WordFile[]>([]);
|
|
const [files, setFiles] = useState<WordFile[]>([]);
|
|
|
const [isMerging, setIsMerging] = useState(false);
|
|
const [isMerging, setIsMerging] = useState(false);
|
|
|
const [mergedFile, setMergedFile] = useState<Blob | null>(null);
|
|
const [mergedFile, setMergedFile] = useState<Blob | null>(null);
|
|
|
|
|
+ const [outputFormat, setOutputFormat] = useState<'pdf' | 'docx'>('docx');
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
|
|
// 格式化文件大小
|
|
// 格式化文件大小
|
|
@@ -83,84 +85,51 @@ const WordMergePage: React.FC = () => {
|
|
|
|
|
|
|
|
setIsMerging(true);
|
|
setIsMerging(true);
|
|
|
try {
|
|
try {
|
|
|
- // 使用真实的Word文档合并功能
|
|
|
|
|
- // 加载必要的库
|
|
|
|
|
- const PizZip = (await import('pizzip')).default;
|
|
|
|
|
- const JSZip = (await import('jszip')).default;
|
|
|
|
|
|
|
+ const formData = new FormData();
|
|
|
|
|
|
|
|
- if (files.length === 0) {
|
|
|
|
|
- toast.error('请先选择要合并的Word文档');
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 创建一个新的空JSZip实例来构建合并后的文档
|
|
|
|
|
- const mergedZip = new JSZip();
|
|
|
|
|
-
|
|
|
|
|
- // 使用第一个文档作为基础模板,复制所有文件
|
|
|
|
|
- const baseFile = files[0].file;
|
|
|
|
|
- const baseArrayBuffer = await baseFile.arrayBuffer();
|
|
|
|
|
- const baseZip = await mergedZip.loadAsync(baseArrayBuffer);
|
|
|
|
|
-
|
|
|
|
|
- // 获取基础文档的word/document.xml内容
|
|
|
|
|
- const baseXmlArrayBuffer = await baseZip.file('word/document.xml').async('arraybuffer');
|
|
|
|
|
- const textDecoder = new TextDecoder('utf-8');
|
|
|
|
|
- let mergedContent = textDecoder.decode(baseXmlArrayBuffer);
|
|
|
|
|
|
|
+ // 添加所有文件到formData
|
|
|
|
|
+ files.forEach(file => {
|
|
|
|
|
+ formData.append('files', file.file);
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
- // 移除基础文档的结束标签,以便添加其他文档内容
|
|
|
|
|
- mergedContent = mergedContent.replace(/<\/w:body>\s*<\/w:document>\s*$/, '');
|
|
|
|
|
|
|
+ // 添加输出格式选项
|
|
|
|
|
+ formData.append('outputFormat', outputFormat);
|
|
|
|
|
+ formData.append('preserveFormatting', 'true');
|
|
|
|
|
+
|
|
|
|
|
+ // 调用后端API进行文档合并
|
|
|
|
|
+ const response = await documentsClient.merge.$post({
|
|
|
|
|
+ form: formData
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (response.status !== 200) {
|
|
|
|
|
+ const errorData = await response.json();
|
|
|
|
|
+ throw new Error(errorData.message || '文档合并失败');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const result = await response.json();
|
|
|
|
|
|
|
|
- // 逐个添加其他文档的完整内容(从第二个文件开始)
|
|
|
|
|
- for (let i = 1; i < files.length; i++) {
|
|
|
|
|
- const file = files[i].file;
|
|
|
|
|
- const fileArrayBuffer = await file.arrayBuffer();
|
|
|
|
|
- const fileZip = new JSZip();
|
|
|
|
|
- await fileZip.loadAsync(fileArrayBuffer);
|
|
|
|
|
-
|
|
|
|
|
- const fileXmlArrayBuffer = await fileZip.file('word/document.xml').async('arraybuffer');
|
|
|
|
|
- let fileContent = textDecoder.decode(fileXmlArrayBuffer);
|
|
|
|
|
-
|
|
|
|
|
- // 提取文档的完整body内容(包括样式和格式)
|
|
|
|
|
- const bodyMatch = fileContent.match(/<w:body(?:\s+[^>]*)?>([\s\S]*?)<\/w:body>/);
|
|
|
|
|
- if (bodyMatch && bodyMatch[1]) {
|
|
|
|
|
- // 添加分页符以确保每个文档在新页面开始
|
|
|
|
|
- mergedContent += '<w:p><w:r><w:br w:type="page"/></w:r></w:p>';
|
|
|
|
|
- mergedContent += bodyMatch[1];
|
|
|
|
|
|
|
+ if (result.success) {
|
|
|
|
|
+ // 从data URL中提取base64数据并转换为Blob
|
|
|
|
|
+ const dataUrl = result.downloadUrl;
|
|
|
|
|
+ const base64Data = dataUrl.split(',')[1];
|
|
|
|
|
+ const byteCharacters = atob(base64Data);
|
|
|
|
|
+ const byteNumbers = new Array(byteCharacters.length);
|
|
|
|
|
+ for (let i = 0; i < byteCharacters.length; i++) {
|
|
|
|
|
+ byteNumbers[i] = byteCharacters.charCodeAt(i);
|
|
|
}
|
|
}
|
|
|
|
|
+ const byteArray = new Uint8Array(byteNumbers);
|
|
|
|
|
+ const blob = new Blob([byteArray], {
|
|
|
|
|
+ type: outputFormat === 'pdf' ? 'application/pdf' : 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
- // 复制其他文档的样式、字体、主题等资源文件
|
|
|
|
|
- const fileEntries = fileZip.file(/^(word\/media|word\/theme|word\/_rels|word\/styles|docProps|_rels|\[Content_Types\].xml)/);
|
|
|
|
|
- for (const entry of fileEntries) {
|
|
|
|
|
- if (!baseZip.file(entry.name)) {
|
|
|
|
|
- const fileData = await entry.async('arraybuffer');
|
|
|
|
|
- baseZip.file(entry.name, fileData);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // 添加完整的结束标签,确保XML结构完整
|
|
|
|
|
- mergedContent += '</w:body></w:document>';
|
|
|
|
|
-
|
|
|
|
|
- // 确保XML声明和编码正确
|
|
|
|
|
- if (!mergedContent.startsWith('<?xml')) {
|
|
|
|
|
- mergedContent = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' + mergedContent;
|
|
|
|
|
|
|
+ setMergedFile(blob);
|
|
|
|
|
+ toast.success(result.message);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ throw new Error(result.message);
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- // 更新合并后的内容
|
|
|
|
|
- baseZip.file('word/document.xml', mergedContent);
|
|
|
|
|
-
|
|
|
|
|
- // 生成合并后的Word文档
|
|
|
|
|
- const mergedDoc = await baseZip.generateAsync({
|
|
|
|
|
- type: 'blob',
|
|
|
|
|
- mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
|
|
|
- compression: 'DEFLATE',
|
|
|
|
|
- compressionOptions: { level: 6 }
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- setMergedFile(mergedDoc);
|
|
|
|
|
- toast.success(`已成功合并 ${files.length} 个Word文档为一个文件`);
|
|
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
console.error('合并失败:', error);
|
|
console.error('合并失败:', error);
|
|
|
- toast.error('文档合并失败,请重试');
|
|
|
|
|
|
|
+ toast.error(error instanceof Error ? error.message : '文档合并失败,请重试');
|
|
|
} finally {
|
|
} finally {
|
|
|
setIsMerging(false);
|
|
setIsMerging(false);
|
|
|
}
|
|
}
|
|
@@ -273,6 +242,33 @@ const WordMergePage: React.FC = () => {
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<div className="space-y-3">
|
|
<div className="space-y-3">
|
|
|
|
|
+ {/* 输出格式选择 */}
|
|
|
|
|
+ <div className="flex items-center gap-4">
|
|
|
|
|
+ <label className="text-sm font-medium">输出格式:</label>
|
|
|
|
|
+ <div className="flex gap-2">
|
|
|
|
|
+ <label className="flex items-center gap-1">
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="radio"
|
|
|
|
|
+ value="docx"
|
|
|
|
|
+ checked={outputFormat === 'docx'}
|
|
|
|
|
+ onChange={(e) => setOutputFormat(e.target.value as 'pdf' | 'docx')}
|
|
|
|
|
+ className="w-4 h-4"
|
|
|
|
|
+ />
|
|
|
|
|
+ Word (.docx)
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <label className="flex items-center gap-1">
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="radio"
|
|
|
|
|
+ value="pdf"
|
|
|
|
|
+ checked={outputFormat === 'pdf'}
|
|
|
|
|
+ onChange={(e) => setOutputFormat(e.target.value as 'pdf' | 'docx')}
|
|
|
|
|
+ className="w-4 h-4"
|
|
|
|
|
+ />
|
|
|
|
|
+ PDF (.pdf)
|
|
|
|
|
+ </label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
<Button
|
|
<Button
|
|
|
onClick={handleMergeFiles}
|
|
onClick={handleMergeFiles}
|
|
|
disabled={files.length < 2 || isMerging}
|
|
disabled={files.length < 2 || isMerging}
|
|
@@ -298,6 +294,9 @@ const WordMergePage: React.FC = () => {
|
|
|
<div className="text-sm text-gray-500">
|
|
<div className="text-sm text-gray-500">
|
|
|
<p>文档顺序:将按照列表中的顺序进行合并</p>
|
|
<p>文档顺序:将按照列表中的顺序进行合并</p>
|
|
|
<p>您可以通过删除后重新添加来调整顺序</p>
|
|
<p>您可以通过删除后重新添加来调整顺序</p>
|
|
|
|
|
+ <p className="mt-2 text-blue-600">
|
|
|
|
|
+ 处理流程:Word → PDF → 合并PDF → {outputFormat === 'pdf' ? '输出PDF' : 'PDF转Word'}
|
|
|
|
|
+ </p>
|
|
|
</div>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
|
</CardContent>
|
|
</CardContent>
|