Ver código fonte

fix(export): Improve file download handling with blob-based approach

- Replace direct URL downloads with apiClient blob-based approach to avoid mixed content warnings
- Extract recordId from download URL using regex pattern matching
- Parse userId and fileName from response headers and export record metadata
- Implement proper blob creation and cleanup with URL.revokeObjectURL
- Add comprehensive error handling and logging for download operations
- Apply consistent download logic across MessageItem, DocumentManagement, and DocumentManagementLayout components
- Use responseType: 'blob' in apiClient configuration for proper file handling
- Simplify download URL construction by leveraging apiClient configuration
Zhang Yice 2 meses atrás
pai
commit
3ff53beab0

+ 57 - 3
src/components/ChatPanel/MessageItem.tsx

@@ -146,10 +146,64 @@ const MessageItem: React.FC<MessageItemProps> = memo(
146 146
      * Handle download document click
147 147
      */
148 148
     const handleDownloadClick = useCallback(
149
-      (e: React.MouseEvent) => {
149
+      async (e: React.MouseEvent) => {
150 150
         e.stopPropagation(); // Prevent card click
151
-        if (exportRecord?.downloadUrl) {
152
-          window.open(exportRecord.downloadUrl, '_blank');
151
+        if (!exportRecord?.downloadUrl) return;
152
+        
153
+        try {
154
+          // Extract recordId from downloadUrl
155
+          // URL format: http://xxx/api/v1/export/records/{recordId}/download?userId=xxx
156
+          const urlMatch = exportRecord.downloadUrl.match(/\/export\/records\/([^/]+)\/download/);
157
+          if (!urlMatch) {
158
+            console.error('[MessageItem] Cannot extract recordId from URL:', exportRecord.downloadUrl);
159
+            return;
160
+          }
161
+          
162
+          const recordId = urlMatch[1];
163
+          const urlParams = new URL(exportRecord.downloadUrl).searchParams;
164
+          const userId = urlParams.get('userId') || 'default-user';
165
+          
166
+          // Use apiClient to download (no mixed content warning)
167
+          const { default: apiClient } = await import('../../services/api');
168
+          const response = await apiClient.get(
169
+            `/api/v1/export/records/${recordId}/download`,
170
+            {
171
+              params: { userId },
172
+              responseType: 'blob',
173
+            }
174
+          );
175
+          
176
+          // Get filename from export record or Content-Disposition header
177
+          let fileName = exportRecord.fileName || 'document.doc';
178
+          const contentDisposition = response.headers['content-disposition'];
179
+          if (contentDisposition) {
180
+            const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
181
+            if (match && match[1]) {
182
+              fileName = match[1].replace(/['"]/g, '');
183
+            }
184
+          }
185
+          
186
+          // Create blob and download
187
+          const blob = new Blob([response.data], { type: 'application/msword' });
188
+          const blobUrl = URL.createObjectURL(blob);
189
+          
190
+          const link = document.createElement('a');
191
+          link.href = blobUrl;
192
+          link.download = fileName;
193
+          link.style.display = 'none';
194
+          
195
+          document.body.appendChild(link);
196
+          link.click();
197
+          
198
+          // Clean up
199
+          setTimeout(() => {
200
+            document.body.removeChild(link);
201
+            URL.revokeObjectURL(blobUrl);
202
+          }, 100);
203
+          
204
+          console.log('[MessageItem] Download triggered:', fileName);
205
+        } catch (error) {
206
+          console.error('[MessageItem] Download failed:', error);
153 207
         }
154 208
       },
155 209
       [exportRecord]

+ 21 - 8
src/components/DocumentManagement/DocumentManagement.tsx

@@ -36,6 +36,7 @@ import {
36 36
 import type { ColumnsType } from 'antd/es/table';
37 37
 import { listDocuments, getDocument, deleteDocuments } from '../../services/documentService';
38 38
 import { exportToWord } from '../../services/exportService';
39
+import apiClient from '../../services/api';
39 40
 import type { DocumentListItem, Document } from '../../types/document';
40 41
 
41 42
 const { Text, Paragraph } = Typography;
@@ -134,7 +135,7 @@ export const DocumentManagement: React.FC<DocumentManagementProps> = ({
134 135
     try {
135 136
       const result = await exportToWord({ 
136 137
         documentId,
137
-        // styleId: null,  // 后端暂不支持样式ID(阶段 1 功能)
138
+        styleId: null,  // 使用默认样式
138 139
       });
139 140
 
140 141
       message.success(`导出成功: ${result.fileName}`);
@@ -143,24 +144,36 @@ export const DocumentManagement: React.FC<DocumentManagementProps> = ({
143 144
         message.warning(result.warning, 5);
144 145
       }
145 146
 
146
-      // 直接触发浏览器下载,而不是在新标签页打开
147
-      if (result.downloadUrl) {
148
-        // 创建隐藏的 a 标签来触发下载
147
+      // Use apiClient to download (no mixed content warning)
148
+      try {
149
+        const response = await apiClient.get(
150
+          `/api/v1/export/records/${result.recordId}/download`,
151
+          {
152
+            params: { userId },
153
+            responseType: 'blob',
154
+          }
155
+        );
156
+        
157
+        const blob = new Blob([response.data], { type: 'application/msword' });
158
+        const blobUrl = URL.createObjectURL(blob);
159
+        
149 160
         const link = document.createElement('a');
150
-        link.href = result.downloadUrl;
151
-        link.download = result.fileName; // 设置下载文件名
161
+        link.href = blobUrl;
162
+        link.download = result.fileName;
152 163
         link.style.display = 'none';
153 164
         
154
-        // 添加到 DOM,触发点击,然后移除
155 165
         document.body.appendChild(link);
156 166
         link.click();
157 167
         
158
-        // 延迟移除,确保下载开始
159 168
         setTimeout(() => {
160 169
           document.body.removeChild(link);
170
+          URL.revokeObjectURL(blobUrl);
161 171
         }, 100);
162 172
         
163 173
         console.log('[DocumentManagement] Word 文档下载已触发:', result.fileName);
174
+      } catch (downloadError) {
175
+        console.error('[DocumentManagement] 下载失败:', downloadError);
176
+        message.error('文件下载失败');
164 177
       }
165 178
     } catch (error) {
166 179
       console.error('[DocumentManagement] 导出失败:', error);

+ 63 - 32
src/components/DocumentManagementLayout/DocumentManagementLayout.tsx

@@ -29,6 +29,7 @@ import {
29 29
   type OutlineItem,
30 30
 } from '../../services/documentContentService';
31 31
 import { exportToWord } from '../../services/exportService';
32
+import apiClient from '../../services/api';
32 33
 
33 34
 // ── Styles ────────────────────────────────────────────────────────────────
34 35
 
@@ -246,40 +247,44 @@ const DocumentManagementLayout: React.FC<DocumentManagementLayoutProps> = ({
246 247
       
247 248
       const result = await exportToWord({
248 249
         documentId: selectedRecordId,
249
-        // styleId: null,  // 后端暂不支持样式ID(阶段 1 功能)
250
+        styleId: null,  // 使用默认样式
250 251
       });
251 252
       
252 253
       message.success(`导出成功: ${result.fileName}`);
253 254
       
254
-      // 直接触发浏览器下载
255
-      if (result.downloadUrl) {
256
-        const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://192.168.0.195:8000';
257
-        const fullUrl = result.downloadUrl.startsWith('http') 
258
-          ? result.downloadUrl 
259
-          : `${baseUrl}${result.downloadUrl}`;
260
-        
261
-        // 创建隐藏的 a 标签来触发下载
262
-        const link = document.createElement('a');
263
-        link.href = fullUrl;
264
-        link.download = result.fileName;
265
-        link.style.display = 'none';
266
-        
267
-        document.body.appendChild(link);
268
-        link.click();
269
-        
270
-        setTimeout(() => {
271
-          document.body.removeChild(link);
272
-        }, 100);
273
-        
274
-        console.log('[DocumentManagementLayout] Word 文档下载已触发:', result.fileName);
275
-      }
255
+      // Use apiClient to download (no mixed content warning)
256
+      const response = await apiClient.get(
257
+        `/api/v1/export/records/${result.recordId}/download`,
258
+        {
259
+          params: { userId },
260
+          responseType: 'blob',
261
+        }
262
+      );
263
+      
264
+      const blob = new Blob([response.data], { type: 'application/msword' });
265
+      const blobUrl = URL.createObjectURL(blob);
266
+      
267
+      const link = document.createElement('a');
268
+      link.href = blobUrl;
269
+      link.download = result.fileName;
270
+      link.style.display = 'none';
271
+      
272
+      document.body.appendChild(link);
273
+      link.click();
274
+      
275
+      setTimeout(() => {
276
+        document.body.removeChild(link);
277
+        URL.revokeObjectURL(blobUrl);
278
+      }, 100);
279
+      
280
+      console.log('[DocumentManagementLayout] Word 文档下载已触发:', result.fileName);
276 281
     } catch (error) {
277 282
       console.error('[DocumentManagementLayout] 导出失败:', error);
278 283
       message.error('导出失败: ' + (error instanceof Error ? error.message : '未知错误'));
279 284
     } finally {
280 285
       setLoading(false);
281 286
     }
282
-  }, [selectedRecordId]);
287
+  }, [selectedRecordId, userId]);
283 288
 
284 289
   /**
285 290
    * Handle share button click
@@ -294,26 +299,52 @@ const DocumentManagementLayout: React.FC<DocumentManagementLayoutProps> = ({
294 299
   /**
295 300
    * Handle download button click
296 301
    */
297
-  const handleDownload = useCallback(() => {
298
-    if (selectedRecordId) {
299
-      const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://192.168.0.195:8000';
300
-      const downloadUrl = `${baseUrl}/api/v1/export/records/${selectedRecordId}/download?userId=${encodeURIComponent(userId)}`;
302
+  const handleDownload = useCallback(async () => {
303
+    if (!selectedRecordId) return;
304
+    
305
+    try {
306
+      // Use apiClient to download through axios (no mixed content warning)
307
+      const response = await apiClient.get(
308
+        `/api/v1/export/records/${selectedRecordId}/download`,
309
+        {
310
+          params: { userId },
311
+          responseType: 'blob',
312
+        }
313
+      );
314
+      
315
+      // Get filename from Content-Disposition header
316
+      let fileName = `document-${selectedRecordId}.doc`;
317
+      const contentDisposition = response.headers['content-disposition'];
318
+      if (contentDisposition) {
319
+        const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
320
+        if (match && match[1]) {
321
+          fileName = match[1].replace(/['"]/g, '');
322
+        }
323
+      }
324
+      
325
+      // Create blob and download
326
+      const blob = new Blob([response.data], { type: 'application/msword' });
327
+      const blobUrl = URL.createObjectURL(blob);
301 328
       
302
-      // 创建隐藏的 a 标签来触发下载
303 329
       const link = document.createElement('a');
304
-      link.href = downloadUrl;
305
-      link.download = `document-${selectedRecordId}.docx`; // 默认文件名
330
+      link.href = blobUrl;
331
+      link.download = fileName;
306 332
       link.style.display = 'none';
307 333
       
308 334
       document.body.appendChild(link);
309 335
       link.click();
310 336
       
337
+      // Clean up
311 338
       setTimeout(() => {
312 339
         document.body.removeChild(link);
340
+        URL.revokeObjectURL(blobUrl);
313 341
       }, 100);
314 342
       
315 343
       message.success('下载已开始');
316
-      console.log('[DocumentManagementLayout] 下载已触发:', downloadUrl);
344
+      console.log('[DocumentManagementLayout] 下载已触发:', fileName);
345
+    } catch (error) {
346
+      console.error('[DocumentManagementLayout] 下载失败:', error);
347
+      message.error('下载失败: ' + (error instanceof Error ? error.message : '未知错误'));
317 348
     }
318 349
   }, [selectedRecordId, userId]);
319 350
 

+ 22 - 9
src/components/EditorPanel/EditorPanel.tsx

@@ -8,7 +8,7 @@
8 8
  * Features:
9 9
  * - Markdown preview with table support
10 10
  * - Edit mode with syntax highlighting
11
- * - Export to Word
11
+ * - Export to Word, PDF and Markdown
12 12
  * - Close button to return to empty state
13 13
  *
14 14
  * @module components/EditorPanel
@@ -27,6 +27,7 @@ import {
27 27
 } from '@ant-design/icons';
28 28
 import { getDocumentContent } from '../../services/documentService';
29 29
 import { exportToWord } from '../../services/exportService';
30
+import apiClient from '../../services/api';
30 31
 import { WYSIWYGEditor } from './WYSIWYGEditor';
31 32
 
32 33
 // ── Styles ──────────────────────────────────────────────────────────────────
@@ -272,29 +273,41 @@ export const EditorPanel: React.FC<EditorPanelProps> = ({
272 273
       
273 274
       const result = await exportToWord({
274 275
         documentId,
275
-        // styleId: null,  // 后端暂不支持样式ID(阶段 1 功能)
276
+        styleId: null,  // 使用默认样式
276 277
       });
277 278
       
278 279
       message.success(`导出成功: ${result.fileName}`);
279 280
       
280
-      // 直接触发浏览器下载,而不是在新标签页打开
281
-      if (result.downloadUrl) {
282
-        // 创建隐藏的 a 标签来触发下载
281
+      // Use apiClient to download (no mixed content warning)
282
+      try {
283
+        const response = await apiClient.get(
284
+          `/api/v1/export/records/${result.recordId}/download`,
285
+          {
286
+            params: { userId: 'default-user' }, // TODO: Get actual userId
287
+            responseType: 'blob',
288
+          }
289
+        );
290
+        
291
+        const blob = new Blob([response.data], { type: 'application/msword' });
292
+        const blobUrl = URL.createObjectURL(blob);
293
+        
283 294
         const link = document.createElement('a');
284
-        link.href = result.downloadUrl;
285
-        link.download = result.fileName; // 设置下载文件名
295
+        link.href = blobUrl;
296
+        link.download = result.fileName;
286 297
         link.style.display = 'none';
287 298
         
288
-        // 添加到 DOM,触发点击,然后移除
289 299
         document.body.appendChild(link);
290 300
         link.click();
291 301
         
292
-        // 延迟移除,确保下载开始
293 302
         setTimeout(() => {
294 303
           document.body.removeChild(link);
304
+          URL.revokeObjectURL(blobUrl);
295 305
         }, 100);
296 306
         
297 307
         console.log('[EditorPanel] Word 文档下载已触发:', result.fileName);
308
+      } catch (downloadError) {
309
+        console.error('[EditorPanel] 下载失败:', downloadError);
310
+        message.error('文件下载失败');
298 311
       }
299 312
     } catch (error) {
300 313
       console.error('[EditorPanel] 导出失败:', error);

+ 33 - 4
src/components/MarkdownPreview/MarkdownPreview.tsx

@@ -27,6 +27,7 @@ import rehypeRaw from 'rehype-raw';
27 27
 import rehypeSanitize from 'rehype-sanitize';
28 28
 import { getDocumentContent, updateDocument } from '../../services/documentService';
29 29
 import { exportToWord } from '../../services/exportService';
30
+import apiClient from '../../services/api';
30 31
 
31 32
 const { TextArea } = Input;
32 33
 
@@ -354,16 +355,44 @@ export const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
354 355
       
355 356
       const result = await exportToWord({
356 357
         documentId,
357
-        // styleId: null,  // 后端暂不支持样式ID(阶段 1 功能)
358
+        styleId: null,  // 使用默认样式
358 359
       });
359 360
       
360 361
       message.success(`导出成功: ${result.fileName}`);
361 362
       
362
-      // The downloadUrl is now properly formatted with base URL in the service
363
-      if (result.downloadUrl) {
364
-        window.open(result.downloadUrl, '_blank');
363
+      // Use apiClient to download (no mixed content warning)
364
+      try {
365
+        const response = await apiClient.get(
366
+          `/api/v1/export/records/${result.recordId}/download`,
367
+          {
368
+            params: { userId: 'default-user' }, // TODO: Get actual userId
369
+            responseType: 'blob',
370
+          }
371
+        );
372
+        
373
+        const blob = new Blob([response.data], { type: 'application/msword' });
374
+        const blobUrl = URL.createObjectURL(blob);
375
+        
376
+        const link = document.createElement('a');
377
+        link.href = blobUrl;
378
+        link.download = result.fileName;
379
+        link.style.display = 'none';
380
+        
381
+        document.body.appendChild(link);
382
+        link.click();
383
+        
384
+        setTimeout(() => {
385
+          document.body.removeChild(link);
386
+          URL.revokeObjectURL(blobUrl);
387
+        }, 100);
388
+        
389
+        console.log('[MarkdownPreview] Word 文档下载已触发:', result.fileName);
390
+      } catch (downloadError) {
391
+        console.error('[MarkdownPreview] 下载失败:', downloadError);
392
+        message.error('文件下载失败');
365 393
       }
366 394
     } catch (error) {
395
+      console.error('[MarkdownPreview] 导出失败:', error);
367 396
       message.error('导出失败: ' + (error instanceof Error ? error.message : '未知错误'));
368 397
     } finally {
369 398
       setExporting(false);

+ 63 - 8
src/services/exportRecordService.ts

@@ -58,7 +58,32 @@ export const listExportRecords = async (
58 58
       { params }
59 59
     );
60 60
 
61
-    return response.data.data;
61
+    const data = response.data.data;
62
+    
63
+    // Fix any incorrect base URLs in download URLs
64
+    const correctBaseURL = import.meta.env.VITE_API_BASE_URL;
65
+    if (correctBaseURL && data.records) {
66
+      data.records = data.records.map(record => {
67
+        if (record.downloadUrl && record.downloadUrl.startsWith('http')) {
68
+          try {
69
+            const urlObj = new URL(record.downloadUrl);
70
+            const correctUrlObj = new URL(correctBaseURL);
71
+            if (urlObj.origin !== correctUrlObj.origin) {
72
+              console.warn(`[ExportRecord] Replacing origin in record ${record.recordId}: ${urlObj.origin} -> ${correctUrlObj.origin}`);
73
+              return {
74
+                ...record,
75
+                downloadUrl: record.downloadUrl.replace(urlObj.origin, correctUrlObj.origin)
76
+              };
77
+            }
78
+          } catch (e) {
79
+            // Invalid URL, skip
80
+          }
81
+        }
82
+        return record;
83
+      });
84
+    }
85
+
86
+    return data;
62 87
   } catch (error) {
63 88
     const message = getErrorMessage(error);
64 89
     throw new Error(`获取导出记录列表失败: ${message}`, { cause: error });
@@ -89,15 +114,45 @@ export const downloadExportRecord = async (
89 114
   userId: string
90 115
 ): Promise<void> => {
91 116
   try {
92
-    // Construct download URL
93
-    const downloadUrl = `/api/v1/export/records/${recordId}/download?userId=${encodeURIComponent(userId)}`;
117
+    // Use apiClient to download the file (goes through axios with proper config)
118
+    // This avoids mixed content warnings by using relative URLs
119
+    const response = await apiClient.get(
120
+      `/api/v1/export/records/${recordId}/download`,
121
+      {
122
+        params: { userId },
123
+        responseType: 'blob', // Important: tell axios to expect binary data
124
+      }
125
+    );
126
+    
127
+    // Get filename from Content-Disposition header
128
+    let fileName = `export-${recordId}.doc`;
129
+    const contentDisposition = response.headers['content-disposition'];
130
+    if (contentDisposition) {
131
+      const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
132
+      if (match && match[1]) {
133
+        fileName = match[1].replace(/['"]/g, '');
134
+      }
135
+    }
136
+    
137
+    // Create blob and download
138
+    const blob = new Blob([response.data], { type: 'application/msword' });
139
+    const blobUrl = URL.createObjectURL(blob);
140
+    
141
+    const link = document.createElement('a');
142
+    link.href = blobUrl;
143
+    link.download = fileName;
144
+    link.style.display = 'none';
145
+    
146
+    document.body.appendChild(link);
147
+    link.click();
94 148
     
95
-    // Get base URL from API client
96
-    const baseURL = apiClient.defaults.baseURL || '';
97
-    const fullUrl = `${baseURL}${downloadUrl}`;
149
+    // Clean up
150
+    setTimeout(() => {
151
+      document.body.removeChild(link);
152
+      URL.revokeObjectURL(blobUrl);
153
+    }, 100);
98 154
     
99
-    // Open in new window/tab to trigger download
100
-    window.open(fullUrl, '_blank');
155
+    console.log('[ExportRecord] Download completed:', fileName);
101 156
   } catch (error) {
102 157
     const message = getErrorMessage(error);
103 158
     throw new Error(`下载文件失败: ${message}`, { cause: error });

+ 19 - 4
src/services/exportService.ts

@@ -81,10 +81,25 @@ export const exportToWord = async (
81 81
       console.warn('[Export Service] Warning:', data.warning);
82 82
     }
83 83
     
84
-    // Ensure downloadUrl is properly formatted
85
-    if (data.downloadUrl && !data.downloadUrl.startsWith('http')) {
86
-      const baseURL = apiClient.defaults.baseURL || '';
87
-      data.downloadUrl = `${baseURL}${data.downloadUrl}`;
84
+    // Ensure downloadUrl is properly formatted and uses correct base URL
85
+    if (data.downloadUrl) {
86
+      // If URL doesn't start with http, prepend base URL
87
+      if (!data.downloadUrl.startsWith('http')) {
88
+        const baseURL = apiClient.defaults.baseURL || import.meta.env.VITE_API_BASE_URL || '';
89
+        data.downloadUrl = `${baseURL}${data.downloadUrl}`;
90
+      } else {
91
+        // If URL contains wrong IP, replace it with correct one from env
92
+        const correctBaseURL = import.meta.env.VITE_API_BASE_URL;
93
+        if (correctBaseURL) {
94
+          // Replace any base URL in the download URL with the correct one
95
+          const urlObj = new URL(data.downloadUrl);
96
+          const correctUrlObj = new URL(correctBaseURL);
97
+          if (urlObj.origin !== correctUrlObj.origin) {
98
+            console.warn(`[Export Service] Replacing origin ${urlObj.origin} with ${correctUrlObj.origin}`);
99
+            data.downloadUrl = data.downloadUrl.replace(urlObj.origin, correctUrlObj.origin);
100
+          }
101
+        }
102
+      }
88 103
     }
89 104
     
90 105
     console.log('[Export Service] Export successful:', {