Переглянути джерело

feat(chat-export): Add document creation workflow for exported messages

- Integrate document store with MessageItem to create/import documents on preview
- Handle multiple response formats from workflow service (nested, direct, single record)
- Add loading state and spinner overlay during document creation
- Implement automatic document import using export record download URL
- Enhance workflowService to parse and standardize export record metadata
- Update BlockEditor loading spinner with proper padding for better UX
- Add user-friendly success message when documents are generated
- Provide fallback documentId mapping for records missing explicit documentId field
Zhang Yice 1 місяць тому
батько
коміт
d3cfcefa03

+ 58 - 7
src/components/ChatPanel/MessageItem.tsx

@@ -10,11 +10,13 @@
10 10
  * @module components/ChatPanel/MessageItem
11 11
  */
12 12
 
13
-import React, { memo, useCallback } from 'react';
14
-import { Typography, Card } from 'antd';
15
-import { FileTextOutlined, DownloadOutlined } from '@ant-design/icons';
13
+import React, { memo, useCallback, useState } from 'react';
14
+import { Typography, Card, message as antdMessage, Spin } from 'antd';
15
+import { FileTextOutlined, DownloadOutlined, LoadingOutlined } from '@ant-design/icons';
16 16
 import { formatDate } from '../../utils/formatDate';
17 17
 import type { ChatMessage } from '../../types/chat';
18
+import { useDocumentStore } from '../../stores/documentStore';
19
+import { useChatStore } from '../../stores/chatStore';
18 20
 
19 21
 const { Text } = Typography;
20 22
 
@@ -56,6 +58,7 @@ const exportCardStyle: React.CSSProperties = {
56 58
   marginTop: '8px',
57 59
   cursor: 'pointer',
58 60
   transition: 'all 0.2s',
61
+  position: 'relative',
59 62
 };
60 63
 
61 64
 const exportCardBodyStyle: React.CSSProperties = {
@@ -132,15 +135,45 @@ export interface MessageItemProps {
132 135
 const MessageItem: React.FC<MessageItemProps> = memo(
133 136
   ({ message, onPreviewDocument, className }) => {
134 137
     const { role, content, timestamp, exportRecord } = message;
138
+    const [isCreatingDocument, setIsCreatingDocument] = useState(false);
139
+    const createDocument = useDocumentStore((state) => state.createDocument);
140
+    const currentSessionId = useChatStore((state) => state.currentSessionId);
135 141
 
136 142
     /**
137 143
      * Handle preview document click
144
+     * 
145
+     * This function needs to ensure the document exists in the document management
146
+     * system before opening the editor. The workflow returns an export record with
147
+     * a recordId and downloadUrl, but the document hasn't been imported yet.
148
+     * 
149
+     * Steps:
150
+     * 1. Call POST /api/v1/documents to create/import the document
151
+     * 2. Use the returned documentId to open the editor
138 152
      */
139
-    const handlePreviewClick = useCallback(() => {
140
-      if (exportRecord && onPreviewDocument) {
141
-        onPreviewDocument(exportRecord.documentId, exportRecord.fileName);
153
+    const handlePreviewClick = useCallback(async () => {
154
+      if (!exportRecord || !onPreviewDocument || isCreatingDocument) return;
155
+
156
+      try {
157
+        setIsCreatingDocument(true);
158
+        
159
+        // Create/import document using the export record's download URL
160
+        // The backend will download the file and parse it into blocks
161
+        const response = await createDocument({
162
+          userId: 'default-user', // TODO: Get from auth context
163
+          fileUrl: exportRecord.downloadUrl,
164
+          sessionId: currentSessionId || 'default-session',
165
+        });
166
+        
167
+        // Now we can open the preview with the document ID
168
+        onPreviewDocument(response.documentId, exportRecord.fileName);
169
+        
170
+      } catch (error) {
171
+        console.error('Failed to create document:', error);
172
+        antdMessage.error('打开文档失败: ' + (error instanceof Error ? error.message : '未知错误'));
173
+      } finally {
174
+        setIsCreatingDocument(false);
142 175
       }
143
-    }, [exportRecord, onPreviewDocument]);
176
+    }, [exportRecord, onPreviewDocument, isCreatingDocument, createDocument, currentSessionId]);
144 177
 
145 178
     /**
146 179
      * Handle download document click
@@ -226,6 +259,24 @@ const MessageItem: React.FC<MessageItemProps> = memo(
226 259
             onClick={handlePreviewClick}
227 260
             data-testid="export-record-card"
228 261
           >
262
+            {isCreatingDocument && (
263
+              <div
264
+                style={{
265
+                  position: 'absolute',
266
+                  top: 0,
267
+                  left: 0,
268
+                  right: 0,
269
+                  bottom: 0,
270
+                  display: 'flex',
271
+                  alignItems: 'center',
272
+                  justifyContent: 'center',
273
+                  backgroundColor: 'rgba(255, 255, 255, 0.8)',
274
+                  zIndex: 1,
275
+                }}
276
+              >
277
+                <Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />
278
+              </div>
279
+            )}
229 280
             <div style={exportCardHeaderStyle}>
230 281
               <FileTextOutlined style={exportCardIconStyle} />
231 282
               <div style={exportCardTitleStyle}>{exportRecord.fileName}</div>

+ 3 - 1
src/components/Editor/BlockEditor.tsx

@@ -82,7 +82,9 @@ export const BlockEditor: React.FC<BlockEditorProps> = ({
82 82
   if (isLoading) {
83 83
     return (
84 84
       <div className="block-editor-loading">
85
-        <Spin size="large" tip="加载文档中..." />
85
+        <Spin size="large" tip="加载文档中...">
86
+          <div style={{ padding: '50px' }} />
87
+        </Spin>
86 88
       </div>
87 89
     );
88 90
   }

+ 51 - 6
src/services/workflowService.ts

@@ -203,21 +203,64 @@ export const triggerDocumentWorkflow = async (
203 203
       try {
204 204
         const parsedContent = JSON.parse(content);
205 205
         
206
-        // If parsed content has records array
207
-        if (parsedContent.records && Array.isArray(parsedContent.records) && parsedContent.records.length > 0) {
206
+        // Handle nested response format: { code: 0, data: { records: [...] } }
207
+        if (parsedContent.code === 0 && parsedContent.data?.records && Array.isArray(parsedContent.data.records)) {
208
+          const records = parsedContent.data.records;
209
+          if (records.length > 0) {
210
+            const record = records[0];
211
+            exportRecord = {
212
+              recordId: record.recordId,
213
+              fileName: record.fileName || '导出文档.docx',
214
+              downloadUrl: record.downloadUrl,
215
+              documentId: record.documentId || record.recordId,
216
+            };
217
+            
218
+            // Update content to be more user-friendly
219
+            content = '✅ 文档已生成,点击下方卡片预览或下载';
220
+          }
221
+        }
222
+        // Handle direct records array format
223
+        else if (parsedContent.records && Array.isArray(parsedContent.records) && parsedContent.records.length > 0) {
208 224
           const record = parsedContent.records[0];
209 225
           exportRecord = {
210 226
             recordId: record.recordId,
211
-            fileName: record.fileName,
227
+            fileName: record.fileName || '导出文档.docx',
228
+            downloadUrl: record.downloadUrl,
229
+            documentId: record.documentId || record.recordId,
230
+          };
231
+          
232
+          // Update content to be more user-friendly
233
+          content = '✅ 文档已生成,点击下方卡片预览或下载';
234
+        }
235
+        // Handle single record object format (not wrapped in array)
236
+        else if (parsedContent.recordId && parsedContent.downloadUrl) {
237
+          exportRecord = {
238
+            recordId: parsedContent.recordId,
239
+            fileName: parsedContent.fileName || '导出文档.docx',
240
+            downloadUrl: parsedContent.downloadUrl,
241
+            documentId: parsedContent.documentId || parsedContent.recordId,
242
+          };
243
+          
244
+          // Update content to be more user-friendly
245
+          content = '✅ 文档已生成,点击下方卡片预览或下载';
246
+        }
247
+        // Handle nested data format with single record
248
+        else if (parsedContent.data && parsedContent.data.recordId) {
249
+          const record = parsedContent.data;
250
+          exportRecord = {
251
+            recordId: record.recordId,
252
+            fileName: record.fileName || '导出文档.docx',
212 253
             downloadUrl: record.downloadUrl,
213
-            documentId: record.documentId,
254
+            documentId: record.documentId || record.recordId,
214 255
           };
215 256
           
216 257
           // Update content to be more user-friendly
217
-          content = '✅ 文档已生成,请查看下方链接';
258
+          content = '✅ 文档已生成,点击下方卡片预览或下载';
218 259
         }
219 260
       } catch (e) {
220
-        // Content looks like JSON but failed to parse
261
+        // Content looks like JSON but failed to parse, log for debugging
262
+        console.warn('Failed to parse JSON content:', e);
263
+        console.warn('Content:', content);
221 264
       }
222 265
     }
223 266
 
@@ -230,6 +273,7 @@ export const triggerDocumentWorkflow = async (
230 273
         downloadUrl: data.exportRecord.downloadUrl,
231 274
         documentId: data.exportRecord.documentId,
232 275
       };
276
+      content = '✅ 文档已生成,点击下方卡片预览或下载';
233 277
     } else if (!exportRecord && data.records && data.records.length > 0) {
234 278
       // Records array format (take the first one)
235 279
       const record = data.records[0];
@@ -239,6 +283,7 @@ export const triggerDocumentWorkflow = async (
239 283
         downloadUrl: record.downloadUrl,
240 284
         documentId: record.documentId,
241 285
       };
286
+      content = '✅ 文档已生成,点击下方卡片预览或下载';
242 287
     }
243 288
     
244 289
     return {

+ 4 - 0
src/stores/documentStore.ts

@@ -70,6 +70,7 @@ export const useDocumentStore = create<DocumentStoreState>((set, get) => ({
70 70
    * Save status is set to 'saved' after successful creation.
71 71
    *
72 72
    * @param req - Document creation request (userId, fileUrl, sessionId)
73
+   * @returns The created document with its ID
73 74
    * @throws {Error} When document creation fails
74 75
    */
75 76
   createDocument: async (req: CreateDocumentRequest) => {
@@ -90,6 +91,9 @@ export const useDocumentStore = create<DocumentStoreState>((set, get) => ({
90 91
 
91 92
       // Refresh document list to include the new document
92 93
       await get().fetchDocumentList();
94
+      
95
+      // Return the created document info
96
+      return { documentId: response.documentId, document: fullDocument };
93 97
     } catch (error) {
94 98
       set({ saveStatus: 'error' });
95 99
       throw error;

+ 2 - 2
src/types/store.ts

@@ -25,8 +25,8 @@ export interface DocumentStoreState {
25 25
   saveStatus: SaveStatus;
26 26
 
27 27
   // Actions
28
-  /** Create a new document */
29
-  createDocument: (req: CreateDocumentRequest) => Promise<void>;
28
+  /** Create a new document and return its ID */
29
+  createDocument: (req: CreateDocumentRequest) => Promise<{ documentId: string; document: Document }>;
30 30
   /** Fetch a document by ID */
31 31
   fetchDocument: (id: string, includeBlocks?: boolean) => Promise<void>;
32 32
   /** Fetch document list with optional filters */