Procházet zdrojové kódy

feat(editor): Add document caching and display name propagation

- Implement localStorage-based document caching with deduplication in MessageItem
- Add document existence verification to prevent stale cached references
- Propagate documentName prop through EditorWithOutline to BlockEditor component
- Update MainToolbar to display documentId and track modification state with visual indicator
- Add animated dot indicator for unsaved changes in toolbar
- Improve document title priority: prop name > store title > default fallback
- Enhance error messaging and logging in document preview workflow
- This reduces redundant API calls for frequently previewed documents while maintaining data integrity
Zhang Yice před 1 měsícem
rodič
revize
da327e8db0

+ 49 - 15
src/components/ChatPanel/MessageItem.tsx

@@ -142,13 +142,14 @@ const MessageItem: React.FC<MessageItemProps> = memo(
142 142
     /**
143 143
      * Handle preview document click
144 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.
145
+     * This function ensures the document exists in the document management
146
+     * system before opening the editor. It implements smart deduplication:
148 147
      * 
149 148
      * Steps:
150
-     * 1. Call POST /api/v1/documents to create/import the document
151
-     * 2. Use the returned documentId to open the editor
149
+     * 1. Check localStorage cache for existing documentId
150
+     * 2. If cached, verify document still exists
151
+     * 3. If not cached or deleted, create new document via POST
152
+     * 4. Cache the documentId and open editor
152 153
      */
153 154
     const handlePreviewClick = useCallback(async () => {
154 155
       if (!exportRecord || !onPreviewDocument || isCreatingDocument) return;
@@ -156,19 +157,52 @@ const MessageItem: React.FC<MessageItemProps> = memo(
156 157
       try {
157 158
         setIsCreatingDocument(true);
158 159
         
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
-        });
160
+        // Step 1: Check localStorage cache
161
+        const { default: apiClient } = await import('../../services/api');
162
+        const cacheKey = `doc_cache_${currentSessionId}_${exportRecord.recordId}`;
163
+        const cachedDocId = localStorage.getItem(cacheKey);
166 164
         
167
-        // Now we can open the preview with the document ID
168
-        onPreviewDocument(response.documentId, exportRecord.fileName);
165
+        let documentId: string;
166
+        
167
+        if (cachedDocId) {
168
+          // Step 2: Verify cached document still exists
169
+          try {
170
+            await apiClient.get(`/api/v1/documents/${cachedDocId}`);
171
+            // Document exists, reuse it
172
+            documentId = cachedDocId;
173
+          } catch {
174
+            // Document no longer exists, clear cache and create new
175
+            localStorage.removeItem(cacheKey);
176
+            
177
+            // Create new document
178
+            const response = await createDocument({
179
+              userId: 'default-user', // TODO: Get from auth context
180
+              fileUrl: exportRecord.downloadUrl,
181
+              sessionId: currentSessionId || 'default-session',
182
+            });
183
+            
184
+            documentId = response.documentId;
185
+            localStorage.setItem(cacheKey, documentId);
186
+          }
187
+        } else {
188
+          // Step 3: No cache, create new document
189
+          const response = await createDocument({
190
+            userId: 'default-user', // TODO: Get from auth context
191
+            fileUrl: exportRecord.downloadUrl,
192
+            sessionId: currentSessionId || 'default-session',
193
+          });
194
+          
195
+          documentId = response.documentId;
196
+          
197
+          // Step 4: Cache the mapping
198
+          localStorage.setItem(cacheKey, documentId);
199
+        }
200
+        
201
+        // Step 5: Open the preview with the document ID
202
+        onPreviewDocument(documentId, exportRecord.fileName);
169 203
         
170 204
       } catch (error) {
171
-        console.error('Failed to create document:', error);
205
+        console.error('Failed to open document:', error);
172 206
         antdMessage.error('打开文档失败: ' + (error instanceof Error ? error.message : '未知错误'));
173 207
       } finally {
174 208
         setIsCreatingDocument(false);

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

@@ -21,6 +21,8 @@ import './BlockEditor.css';
21 21
 export interface BlockEditorProps {
22 22
   /** 文档ID */
23 23
   documentId: string;
24
+  /** 文档名称(用于显示在工具栏,优先级高于从blocks提取的标题) */
25
+  documentName?: string | null;
24 26
   /** 是否只读模式 */
25 27
   readOnly?: boolean;
26 28
   /** 关闭回调 */
@@ -38,12 +40,14 @@ export interface BlockEditorProps {
38 40
  * ```tsx
39 41
  * <BlockEditor 
40 42
  *   documentId="doc-123"
43
+ *   documentName="地质报告.docx"
41 44
  *   onClose={() => handleClose()}
42 45
  * />
43 46
  * ```
44 47
  */
45 48
 export const BlockEditor: React.FC<BlockEditorProps> = ({
46 49
   documentId,
50
+  documentName,
47 51
   readOnly = false,
48 52
   onClose,
49 53
 }) => {
@@ -57,6 +61,10 @@ export const BlockEditor: React.FC<BlockEditorProps> = ({
57 61
     saveDocument,
58 62
   } = useEditorStore();
59 63
 
64
+  // ── Determine display title ──────────────────────────────────────────────
65
+  // Priority: 1. documentName prop (from chat), 2. documentTitle from store, 3. default
66
+  const displayTitle = documentName || documentTitle || '未命名文档';
67
+
60 68
   // ── Load document ──────────────────────────────────────────────────────────
61 69
   useEffect(() => {
62 70
     if (documentId) {
@@ -105,7 +113,8 @@ export const BlockEditor: React.FC<BlockEditorProps> = ({
105 113
     <div className="block-editor" data-testid="block-editor">
106 114
       {/* 工具栏 */}
107 115
       <MainToolbar
108
-        documentTitle={documentTitle}
116
+        documentTitle={displayTitle}
117
+        documentId={documentId}
109 118
         readOnly={readOnly}
110 119
         onSave={handleSave}
111 120
         onClose={onClose}

+ 8 - 1
src/components/Editor/EditorWithOutline.tsx

@@ -22,6 +22,8 @@ export interface EditorWithOutlineProps {
22 22
   defaultShowOutline?: boolean;
23 23
   /** 文档ID */
24 24
   documentId?: string;
25
+  /** 文档名称(用于显示在工具栏) */
26
+  documentName?: string | null;
25 27
   /** 关闭回调 */
26 28
   onClose?: () => void;
27 29
 }
@@ -36,6 +38,7 @@ export interface EditorWithOutlineProps {
36 38
 export const EditorWithOutline: React.FC<EditorWithOutlineProps> = ({
37 39
   defaultShowOutline = true,
38 40
   documentId,
41
+  documentName,
39 42
   onClose,
40 43
 }) => {
41 44
   const [showOutline, setShowOutline] = useState(defaultShowOutline);
@@ -59,7 +62,11 @@ export const EditorWithOutline: React.FC<EditorWithOutlineProps> = ({
59 62
 
60 63
       {/* 主编辑器 */}
61 64
       <div className={`editor-main ${showOutline ? 'with-outline' : ''}`}>
62
-        <BlockEditor documentId={documentId || ''} onClose={onClose} />
65
+        <BlockEditor 
66
+          documentId={documentId || ''} 
67
+          documentName={documentName}
68
+          onClose={onClose} 
69
+        />
63 70
       </div>
64 71
 
65 72
       {/* 文档大纲 */}

+ 27 - 0
src/components/Editor/toolbar/MainToolbar.css

@@ -36,3 +36,30 @@
36 36
   text-overflow: ellipsis;
37 37
   white-space: nowrap;
38 38
 }
39
+
40
+/* 修改状态标识 */
41
+.modified-indicator {
42
+  display: inline-flex;
43
+  align-items: center;
44
+  gap: 4px;
45
+  font-size: 12px;
46
+  color: #faad14;
47
+  margin-left: 8px;
48
+}
49
+
50
+.modified-indicator .dot {
51
+  width: 6px;
52
+  height: 6px;
53
+  border-radius: 50%;
54
+  background-color: #faad14;
55
+  animation: pulse 2s ease-in-out infinite;
56
+}
57
+
58
+@keyframes pulse {
59
+  0%, 100% {
60
+    opacity: 1;
61
+  }
62
+  50% {
63
+    opacity: 0.5;
64
+  }
65
+}

+ 100 - 17
src/components/Editor/toolbar/MainToolbar.tsx

@@ -6,8 +6,8 @@
6 6
  * @module components/Editor/toolbar
7 7
  */
8 8
 
9
-import React from 'react';
10
-import { Button, Dropdown, Divider } from 'antd';
9
+import React, { useState } from 'react';
10
+import { Button, Dropdown, Divider, message } from 'antd';
11 11
 import type { MenuProps } from 'antd';
12 12
 import {
13 13
   SaveOutlined,
@@ -17,7 +17,11 @@ import {
17 17
   FilePdfOutlined,
18 18
   FileMarkdownOutlined,
19 19
   DownOutlined,
20
+  LoadingOutlined,
20 21
 } from '@ant-design/icons';
22
+import { useEditorStore } from '../../../stores/editorStore';
23
+import { exportToWord } from '../../../services/exportService';
24
+import { downloadExportRecord } from '../../../services/exportRecordService';
21 25
 import './MainToolbar.css';
22 26
 
23 27
 // ══════════════════════════════════════════════════════════════════════════════
@@ -27,6 +31,8 @@ import './MainToolbar.css';
27 31
 export interface MainToolbarProps {
28 32
   /** 文档标题 */
29 33
   documentTitle: string;
34
+  /** 文档ID */
35
+  documentId: string;
30 36
   /** 是否只读 */
31 37
   readOnly?: boolean;
32 38
   /** 保存回调 */
@@ -44,38 +50,100 @@ export interface MainToolbarProps {
44 50
  */
45 51
 export const MainToolbar: React.FC<MainToolbarProps> = ({
46 52
   documentTitle,
53
+  documentId,
47 54
   readOnly = false,
48 55
   onSave,
49 56
   onClose,
50 57
 }) => {
58
+  // ── State ──────────────────────────────────────────────────────────────────
59
+  const [isExporting, setIsExporting] = useState(false);
60
+  
61
+  // ── Editor Store ───────────────────────────────────────────────────────────
62
+  // 获取文档修改状态和保存状态
63
+  const { hasModified, isSaving } = useEditorStore((state) => ({
64
+    hasModified: state.hasModified,
65
+    isSaving: state.isSaving,
66
+  }));
67
+
68
+  // ── Export handlers ────────────────────────────────────────────────────────
69
+
70
+  /**
71
+   * 导出为Word文档
72
+   */
73
+  const handleExportWord = async () => {
74
+    if (!documentId) {
75
+      message.error('无法导出:文档ID不存在');
76
+      return;
77
+    }
78
+
79
+    setIsExporting(true);
80
+    const hideLoading = message.loading('正在导出Word文档...', 0);
81
+
82
+    try {
83
+      // 1. 调用导出API
84
+      const response = await exportToWord({
85
+        documentId,
86
+        styleId: null, // 使用默认样式
87
+      });
88
+
89
+      hideLoading();
90
+
91
+      // 2. 显示导出成功提示
92
+      if (response.warning) {
93
+        message.warning(response.warning);
94
+      } else {
95
+        message.success('导出成功!');
96
+      }
97
+
98
+      // 3. 触发下载
99
+      // 使用downloadExportRecord来触发浏览器下载
100
+      const userId = 'default-user'; // TODO: 从认证上下文获取
101
+      await downloadExportRecord(response.recordId, userId);
102
+    } catch (error: any) {
103
+      hideLoading();
104
+      console.error('Export to Word failed:', error);
105
+      message.error(error.message || '导出Word文档失败');
106
+    } finally {
107
+      setIsExporting(false);
108
+    }
109
+  };
110
+
111
+  /**
112
+   * 导出为PDF文档(暂未实现)
113
+   */
114
+  const handleExportPDF = () => {
115
+    message.info('PDF导出功能即将上线');
116
+  };
117
+
118
+  /**
119
+   * 导出为Markdown文档(暂未实现)
120
+   */
121
+  const handleExportMarkdown = () => {
122
+    message.info('Markdown导出功能即将上线');
123
+  };
124
+
51 125
   // 导出菜单
52 126
   const exportMenuItems: MenuProps['items'] = [
53 127
     {
54 128
       key: 'word',
55 129
       icon: <FileWordOutlined />,
56 130
       label: 'Word',
57
-      onClick: () => {
58
-        // TODO: 实现Word导出
59
-        console.log('导出Word');
60
-      },
131
+      disabled: isExporting,
132
+      onClick: handleExportWord,
61 133
     },
62 134
     {
63 135
       key: 'pdf',
64 136
       icon: <FilePdfOutlined />,
65 137
       label: 'PDF',
66
-      onClick: () => {
67
-        // TODO: 实现PDF导出
68
-        console.log('导出PDF');
69
-      },
138
+      disabled: true,
139
+      onClick: handleExportPDF,
70 140
     },
71 141
     {
72 142
       key: 'markdown',
73 143
       icon: <FileMarkdownOutlined />,
74 144
       label: 'Markdown',
75
-      onClick: () => {
76
-        // TODO: 实现Markdown导出
77
-        console.log('导出Markdown');
78
-      },
145
+      disabled: true,
146
+      onClick: handleExportMarkdown,
79 147
     },
80 148
   ];
81 149
 
@@ -86,6 +154,13 @@ export const MainToolbar: React.FC<MainToolbarProps> = ({
86 154
         <span className="document-title" title={documentTitle}>
87 155
           {documentTitle || '未命名文档'}
88 156
         </span>
157
+        {/* 修改状态指示 */}
158
+        {hasModified && (
159
+          <span className="modified-indicator">
160
+            <span className="dot"></span>
161
+            <span>未保存</span>
162
+          </span>
163
+        )}
89 164
       </div>
90 165
 
91 166
       {/* 右侧 - 操作按钮 */}
@@ -96,8 +171,11 @@ export const MainToolbar: React.FC<MainToolbarProps> = ({
96 171
             <Button
97 172
               type="primary"
98 173
               size="small"
99
-              icon={<SaveOutlined />}
174
+              icon={isSaving ? <LoadingOutlined /> : <SaveOutlined />}
100 175
               onClick={onSave}
176
+              disabled={!hasModified || isSaving}
177
+              loading={isSaving}
178
+              title={hasModified ? '文档已修改,点击保存' : '文档未修改'}
101 179
             >
102 180
               保存
103 181
             </Button>
@@ -105,8 +183,13 @@ export const MainToolbar: React.FC<MainToolbarProps> = ({
105 183
         )}
106 184
 
107 185
         {/* 导出 */}
108
-        <Dropdown menu={{ items: exportMenuItems }} placement="bottomRight">
109
-          <Button type="default" size="small" icon={<DownloadOutlined />}>
186
+        <Dropdown menu={{ items: exportMenuItems }} placement="bottomRight" disabled={isExporting}>
187
+          <Button 
188
+            type="default" 
189
+            size="small" 
190
+            icon={isExporting ? <LoadingOutlined /> : <DownloadOutlined />}
191
+            loading={isExporting}
192
+          >
110 193
             导出 <DownOutlined />
111 194
           </Button>
112 195
         </Dropdown>

+ 2 - 0
src/components/EditorPanel/EditorPanel.tsx

@@ -50,6 +50,7 @@ export interface EditorPanelProps {
50 50
  */
51 51
 export const EditorPanel: React.FC<EditorPanelProps> = ({
52 52
   documentId,
53
+  initialDocumentName,
53 54
   onClose,
54 55
 }) => {
55 56
   return (
@@ -57,6 +58,7 @@ export const EditorPanel: React.FC<EditorPanelProps> = ({
57 58
       {/* 集成带大纲的编辑器 */}
58 59
       <EditorWithOutline
59 60
         documentId={documentId}
61
+        documentName={initialDocumentName}
60 62
         defaultShowOutline={true}
61 63
         onClose={onClose}
62 64
       />

+ 56 - 1
src/stores/editorStore.ts

@@ -35,6 +35,12 @@ interface EditorStore {
35 35
   isSaving: boolean;
36 36
   error: string | null;
37 37
   
38
+  // ── 修改状态追踪 ────────────────────────────────────────────────────────
39
+  /** 文档是否已被修改(用于控制保存按钮状态) */
40
+  hasModified: boolean;
41
+  /** 原始blocks快照(用于检测变化) */
42
+  originalBlocksSnapshot: string | null;
43
+  
38 44
   // ── 操作方法 ────────────────────────────────────────────────────────────
39 45
   
40 46
   /**
@@ -62,6 +68,16 @@ interface EditorStore {
62 68
   updateBlock: (id: string, updates: BlockUpdate) => void;
63 69
   
64 70
   /**
71
+   * 标记文档已修改
72
+   */
73
+  markAsModified: () => void;
74
+  
75
+  /**
76
+   * 标记文档已保存
77
+   */
78
+  markAsSaved: () => void;
79
+  
80
+  /**
65 81
    * 删除块
66 82
    * @param id 块ID
67 83
    */
@@ -162,6 +178,8 @@ const initialState = {
162 178
   isLoading: false,
163 179
   isSaving: false,
164 180
   error: null,
181
+  hasModified: false,
182
+  originalBlocksSnapshot: null,
165 183
 };
166 184
 
167 185
 export const useEditorStore = create<EditorStore>((set, get) => ({
@@ -174,11 +192,16 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
174 192
     try {
175 193
       const data = await blockService.getBlocks(documentId);
176 194
       
195
+      // 保存原始blocks快照,用于检测修改
196
+      const snapshot = JSON.stringify(data.blocks);
197
+      
177 198
       set({
178 199
         documentId,
179 200
         documentTitle: '未命名文档', // 后端不返回title,使用默认值
180 201
         blocks: data.blocks,
181 202
         isLoading: false,
203
+        hasModified: false, // 初始状态为未修改
204
+        originalBlocksSnapshot: snapshot,
182 205
       });
183 206
     } catch (error: any) {
184 207
       set({
@@ -212,7 +235,13 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
212 235
         });
213 236
       }
214 237
       
215
-      set({ isSaving: false });
238
+      // 保存成功后,更新快照并标记为未修改
239
+      const snapshot = JSON.stringify(blocks);
240
+      set({ 
241
+        isSaving: false,
242
+        hasModified: false,
243
+        originalBlocksSnapshot: snapshot,
244
+      });
216 245
     } catch (error: any) {
217 246
       set({
218 247
         error: error.message || '保存失败',
@@ -254,6 +283,8 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
254 283
           { ...partialBlock, block_order: 0 } as DocumentBlock,
255 284
         ]);
256 285
         set({ blocks: newBlocks });
286
+        // 标记文档已修改
287
+        get().markAsModified();
257 288
         return;
258 289
       }
259 290
     }
@@ -276,6 +307,9 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
276 307
     );
277 308
     
278 309
     set({ blocks: newBlocks });
310
+    
311
+    // 标记文档已修改
312
+    get().markAsModified();
279 313
   },
280 314
 
281 315
   // ── updateBlock ─────────────────────────────────────────────────────────
@@ -287,6 +321,9 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
287 321
     );
288 322
     
289 323
     set({ blocks: newBlocks });
324
+    
325
+    // 标记文档已修改
326
+    get().markAsModified();
290 327
   },
291 328
 
292 329
   // ── deleteBlock ─────────────────────────────────────────────────────────
@@ -294,6 +331,9 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
294 331
     const { blocks } = get();
295 332
     const newBlocks = blocks.filter((block) => block.id !== id);
296 333
     set({ blocks: newBlocks });
334
+    
335
+    // 标记文档已修改
336
+    get().markAsModified();
297 337
   },
298 338
 
299 339
   // ── moveBlock ───────────────────────────────────────────────────────────
@@ -357,6 +397,21 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
357 397
     }
358 398
   },
359 399
   
400
+  // ── markAsModified ──────────────────────────────────────────────────────
401
+  markAsModified: () => {
402
+    set({ hasModified: true });
403
+  },
404
+  
405
+  // ── markAsSaved ─────────────────────────────────────────────────────────
406
+  markAsSaved: () => {
407
+    const { blocks } = get();
408
+    const snapshot = JSON.stringify(blocks);
409
+    set({ 
410
+      hasModified: false,
411
+      originalBlocksSnapshot: snapshot,
412
+    });
413
+  },
414
+  
360 415
   // ── recomputeBlockOrders ────────────────────────────────────────────────
361 416
   recomputeBlockOrders: () => {
362 417
     const { blocks } = get();