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

feat: 增强 webMcpService,新增内容校验与区块创建事件
- 新增 `requiredContent` 函数,用于校验区块输入内容
- 定义 `BLOCK_CREATED_EVENT`,区块创建成功后派发该事件
- 更新区块创建逻辑,接入新增内容校验并触发事件

feat: 在 workflowService 规范化导出链接
- 实现 `normalizeExportDownloadUrl`,将工作流生成的导出地址适配为后端配置域名
- 更新 `triggerDocumentWorkflow`,调用新的地址规范化方法

feat: 在 chatStore 实现活跃会话管理
- 基于 localStorage 实现活跃会话的持久化与读取能力
- 升级会话管理逻辑,保障活跃会话正常加载与保存

refactor: 精简 editorStore,抽离工具函数
- 将区块操作相关工具函数迁移至 `editorStoreHelpers.ts`
- 清理 `editorStore.ts` 冗余代码,提升代码可读性
- 新增 `applyRemoteBlockInsert`,更高效处理远端区块新增操作

chore: 将 editorStore 类型定义拆分至独立文件
- 新建 `editorStoreTypes.ts`,定义编辑器状态、操作相关接口,提升类型安全性与代码整洁度

refactor: 优化区块序列化与更新处理逻辑
- 优化 `editorStoreHelpers.ts` 内区块序列化逻辑,妥善处理区块更新与状态管理
- 整合区块更新合并、序列化相关函数,提升代码可维护性

Zhang Yice 1 місяць тому
батько
коміт
3bcba0877d

+ 37 - 21
src/App.tsx

@@ -25,13 +25,15 @@
25 25
  */
26 26
 
27 27
 import React, { useCallback, useEffect, useState, memo, lazy, Suspense, useMemo } from 'react';
28
-import { ConfigProvider, Alert, Button, Drawer, Spin } from 'antd';
28
+import { ConfigProvider, Alert, Button, Drawer, Spin, message } from 'antd';
29 29
 import { MessageOutlined } from '@ant-design/icons';
30 30
 import zhCN from 'antd/locale/zh_CN';
31 31
 
32 32
 import { ErrorBoundary } from './components/common';
33 33
 import ResizableLayout from './components/Layout/ResizableLayout';
34 34
 import { useUIStore } from './stores/uiStore';
35
+import { getDocument } from './services/documentService';
36
+import { isApiError } from './services/api';
35 37
 
36 38
 // ── Lazy-loaded panel components (Req 13.2, 13.8) ────────────────────────────
37 39
 // These non-first-screen panels are split into separate chunks by Vite,
@@ -73,6 +75,14 @@ const PanelFallback: React.FC = memo(() => (
73 75
 ));
74 76
 PanelFallback.displayName = 'PanelFallback';
75 77
 
78
+const leftPanel = (
79
+  <Suspense fallback={<PanelFallback />}>
80
+    <ChatPanel />
81
+  </Suspense>
82
+);
83
+
84
+const CURRENT_USER_ID = 'default-user';
85
+
76 86
 // ── Styles ────────────────────────────────────────────────────────────────────
77 87
 
78 88
 const appStyle: React.CSSProperties = {
@@ -137,15 +147,34 @@ const App: React.FC = () => {
137 147
 
138 148
   useEffect(() => {
139 149
     const documentId = new URLSearchParams(window.location.search).get('documentId');
140
-    if (documentId) openDocumentPreview(documentId);
141
-  }, [openDocumentPreview]);
150
+    if (!documentId) return;
151
+
152
+    let disposed = false;
153
+    void getDocument(documentId, { includeBlocks: false })
154
+      .then(() => {
155
+        if (!disposed) openDocumentPreview(documentId);
156
+      })
157
+      .catch((error: unknown) => {
158
+        if (disposed) return;
159
+        const url = new URL(window.location.href);
160
+        url.searchParams.delete('documentId');
161
+        window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`);
162
+        closeDocumentPreview();
163
+        message.error(
164
+          isApiError(error) && error.status === 404
165
+            ? '文档不存在,已清理失效的文档链接'
166
+            : '文档加载失败,请稍后重试'
167
+        );
168
+      });
169
+
170
+    return () => {
171
+      disposed = true;
172
+    };
173
+  }, [openDocumentPreview, closeDocumentPreview]);
142 174
 
143 175
   // ── Session history drawer ────────────────────────────────────────────────
144 176
   const [sessionListOpen, setSessionListOpen] = useState(false);
145 177
 
146
-  // ── User ID (in a real app, this would come from auth context) ───────────
147
-  const currentUserId = 'default-user';
148
-
149 178
   /** Stable callback to close the session drawer */
150 179
   const handleCloseSessionList = useCallback(() => setSessionListOpen(false), []);
151 180
   /** Stable callback to open the session drawer */
@@ -159,19 +188,6 @@ const App: React.FC = () => {
159 188
   }, [closeDocumentPreview]);
160 189
 
161 190
   // ── Panels ────────────────────────────────────────────────────────────────
162
-  // Each panel is wrapped in <Suspense> so the lazy chunk loads independently.
163
-  // useMemo ensures the JSX elements are stable references and don't cause
164
-  // Suspense to remount unnecessarily on parent re-renders (Req 13.7).
165
-
166
-  const leftPanel = useMemo(() => {
167
-    // Always show chat panel on the left
168
-    return (
169
-      <Suspense fallback={<PanelFallback />}>
170
-        <ChatPanel />
171
-      </Suspense>
172
-    );
173
-  }, []);
174
-
175 191
   const rightPanel = useMemo(() => {
176 192
     // If a document is being previewed, show EditorPanel
177 193
     // Otherwise show ExportRecordList (default)
@@ -190,10 +206,10 @@ const App: React.FC = () => {
190 206
     // Default: show export records list
191 207
     return (
192 208
       <Suspense fallback={<PanelFallback />}>
193
-        <ExportRecordList userId={currentUserId} />
209
+        <ExportRecordList userId={CURRENT_USER_ID} />
194 210
       </Suspense>
195 211
     );
196
-  }, [previewDocumentId, previewDocumentName, handleCloseEditor, currentUserId]);
212
+  }, [previewDocumentId, previewDocumentName, handleCloseEditor]);
197 213
 
198 214
   // ── Session history drawer ────────────────────────────────────────────────
199 215
   const sessionListDrawer = sessionListOpen ? (

+ 5 - 4
src/components/ChatPanel/MessageItem.tsx

@@ -15,7 +15,7 @@ import { Typography, Card, message as antdMessage, Spin } from 'antd';
15 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';
18
+import { createDocument } from '../../services/documentService';
19 19
 import { useChatStore } from '../../stores/chatStore';
20 20
 import { downloadBlob, getFileNameFromContentDisposition } from '../../utils/download';
21 21
 
@@ -217,7 +217,6 @@ const MessageItem: React.FC<MessageItemProps> = memo(
217 217
   ({ message, onPreviewDocument, className }) => {
218 218
     const { role, content, timestamp, exportRecord } = message;
219 219
     const [isCreatingDocument, setIsCreatingDocument] = useState(false);
220
-    const createDocument = useDocumentStore((state) => state.createDocument);
221 220
     const currentSessionId = useChatStore((state) => state.currentSessionId);
222 221
 
223 222
     /**
@@ -246,7 +245,9 @@ const MessageItem: React.FC<MessageItemProps> = memo(
246 245
         if (cachedDocId) {
247 246
           // Step 2: Verify cached document still exists
248 247
           try {
249
-            await apiClient.get(`/api/v1/documents/${cachedDocId}`);
248
+            await apiClient.get(`/api/v1/documents/${cachedDocId}`, {
249
+              skipErrorNotification: true,
250
+            });
250 251
             // Document exists, reuse it
251 252
             documentId = cachedDocId;
252 253
           } catch (error) {
@@ -291,7 +292,7 @@ const MessageItem: React.FC<MessageItemProps> = memo(
291 292
       } finally {
292 293
         setIsCreatingDocument(false);
293 294
       }
294
-    }, [exportRecord, onPreviewDocument, isCreatingDocument, createDocument, currentSessionId]);
295
+    }, [exportRecord, onPreviewDocument, isCreatingDocument, currentSessionId]);
295 296
 
296 297
     /**
297 298
      * Handle download document click

+ 2 - 8
src/components/Editor/BlockCanvas.tsx

@@ -52,16 +52,10 @@ export const BlockCanvas = React.memo(function BlockCanvas({
52 52
     const sorted = [...blocks].sort((a, b) => a.block_order - b.block_order);
53 53
     let tocFound = false;
54 54
     let headingIndex = -1;
55
-
56
-    sorted.forEach((block, index) => {
57
-      if (block.type === 'toc') tocFound = true;
58
-      if (headingIndex === -1 && block.type === 'heading') {
59
-        headingIndex = index;
60
-      }
61
-    });
62
-
63 55
     const collapsible = new Set<string>();
64 56
     sorted.forEach((block, index) => {
57
+      if (block.type === 'toc') tocFound = true;
58
+      if (headingIndex === -1 && block.type === 'heading') headingIndex = index;
65 59
       if (block.type !== 'heading') return;
66 60
 
67 61
       const nextBlock = sorted[index + 1];

+ 13 - 2
src/components/Editor/BlockEditor.tsx

@@ -11,10 +11,10 @@ import React, { useEffect, useCallback } from 'react';
11 11
 import { Spin, message } from 'antd';
12 12
 import { useShallow } from 'zustand/react/shallow';
13 13
 import { useEditorStore } from '../../stores/editorStore';
14
-import { BLOCK_DELETED_EVENT, BLOCK_UPDATED_EVENT } from '../../services/blockService';
14
+import { BLOCK_CREATED_EVENT, BLOCK_DELETED_EVENT, BLOCK_UPDATED_EVENT } from '../../services/blockService';
15 15
 import { BlockCanvas } from './BlockCanvas';
16 16
 import { MainToolbar } from './toolbar/MainToolbar';
17
-import type { BlockUpdate } from '../../types/editor';
17
+import type { BlockUpdate, DocumentBlock } from '../../types/editor';
18 18
 import './BlockEditor.css';
19 19
 
20 20
 // ══════════════════════════════════════════════════════════════════════════════
@@ -90,6 +90,17 @@ export const BlockEditor: React.FC<BlockEditorProps> = ({
90 90
 
91 91
   // 接收聊天/WebMCP 已保存的块更新,立即同步当前打开的文档。
92 92
   useEffect(() => {
93
+    const handleBlockCreated = (event: Event) => {
94
+      const detail = (event as CustomEvent<{ documentId?: unknown; block?: unknown }>).detail;
95
+      if (detail?.documentId !== documentId || !detail.block || typeof detail.block !== 'object') return;
96
+      useEditorStore.getState().applyRemoteBlockInsert(detail.block as DocumentBlock);
97
+    };
98
+
99
+    window.addEventListener(BLOCK_CREATED_EVENT, handleBlockCreated);
100
+    return () => window.removeEventListener(BLOCK_CREATED_EVENT, handleBlockCreated);
101
+  }, [documentId]);
102
+
103
+  useEffect(() => {
93 104
     const handleBlockUpdated = (event: Event) => {
94 105
       const detail = (event as CustomEvent<{
95 106
         documentId?: unknown;

+ 17 - 16
src/components/Editor/DocumentOutline.tsx

@@ -109,22 +109,23 @@ export const DocumentOutline: React.FC<DocumentOutlineProps> = ({ visible = true
109 109
   const headings = useEditorStore(
110 110
     useShallow((state) => {
111 111
       const seenIds = new Set<string>();
112
-      return state.blocks
113
-        .filter(
114
-          (block): block is HeadingBlock =>
115
-            block.type === 'heading' &&
116
-            typeof block.id === 'string' &&
117
-            block.id.length > 0 &&
118
-            Number.isInteger(block.level) &&
119
-            block.level >= 1 &&
120
-            block.level <= 6
121
-        )
122
-        .sort((left, right) => left.block_order - right.block_order)
123
-        .filter((heading) => {
124
-          if (seenIds.has(heading.id)) return false;
125
-          seenIds.add(heading.id);
126
-          return true;
127
-        });
112
+      const result: HeadingBlock[] = [];
113
+      for (const block of state.blocks) {
114
+        if (
115
+          block.type !== 'heading' ||
116
+          typeof block.id !== 'string' ||
117
+          block.id.length === 0 ||
118
+          !Number.isInteger(block.level) ||
119
+          block.level < 1 ||
120
+          block.level > 6 ||
121
+          seenIds.has(block.id)
122
+        ) {
123
+          continue;
124
+        }
125
+        seenIds.add(block.id);
126
+        result.push(block);
127
+      }
128
+      return result.sort((left, right) => left.block_order - right.block_order);
128 129
     })
129 130
   );
130 131
   const selectedBlockId = useEditorStore((state) => state.selectedBlockId);

+ 4 - 1
src/components/ExportRecordList/ExportRecordList.tsx

@@ -106,15 +106,18 @@ const ExportRecordList: React.FC<ExportRecordListProps> = ({
106 106
 
107 107
   // Fetch records on mount and when page changes
108 108
   useEffect(() => {
109
+    let disposed = false;
109 110
     const refresh = () => {
111
+      if (disposed) return;
110 112
       setLoading(true);
111 113
       void fetchRecords();
112 114
     };
113 115
 
114 116
     window.addEventListener(EXPORT_RECORD_CREATED_EVENT, refresh);
115
-    void fetchRecords();
117
+    queueMicrotask(refresh);
116 118
 
117 119
     return () => {
120
+      disposed = true;
118 121
       window.removeEventListener(EXPORT_RECORD_CREATED_EVENT, refresh);
119 122
     };
120 123
   }, [fetchRecords]);

+ 6 - 0
src/services/api.ts

@@ -17,6 +17,12 @@ import type { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse } from 'a
17 17
 import type { ApiResponse } from '../types/api';
18 18
 import { message } from 'antd';
19 19
 
20
+declare module 'axios' {
21
+  interface AxiosRequestConfig {
22
+    skipErrorNotification?: boolean;
23
+  }
24
+}
25
+
20 26
 /**
21 27
  * Normalized API error structure
22 28
  */

+ 1 - 0
src/services/blockService.ts

@@ -31,6 +31,7 @@ const BLOCKS_BASE_URL = '/api/v1/documents';
31 31
 
32 32
 export const BLOCK_UPDATED_EVENT = 'ax:block-updated';
33 33
 export const BLOCK_DELETED_EVENT = 'ax:block-deleted';
34
+export const BLOCK_CREATED_EVENT = 'ax:block-created';
34 35
 
35 36
 function encodeResourceId(value: string, name: string): string {
36 37
   const hasUnsafeCharacter = Array.from(value).some((character) => {

+ 43 - 5
src/services/documentService.ts

@@ -26,6 +26,34 @@ import type {
26 26
   GetDocumentOptions,
27 27
 } from '../types/document';
28 28
 
29
+const encodeResourceId = (value: string, name: string): string => {
30
+  const hasUnsafeCharacter = Array.from(value).some((character) => {
31
+    const codePoint = character.codePointAt(0) ?? 0;
32
+    return codePoint < 32 || codePoint === 127 || character === '/' || character === '\\';
33
+  });
34
+  if (!value || value.length > 128 || hasUnsafeCharacter) {
35
+    throw new Error(`无效的${name}`);
36
+  }
37
+  return encodeURIComponent(value);
38
+};
39
+
40
+const normalizeExportDownloadUrl = (fileUrl: string): string => {
41
+  const configuredBaseUrl = import.meta.env.VITE_API_BASE_URL;
42
+  if (!configuredBaseUrl || !fileUrl) return fileUrl;
43
+
44
+  try {
45
+    const configuredUrl = new URL(configuredBaseUrl);
46
+    const exportUrl = new URL(fileUrl, configuredUrl);
47
+    if (!exportUrl.pathname.startsWith('/api/v1/export/records/')) return fileUrl;
48
+
49
+    exportUrl.protocol = configuredUrl.protocol;
50
+    exportUrl.host = configuredUrl.host;
51
+    return exportUrl.toString();
52
+  } catch {
53
+    return fileUrl;
54
+  }
55
+};
56
+
29 57
 /**
30 58
  * Create a new document from a Word file URL
31 59
  *
@@ -40,9 +68,14 @@ export const createDocument = async (
40 68
   request: CreateDocumentRequest
41 69
 ): Promise<CreateDocumentResponse> => {
42 70
   try {
71
+    const normalizedRequest = {
72
+      ...request,
73
+      fileUrl: normalizeExportDownloadUrl(request.fileUrl),
74
+    };
43 75
     const response = await apiClient.post<ApiResponse<CreateDocumentResponse>>(
44 76
       '/api/v1/documents',
45
-      request
77
+      normalizedRequest,
78
+      { skipErrorNotification: true }
46 79
     );
47 80
     return response.data.data;
48 81
   } catch (error: unknown) {
@@ -79,9 +112,12 @@ export const getDocument = async (
79 112
       params.includeBlocks = true;
80 113
     }
81 114
 
82
-    const response = await apiClient.get<ApiResponse<Document>>(`/api/v1/documents/${documentId}`, {
83
-      params,
84
-    });
115
+    const response = await apiClient.get<ApiResponse<Document>>(
116
+      `/api/v1/documents/${encodeResourceId(documentId, '文档 ID')}`,
117
+      {
118
+        params,
119
+      }
120
+    );
85 121
     return response.data.data;
86 122
   } catch (error: unknown) {
87 123
     let friendlyMessage = '获取文档失败';
@@ -107,7 +143,9 @@ export const getDocument = async (
107 143
  */
108 144
 export const deleteDocuments = async (sessionId: string): Promise<string> => {
109 145
   try {
110
-    const response = await apiClient.delete<ApiResponse<void>>(`/api/v1/documents/${sessionId}`);
146
+    const response = await apiClient.delete<ApiResponse<void>>(
147
+      `/api/v1/documents/${encodeResourceId(sessionId, '会话 ID')}`
148
+    );
111 149
 
112 150
     const message = response.data.message || 'Documents deleted successfully';
113 151
     return message;

Різницю між файлами не показано, бо вона завелика
+ 609 - 467
src/services/webMcpAgentService.ts


+ 36 - 8
src/services/webMcpService.ts

@@ -6,7 +6,7 @@ import type { BlockType, CreateBlockRequest, UpdateBlockRequest } from '../types
6 6
 import type { DocumentListFilters } from '../types/document';
7 7
 import { useUIStore } from '../stores/uiStore';
8 8
 import { finishWebMcpActivity, startWebMcpActivity } from './webMcpActivityService';
9
-import { BLOCK_DELETED_EVENT, BLOCK_UPDATED_EVENT } from './blockService';
9
+import { BLOCK_CREATED_EVENT, BLOCK_DELETED_EVENT, BLOCK_UPDATED_EVENT } from './blockService';
10 10
 
11 11
 export interface WebMcpToolResult {
12 12
 	ok: boolean;
@@ -81,6 +81,20 @@ const requiredString = (input: Record<string, unknown>, key: string): string =>
81 81
 	return value.trim();
82 82
 };
83 83
 
84
+const requiredContent = (input: Record<string, unknown>, key: string): string | object | unknown[] => {
85
+	const value = input[key];
86
+	if (typeof value === 'string') {
87
+		if (!value.trim()) throw new Error(`${key} 不能为空`);
88
+		if (value.length > 200_000) throw new Error(`${key} 长度过长`);
89
+		return value;
90
+	}
91
+	if (Array.isArray(value) || isRecord(value)) {
92
+		if (JSON.stringify(value).length > 200_000) throw new Error(`${key} 内容过大`);
93
+		return value;
94
+	}
95
+	throw new Error(`${key} 必须是字符串、对象或数组`);
96
+};
97
+
84 98
 const optionalString = (input: Record<string, unknown>, key: string): string | undefined => {
85 99
 	const value = input[key];
86 100
 	if (value === undefined || value === null || value === '') return undefined;
@@ -252,20 +266,34 @@ const tools: WebMcpTool[] = [
252 266
 				documentId: documentIdProperty,
253 267
 				type: { type: 'string', enum: ['heading', 'paragraph', 'table', 'image', 'toc'] },
254 268
 				content: { type: 'string', description: '块内容', maxLength: 200000 },
255
-				level: { type: 'number', minimum: 1, maximum: 6 },
269
+				level: { type: 'number', minimum: 0, maximum: 6 },
256 270
 				afterBlockId: { type: 'string', description: '插入到此块之后,可选' },
271
+				clientBlockId: { type: 'string', description: '可选,客户端幂等块 ID' },
257 272
 			},
258 273
 			['documentId', 'type', 'content']
259 274
 		),
260
-		execute: (input) => run(() => {
275
+		execute: (input) => run(async () => {
261 276
 			const type = requiredString(input, 'type') as BlockType;
262
-			const request: CreateBlockRequest = {
277
+			const level = input.level === undefined ? 0 : numberOr(input, 'level', 0);
278
+			if (type === 'heading' && (level < 1 || level > 6)) {
279
+				throw new Error('heading 的 level 必须是 1-6');
280
+			}
281
+			if (type !== 'heading' && level !== 0) {
282
+				throw new Error('非 heading 块的 level 必须是 0');
283
+			}
284
+			const documentId = requiredString(input, 'documentId');
285
+			const response = await blockService.createBlock(documentId, {
263 286
 				type,
264
-				content: requiredString(input, 'content'),
265
-				level: input.level === undefined ? undefined : numberOr(input, 'level', 0),
287
+				content: requiredContent(input, 'content') as CreateBlockRequest['content'],
288
+				level,
266 289
 				after_block_id: optionalString(input, 'afterBlockId'),
267
-			};
268
-			return blockService.createBlock(requiredString(input, 'documentId'), request);
290
+				client_block_id: optionalString(input, 'clientBlockId'),
291
+			});
292
+			const created = await blockService.getBlock(documentId, response.blockId);
293
+			window.dispatchEvent(new CustomEvent(BLOCK_CREATED_EVENT, {
294
+				detail: { documentId, block: created.block },
295
+			}));
296
+			return { ...response, block: created.block, message: 'Block created successfully' };
269 297
 		}),
270 298
 	},
271 299
 	{

+ 24 - 0
src/services/workflowService.ts

@@ -85,6 +85,26 @@ const getWorkflowConfig = (): WorkflowConfig => {
85 85
   };
86 86
 };
87 87
 
88
+/** Normalize workflow-generated export URLs to the configured backend origin. */
89
+const normalizeExportDownloadUrl = (downloadUrl: string): string => {
90
+  const configuredBaseUrl = import.meta.env.VITE_API_BASE_URL;
91
+  if (!configuredBaseUrl || !downloadUrl) return downloadUrl;
92
+
93
+  try {
94
+    const configuredUrl = new URL(configuredBaseUrl);
95
+    const exportUrl = new URL(downloadUrl, configuredUrl);
96
+    if (exportUrl.pathname.startsWith('/api/v1/export/records/')) {
97
+      exportUrl.protocol = configuredUrl.protocol;
98
+      exportUrl.host = configuredUrl.host;
99
+      return exportUrl.toString();
100
+    }
101
+  } catch {
102
+    // Keep the original value so the caller can report an invalid URL.
103
+  }
104
+
105
+  return downloadUrl;
106
+};
107
+
88 108
 /**
89 109
  * Check if user input should trigger document generation workflow
90 110
  *
@@ -272,6 +292,10 @@ export const triggerDocumentWorkflow = async (
272 292
       content = '✅ 文档已生成,点击下方卡片预览或下载';
273 293
     }
274 294
 
295
+    if (exportRecord?.downloadUrl) {
296
+      exportRecord.downloadUrl = normalizeExportDownloadUrl(exportRecord.downloadUrl);
297
+    }
298
+
275 299
     return {
276 300
       content,
277 301
       exportRecord,

+ 43 - 6
src/stores/chatStore.ts

@@ -35,6 +35,7 @@ import {
35 35
 // ── Constants ──────────────────────────────────────────────────────────────
36 36
 
37 37
 const STORAGE_KEY = 'ax-chat-sessions';
38
+const ACTIVE_SESSION_STORAGE_KEY = 'ax-active-session-id';
38 39
 const MAX_SESSIONS = 50; // Maximum number of sessions to keep
39 40
 const MAX_MESSAGES_PER_SESSION = 200;
40 41
 const STORAGE_WRITE_DELAY = 150;
@@ -104,6 +105,27 @@ const loadSessionsFromStorage = (): ChatSession[] => {
104 105
   }
105 106
 };
106 107
 
108
+const getActiveSession = (sessions: ChatSession[]): ChatSession | undefined => {
109
+  try {
110
+    const activeSessionId = localStorage.getItem(ACTIVE_SESSION_STORAGE_KEY);
111
+    return sessions.find((session) => session.id === activeSessionId) || sessions[0];
112
+  } catch {
113
+    return sessions[0];
114
+  }
115
+};
116
+
117
+const persistActiveSession = (sessionId: string | null): void => {
118
+  try {
119
+    if (sessionId) {
120
+      localStorage.setItem(ACTIVE_SESSION_STORAGE_KEY, sessionId);
121
+    } else {
122
+      localStorage.removeItem(ACTIVE_SESSION_STORAGE_KEY);
123
+    }
124
+  } catch (error) {
125
+    console.warn('保存当前会话失败', error);
126
+  }
127
+};
128
+
107 129
 /**
108 130
  * Save sessions to localStorage
109 131
  */
@@ -240,9 +262,16 @@ const getAIResponse = async (
240 262
  */
241 263
 export const useChatStore = create<ChatStoreState>((set, get) => ({
242 264
   // ============ State ============
243
-  currentSessionId: null,
244
-  sessions: loadSessionsFromStorage(),
245
-  messages: [],
265
+  ...(() => {
266
+    const sessions = loadSessionsFromStorage();
267
+    const activeSession = getActiveSession(sessions);
268
+    persistActiveSession(activeSession?.id ?? null);
269
+    return {
270
+      currentSessionId: activeSession?.id ?? null,
271
+      sessions,
272
+      messages: activeSession?.messages ?? [],
273
+    };
274
+  })(),
246 275
   isLoading: false,
247 276
 
248 277
   // ============ Actions ============
@@ -268,6 +297,7 @@ export const useChatStore = create<ChatStoreState>((set, get) => ({
268 297
     set((state) => {
269 298
       const updatedSessions = [newSession, ...state.sessions];
270 299
       saveSessionsToStorage(updatedSessions);
300
+      persistActiveSession(sessionId);
271 301
 
272 302
       return {
273 303
         currentSessionId: sessionId,
@@ -296,6 +326,7 @@ export const useChatStore = create<ChatStoreState>((set, get) => ({
296 326
       currentSessionId: sessionId,
297 327
       messages: session.messages,
298 328
     });
329
+    persistActiveSession(sessionId);
299 330
   },
300 331
 
301 332
   /**
@@ -318,6 +349,9 @@ export const useChatStore = create<ChatStoreState>((set, get) => ({
318 349
       saveSessionsToStorage(updatedSessions);
319 350
       const newCurrentSessionId =
320 351
         state.currentSessionId === sessionId ? null : state.currentSessionId;
352
+      if (newCurrentSessionId === null) {
353
+        persistActiveSession(null);
354
+      }
321 355
 
322 356
       return {
323 357
         currentSessionId: newCurrentSessionId,
@@ -414,8 +448,11 @@ export const useChatStore = create<ChatStoreState>((set, get) => ({
414 448
       let aiResponse: string;
415 449
       let exportRecord: ExportRecordInfo | undefined;
416 450
 
417
-      // 先尝试匹配聊天中的 WebMCP 命令。未匹配时保持原有 AI 和文档工作流。
418
-      const webMcpResult = await executeWebMcpChatCommand(content);
451
+      // 文档生成请求必须直接进入工作流,避免被 WebMCP 转译成导出等工具操作。
452
+      const workflowRequest = shouldTriggerWorkflow(content);
453
+      const webMcpResult = workflowRequest
454
+        ? { handled: false as const }
455
+        : await executeWebMcpChatCommand(content);
419 456
       if (webMcpResult.handled) {
420 457
         if (webMcpResult.requiresConfirmation) {
421 458
           const confirmed = await confirmWebMcpCommand(webMcpResult.response || '请确认此操作');
@@ -428,7 +465,7 @@ export const useChatStore = create<ChatStoreState>((set, get) => ({
428 465
         } else {
429 466
           aiResponse = webMcpResult.response || 'WebMCP 操作已完成。';
430 467
         }
431
-      } else if (shouldTriggerWorkflow(content)) {
468
+      } else if (workflowRequest) {
432 469
         // Use workflow for document generation
433 470
         // ⭐ 重要: 传递 sessionId 确保生成的文档与当前会话关联
434 471
         // 在同一个会话中生成多个文档时:

+ 73 - 443
src/stores/editorStore.ts

@@ -12,7 +12,6 @@
12 12
 
13 13
 import { create } from 'zustand';
14 14
 import pLimit from 'p-limit';
15
-import hashSum from 'hash-sum';
16 15
 import { v4 as uuidv4 } from 'uuid';
17 16
 import { message } from 'antd';
18 17
 import type {
@@ -23,12 +22,21 @@ import type {
23 22
   TableBlock,
24 23
 } from '../types/editor';
25 24
 import { blockService } from '../services/blockService';
26
-import { getErrorMessage } from '../services/api';
25
+import { getErrorMessage, isApiError } from '../services/api';
26
+import { normalizeTableBlock, validateTableStructure } from '../utils/blockOperations';
27 27
 import {
28
-  normalizeTableBlock,
29
-  serializeTableBlock,
30
-  validateTableStructure,
31
-} from '../utils/blockOperations';
28
+  computeBlockHash,
29
+  computeInsertOrder,
30
+  isCanceledRequest,
31
+  isReadonlyBlock,
32
+  mergeBlockUpdates,
33
+  rebalanceOrders,
34
+  serializeBlockForSave,
35
+  serializePendingBlockUpdate,
36
+  snapshotBlocks,
37
+  waitForStructuralOperations,
38
+} from '../utils/editorStoreHelpers';
39
+import type { EditorHistoryEntry, EditorStore } from './editorStoreTypes';
32 40
 
33 41
 // 并发保存限制器(最多同时进行 3 个请求,降低服务器压力)
34 42
 const saveConcurrencyLimit = pLimit(3);
@@ -42,347 +50,6 @@ const MAX_RETRY_ATTEMPTS = 3;
42 50
 const MAX_HISTORY_ENTRIES = 100;
43 51
 const HISTORY_COALESCE_WINDOW = 500;
44 52
 
45
-function waitForStructuralOperations(isReady: () => boolean): Promise<void> {
46
-  return new Promise((resolve) => {
47
-    const check = () => {
48
-      if (isReady()) {
49
-        resolve();
50
-        return;
51
-      }
52
-      setTimeout(check, 16);
53
-    };
54
-    check();
55
-  });
56
-}
57
-
58
-interface EditorHistoryEntry {
59
-  blocks: DocumentBlock[];
60
-  selectedBlockId: string | null;
61
-}
62
-
63
-function snapshotBlocks(blocks: DocumentBlock[]): DocumentBlock[] {
64
-  // 块更新采用不可变替换,历史快照只需复制数组即可复用块对象。
65
-  return blocks.slice();
66
-}
67
-
68
-/**
69
- * 计算块内容的哈希值
70
- * 用于检测内容是否真的发生了变化
71
- */
72
-function computeBlockHash(block: DocumentBlock): string {
73
-  // 只计算可编辑字段的哈希,忽略 id 等不可变元数据
74
-  const contentForHash = {
75
-    type: block.type,
76
-    content: block.content,
77
-    style: block.style,
78
-    word_style: block.word_style,
79
-    level: block.level,
80
-    block_order: block.block_order,
81
-  };
82
-  return hashSum(contentForHash);
83
-}
84
-
85
-function isReadonlyBlock(block: DocumentBlock): boolean {
86
-  const metadata: object = block.metadata;
87
-  return (
88
-    ('readonly' in metadata && metadata.readonly === true) ||
89
-    ('is_auto_generated' in metadata && metadata.is_auto_generated === true)
90
-  );
91
-}
92
-
93
-function isCanceledRequest(error: unknown): boolean {
94
-  return (
95
-    error instanceof Error &&
96
-    (error.name === 'AbortError' ||
97
-      error.name === 'CanceledError' ||
98
-      error.message.toLowerCase().includes('cancel'))
99
-  );
100
-}
101
-
102
-function serializeBlockForSave(block: DocumentBlock): Pick<BlockUpdate, 'content' | 'style'> {
103
-  if (block.type === 'table') {
104
-    const serializedTable = serializeTableBlock(block as TableBlock);
105
-    return {
106
-      content: serializedTable.content,
107
-      style: block.style,
108
-    };
109
-  }
110
-
111
-  if ((block.type === 'heading' || block.type === 'paragraph') && Array.isArray(block.content)) {
112
-    const firstStyle = block.content[0]?.style;
113
-    const allSameStyle =
114
-      !!firstStyle &&
115
-      block.content.every(
116
-        (segment) => JSON.stringify(segment.style) === JSON.stringify(firstStyle)
117
-      );
118
-
119
-    return {
120
-      content: block.content.map((segment) => segment.text).join(''),
121
-      style:
122
-        allSameStyle && Object.keys(firstStyle).length > 0
123
-          ? { ...block.style, ...firstStyle }
124
-          : block.style,
125
-    };
126
-  }
127
-
128
-  return {
129
-    content: block.content,
130
-    style: block.style,
131
-  };
132
-}
133
-
134
-function mergeBlockUpdates(previous: BlockUpdate | undefined, next: BlockUpdate): BlockUpdate {
135
-  return { ...(previous || {}), ...next };
136
-}
137
-
138
-function serializePendingBlockUpdate(
139
-  block: DocumentBlock,
140
-  pending: BlockUpdate | undefined
141
-): BlockUpdate {
142
-  if (!pending) {
143
-    const serialized = serializeBlockForSave(block);
144
-    return {
145
-      type: block.type,
146
-      level: block.level,
147
-      content: serialized.content,
148
-      style: serialized.style,
149
-      word_style: block.word_style,
150
-      metadata: block.metadata,
151
-      block_order: block.block_order,
152
-    };
153
-  }
154
-  const updates = pending || {};
155
-  const serialized = serializeBlockForSave(block);
156
-  const payload: BlockUpdate = {};
157
-
158
-  if ('type' in updates) payload.type = block.type;
159
-  if ('level' in updates) payload.level = block.level;
160
-  if ('content' in updates) payload.content = serialized.content;
161
-  if ('style' in updates) payload.style = serialized.style;
162
-  if ('word_style' in updates) payload.word_style = block.word_style;
163
-  if ('metadata' in updates) payload.metadata = block.metadata;
164
-  if ('block_order' in updates) payload.block_order = block.block_order;
165
-
166
-  return payload;
167
-}
168
-
169
-// ══════════════════════════════════════════════════════════════════════════════
170
-// Store State Interface
171
-// ══════════════════════════════════════════════════════════════════════════════
172
-
173
-interface EditorStore {
174
-  // ── 文档状态 ────────────────────────────────────────────────────────────
175
-  documentId: string | null;
176
-  documentTitle: string;
177
-  blocks: DocumentBlock[];
178
-  selectedBlockId: string | null;
179
-  focusRequestId: number;
180
-  past: EditorHistoryEntry[];
181
-  future: EditorHistoryEntry[];
182
-  lastHistoryEditBlockId: string | null;
183
-  lastHistoryEditAt: number | null;
184
-  isHistoryApplying: boolean;
185
-  pendingStructuralOperations: number;
186
-
187
-  // ── 加载/保存状态 ──────────────────────────────────────────────────────
188
-  isLoading: boolean;
189
-  isSaving: boolean;
190
-  error: string | null;
191
-
192
-  // ── 保存进度 ────────────────────────────────────────────────────────────
193
-  /** 正在保存的块数量 */
194
-  savingProgress: { current: number; total: number } | null;
195
-
196
-  // ── 修改状态追踪 ────────────────────────────────────────────────────────
197
-  /** 文档是否已被修改(用于控制保存按钮状态) */
198
-  hasModified: boolean;
199
-  /** 原始blocks快照(用于检测变化) */
200
-  originalBlocksSnapshot: string | null;
201
-  /** 保存失败的块ID列表 */
202
-  failedBlocks: string[];
203
-  /** 被修改但未保存的块ID集合 */
204
-  dirtyBlocks: Set<string>;
205
-  /** 上次成功保存的快照 */
206
-  lastSavedSnapshot: string | null;
207
-  /** 块内容哈希映射表(用于精确检测内容变化)*/
208
-  blockHashes: Map<string, string>;
209
-  /** 每个块尚未提交的字段变更,只在保存时转换为 API patch */
210
-  pendingBlockUpdates: Map<string, BlockUpdate>;
211
-
212
-  // ── 保存控制 ────────────────────────────────────────────────────────────
213
-  /** 当前正在进行的保存 Promise */
214
-  currentSavePromise: Promise<void> | null;
215
-  /** 用于取消请求的 AbortController */
216
-  saveAbortController: AbortController | null;
217
-  /** 用于取消文档加载请求的 AbortController */
218
-  loadAbortController: AbortController | null;
219
-
220
-  // ── 自动保存 ────────────────────────────────────────────────────────────
221
-  /** 自动保存定时器 */
222
-  autoSaveTimer: ReturnType<typeof setTimeout> | null;
223
-  /** 是否启用自动保存 */
224
-  autoSaveEnabled: boolean;
225
-  /** 上次保存时间戳 */
226
-  lastSaveTime: number | null;
227
-
228
-  // ── 重试机制 ────────────────────────────────────────────────────────────
229
-  /** 块重试次数记录 */
230
-  retryAttempts: Map<string, number>;
231
-  /** 延迟重试定时器 */
232
-  retryTimer: ReturnType<typeof setTimeout> | null;
233
-
234
-  // ── 操作方法 ────────────────────────────────────────────────────────────
235
-
236
-  /**
237
-   * 加载文档
238
-   */
239
-  loadDocument: (documentId: string) => Promise<void>;
240
-
241
-  /**
242
-   * 保存文档
243
-   */
244
-  saveDocument: () => Promise<void>;
245
-
246
-  /**
247
-   * 重试保存失败的块
248
-   */
249
-  retryFailedBlocks: () => Promise<void>;
250
-
251
-  /**
252
-   * 添加块
253
-   * @param block 块数据(部分字段)
254
-   * @param afterBlockId 插入位置(在此块之后),不传则追加到末尾
255
-   */
256
-  addBlock: (block: PartialBlock, afterBlockId?: string) => void;
257
-
258
-  /**
259
-   * 更新块
260
-   * @param id 块ID
261
-   * @param updates 更新数据
262
-   */
263
-  updateBlock: (id: string, updates: BlockUpdate) => void;
264
-
265
-  /** 应用来自聊天/WebMCP 的已保存块更新 */
266
-  applyRemoteBlockUpdate: (id: string, updates: BlockUpdate) => void;
267
-
268
-  /** 应用来自聊天/WebMCP 的已保存块删除 */
269
-  applyRemoteBlockDelete: (id: string) => void;
270
-
271
-  /**
272
-   * 标记文档已修改
273
-   */
274
-  markAsModified: () => void;
275
-
276
-  /**
277
-   * 标记文档已保存
278
-   */
279
-  markAsSaved: () => void;
280
-
281
-  /**
282
-   * 删除块
283
-   * @param id 块ID
284
-   */
285
-  deleteBlock: (id: string) => Promise<void>;
286
-
287
-  /**
288
-   * 移动块
289
-   * @param id 块ID
290
-   * @param targetOrder 目标位置
291
-   */
292
-  moveBlock: (id: string, targetOrder: number) => void;
293
-
294
-  /**
295
-   * 选中块
296
-   * @param id 块ID
297
-   */
298
-  selectBlock: (id: string | null) => void;
299
-
300
-  /**
301
-   * 根据ID获取块
302
-   */
303
-  getBlockById: (id: string) => DocumentBlock | undefined;
304
-
305
-  /**
306
-   * 根据类型获取块
307
-   */
308
-  getBlocksByType: (type: BlockType) => DocumentBlock[];
309
-
310
-  /**
311
-   * 检查块是否为脏块(已修改未保存)
312
-   */
313
-  isBlockDirty: (id: string) => boolean;
314
-
315
-  /**
316
-   * 获取所有脏块
317
-   */
318
-  getDirtyBlocks: () => DocumentBlock[];
319
-
320
-  /**
321
-   * 保存单个block的更改
322
-   */
323
-  saveBlock: (id: string) => Promise<void>;
324
-
325
-  /**
326
-   * 重新计算所有块的block_order(稀疏排序)
327
-   */
328
-  recomputeBlockOrders: () => void;
329
-
330
-  /**
331
-   * 启用/禁用自动保存
332
-   */
333
-  setAutoSaveEnabled: (enabled: boolean) => void;
334
-
335
-  /**
336
-   * 触发自动保存(带防抖)
337
-   */
338
-  triggerAutoSave: () => void;
339
-
340
-  /**
341
-   * 取消自动保存定时器
342
-   */
343
-  cancelAutoSave: () => void;
344
-
345
-  /**
346
-   * 重置状态
347
-   */
348
-  reset: () => void;
349
-  /** 撤销最近一次编辑 */
350
-  undo: () => Promise<void>;
351
-  /** 重做最近一次撤销 */
352
-  redo: () => Promise<void>;
353
-}
354
-
355
-// ══════════════════════════════════════════════════════════════════════════════
356
-// Utility Functions
357
-// ══════════════════════════════════════════════════════════════════════════════
358
-
359
-/**
360
- * 计算插入位置的block_order
361
- * 稀疏排序策略:在两个块之间找到中间值
362
- */
363
-function computeInsertOrder(prevOrder: number, nextOrder: number): number {
364
-  const gap = nextOrder - prevOrder;
365
-
366
-  if (gap > 1) {
367
-    // 有间隙,直接取中间值
368
-    return Math.floor((prevOrder + nextOrder) / 2);
369
-  }
370
-
371
-  // 间隙不足,需要重排
372
-  return -1;
373
-}
374
-
375
-/**
376
- * 重新平衡block_order(稀疏排序,间隔100)
377
- */
378
-function rebalanceOrders(blocks: DocumentBlock[]): DocumentBlock[] {
379
-  const sorted = [...blocks].sort((a, b) => a.block_order - b.block_order);
380
-  return sorted.map((block, index) => ({
381
-    ...block,
382
-    block_order: index * 100,
383
-  }));
384
-}
385
-
386 53
 // ══════════════════════════════════════════════════════════════════════════════
387 54
 // Store Implementation
388 55
 // ══════════════════════════════════════════════════════════════════════════════
@@ -612,7 +279,10 @@ export const useEditorStore = create<EditorStore>((set, get) => {
612 279
         });
613 280
       } catch (error: unknown) {
614 281
         if (controller.signal.aborted || isCanceledRequest(error)) return;
282
+        const documentMissing = isApiError(error) && error.status === 404;
615 283
         set({
284
+          documentId: documentMissing ? null : documentId,
285
+          blocks: documentMissing ? [] : get().blocks,
616 286
           error: getErrorMessage(error) || '加载文档失败',
617 287
           isLoading: false,
618 288
           loadAbortController: null,
@@ -649,14 +319,14 @@ export const useEditorStore = create<EditorStore>((set, get) => {
649 319
         return;
650 320
       }
651 321
 
652
-      const dirtyBlocks = new Set(
653
-        blocks
654
-          .filter(
655
-            (block) =>
656
-              currentDirtyBlocks.has(block.id) && block.type !== 'toc' && !isReadonlyBlock(block)
657
-          )
658
-          .map((block) => block.id)
659
-      );
322
+      const dirtyBlocks = new Set<string>();
323
+      const blocksToSave: DocumentBlock[] = [];
324
+      for (const block of blocks) {
325
+        if (currentDirtyBlocks.has(block.id) && block.type !== 'toc' && !isReadonlyBlock(block)) {
326
+          dirtyBlocks.add(block.id);
327
+          blocksToSave.push(block);
328
+        }
329
+      }
660 330
 
661 331
       if (dirtyBlocks.size === 0) {
662 332
         set({
@@ -697,15 +367,6 @@ export const useEditorStore = create<EditorStore>((set, get) => {
697 367
       // 创建保存 Promise
698 368
       const savePromise = (async () => {
699 369
         try {
700
-          // 获取需要保存的块(只保存脏块)
701
-          const blocksToSave = blocks.filter((block) => {
702
-            // 必须在脏块列表中
703
-            if (!dirtyBlocks.has(block.id)) {
704
-              return false;
705
-            }
706
-
707
-            return true;
708
-          });
709 370
           const pendingUpdatesAtSave = new Map(currentPendingBlockUpdates);
710 371
 
711 372
           const totalBlocks = blocksToSave.length;
@@ -728,12 +389,9 @@ export const useEditorStore = create<EditorStore>((set, get) => {
728 389
               );
729 390
 
730 391
               try {
731
-                const result = await blockService.updateBlock(
732
-                  documentId,
733
-                  block.id,
734
-                  payload,
735
-                  { signal: newAbortController.signal }
736
-                );
392
+                const result = await blockService.updateBlock(documentId, block.id, payload, {
393
+                  signal: newAbortController.signal,
394
+                });
737 395
                 if (newAbortController.signal.aborted || get().documentId !== documentId) {
738 396
                   return result;
739 397
                 }
@@ -788,11 +446,17 @@ export const useEditorStore = create<EditorStore>((set, get) => {
788 446
             // 更新成功保存的块的哈希值
789 447
             const newBlockHashes = new Map(latestStateBeforeResult.blockHashes);
790 448
             const newPendingBlockUpdates = new Map(latestStateBeforeResult.pendingBlockUpdates);
449
+            const savedBlocksById = new Map(blocks.map((block) => [block.id, block]));
450
+            const latestBlocksById = new Map(
451
+              latestStateBeforeResult.blocks.map((block) => [block.id, block])
452
+            );
791 453
             successIds.forEach((id) => {
792
-              const block = blocks.find((b) => b.id === id);
793
-              const latestBlock = latestStateBeforeResult.blocks.find((b) => b.id === id);
794
-              if (block && latestBlock && computeBlockHash(block) === computeBlockHash(latestBlock)) {
795
-                newBlockHashes.set(id, computeBlockHash(block));
454
+              const block = savedBlocksById.get(id);
455
+              const latestBlock = latestBlocksById.get(id);
456
+              if (!block || !latestBlock) return;
457
+              const savedHash = computeBlockHash(block);
458
+              if (savedHash === computeBlockHash(latestBlock)) {
459
+                newBlockHashes.set(id, savedHash);
796 460
                 newPendingBlockUpdates.delete(id);
797 461
               }
798 462
             });
@@ -837,9 +501,11 @@ export const useEditorStore = create<EditorStore>((set, get) => {
837 501
             const newDirtyBlocks = new Set(latestState.dirtyBlocks);
838 502
             const newBlockHashes = new Map(latestState.blockHashes);
839 503
             const newPendingBlockUpdates = new Map(latestState.pendingBlockUpdates);
504
+            const savedBlocksById = new Map(blocks.map((block) => [block.id, block]));
505
+            const latestBlocksById = new Map(latestState.blocks.map((block) => [block.id, block]));
840 506
             successIds.forEach((id) => {
841
-              const savedBlock = blocks.find((block) => block.id === id);
842
-              const latestBlock = latestState.blocks.find((block) => block.id === id);
507
+              const savedBlock = savedBlocksById.get(id);
508
+              const latestBlock = latestBlocksById.get(id);
843 509
               if (!savedBlock || !latestBlock) return;
844 510
 
845 511
               const savedHash = computeBlockHash(savedBlock);
@@ -942,7 +608,8 @@ export const useEditorStore = create<EditorStore>((set, get) => {
942 608
       const savePromise = (async () => {
943 609
         try {
944 610
           // 获取失败的块
945
-          const blocksToRetry = blocks.filter((block) => failedBlocks.includes(block.id));
611
+          const failedBlockIds = new Set(failedBlocks);
612
+          const blocksToRetry = blocks.filter((block) => failedBlockIds.has(block.id));
946 613
           const savedHashes = new Map(
947 614
             blocksToRetry.map((block) => [block.id, computeBlockHash(block)])
948 615
           );
@@ -1000,8 +667,9 @@ export const useEditorStore = create<EditorStore>((set, get) => {
1000 667
           const latestState = get();
1001 668
           const newDirtyBlocks = new Set(latestState.dirtyBlocks);
1002 669
           const newBlockHashes = new Map(latestState.blockHashes);
670
+          const latestBlocksById = new Map(latestState.blocks.map((block) => [block.id, block]));
1003 671
           successIds.forEach((id) => {
1004
-            const latestBlock = latestState.blocks.find((block) => block.id === id);
672
+            const latestBlock = latestBlocksById.get(id);
1005 673
             const savedHash = savedHashes.get(id);
1006 674
             if (latestBlock && savedHash === computeBlockHash(latestBlock)) {
1007 675
               newDirtyBlocks.delete(id);
@@ -1257,10 +925,7 @@ export const useEditorStore = create<EditorStore>((set, get) => {
1257 925
       if (originalHash && newHash === originalHash) {
1258 926
         newPendingBlockUpdates.delete(id);
1259 927
       } else {
1260
-        newPendingBlockUpdates.set(
1261
-          id,
1262
-          mergeBlockUpdates(pendingBlockUpdates.get(id), updates)
1263
-        );
928
+        newPendingBlockUpdates.set(id, mergeBlockUpdates(pendingBlockUpdates.get(id), updates));
1264 929
       }
1265 930
 
1266 931
       set({
@@ -1307,6 +972,29 @@ export const useEditorStore = create<EditorStore>((set, get) => {
1307 972
       });
1308 973
     },
1309 974
 
975
+    // ── applyRemoteBlockInsert ─────────────────────────────────────────────
976
+    applyRemoteBlockInsert: (block: DocumentBlock) => {
977
+      const { documentId, blocks, blockHashes, dirtyBlocks, pendingBlockUpdates } = get();
978
+      if (!documentId || blocks.some((currentBlock) => currentBlock.id === block.id)) return;
979
+
980
+      const updatedBlocks = [...blocks, block].sort(
981
+        (left, right) => left.block_order - right.block_order
982
+      );
983
+      const updatedHashes = new Map(blockHashes);
984
+      updatedHashes.set(block.id, computeBlockHash(block));
985
+      const snapshot = JSON.stringify(updatedBlocks);
986
+
987
+      set({
988
+        blocks: updatedBlocks,
989
+        blockHashes: updatedHashes,
990
+        dirtyBlocks,
991
+        pendingBlockUpdates,
992
+        hasModified: dirtyBlocks.size > 0,
993
+        originalBlocksSnapshot: snapshot,
994
+        lastSavedSnapshot: snapshot,
995
+      });
996
+    },
997
+
1310 998
     // ── applyRemoteBlockDelete ─────────────────────────────────────────────
1311 999
     applyRemoteBlockDelete: (id: string) => {
1312 1000
       const { documentId, blocks, blockHashes, dirtyBlocks, pendingBlockUpdates } = get();
@@ -1390,69 +1078,11 @@ export const useEditorStore = create<EditorStore>((set, get) => {
1390 1078
       });
1391 1079
 
1392 1080
       try {
1393
-        // 1. 调用后端API删除块
1081
+        // 稀疏 block_order 不需要因删除而重排,避免对剩余块逐个发起更新请求。
1394 1082
         await blockService.deleteBlock(documentId, id);
1395 1083
 
1396
-        // 2. 删除成功后,重新计算并更新所有块的 block_order
1397
-        // 按照当前顺序重新分配 block_order(使用稀疏排序,间隔100)
1398
-        const sortedBlocks = [...newBlocks].sort((a, b) => a.block_order - b.block_order);
1399
-        const blocksNeedUpdate: Array<{ block: DocumentBlock; newOrder: number }> = [];
1400
-
1401
-        sortedBlocks.forEach((block, index) => {
1402
-          const expectedOrder = index * 100;
1403
-          if (block.block_order !== expectedOrder) {
1404
-            blocksNeedUpdate.push({
1405
-              block,
1406
-              newOrder: expectedOrder,
1407
-            });
1408
-          }
1409
-        });
1410
-
1411
-        // 3. 如果有块需要更新顺序,批量调用 PUT API 更新
1412
-        if (blocksNeedUpdate.length > 0) {
1413
-          // 并发调用 PUT API 更新所有受影响块的顺序。
1414
-          // 只发送顺序字段,避免用删除前的块快照覆盖并发编辑。
1415
-          const updatePromises = blocksNeedUpdate.map(({ block, newOrder }) =>
1416
-            blockService.updateBlock(documentId, block.id, {
1417
-              block_order: newOrder,
1418
-            })
1419
-          );
1420
-
1421
-          await Promise.all(updatePromises);
1422
-
1423
-          // 4. 更新本地状态中的 block_order,并更新这些块的哈希值
1424
-          const { blocks: currentBlocks, blockHashes: currentBlockHashes } = get();
1425
-          const updatedBlocks = currentBlocks.map((b) => {
1426
-            const update = blocksNeedUpdate.find((u) => u.block.id === b.id);
1427
-            if (update) {
1428
-              return { ...b, block_order: update.newOrder };
1429
-            }
1430
-            return b;
1431
-          });
1432
-
1433
-          // 更新这些块的哈希值,因为后端已经保存了
1434
-          const updatedBlockHashes = new Map(currentBlockHashes);
1435
-          updatedBlocks.forEach((block) => {
1436
-            if (blocksNeedUpdate.some((u) => u.block.id === block.id)) {
1437
-              updatedBlockHashes.set(block.id, computeBlockHash(block));
1438
-            }
1439
-          });
1440
-
1441
-          // 5. 更新本地状态,不标记为已修改(因为后端已经同步)
1442
-          const latestState = get();
1443
-          set({
1444
-            blocks: updatedBlocks,
1445
-            blockHashes: updatedBlockHashes,
1446
-            dirtyBlocks: latestState.dirtyBlocks,
1447
-            hasModified: latestState.dirtyBlocks.size > 0,
1448
-          });
1449
-        } else {
1450
-          // 没有块需要更新顺序(可能删除的是最后一个块)
1451
-          const latestState = get();
1452
-          set({
1453
-            hasModified: latestState.dirtyBlocks.size > 0,
1454
-          });
1455
-        }
1084
+        const latestState = get();
1085
+        set({ hasModified: latestState.dirtyBlocks.size > 0 });
1456 1086
         pushHistory(blocks, get().selectedBlockId);
1457 1087
       } catch (error: unknown) {
1458 1088
         if (get().documentId !== documentId) {

+ 70 - 0
src/stores/editorStoreTypes.ts

@@ -0,0 +1,70 @@
1
+import type { BlockType, BlockUpdate, DocumentBlock, PartialBlock } from '../types/editor';
2
+
3
+export interface EditorHistoryEntry {
4
+  blocks: DocumentBlock[];
5
+  selectedBlockId: string | null;
6
+}
7
+
8
+export interface EditorStore {
9
+  documentId: string | null;
10
+  documentTitle: string;
11
+  blocks: DocumentBlock[];
12
+  selectedBlockId: string | null;
13
+  focusRequestId: number;
14
+  past: EditorHistoryEntry[];
15
+  future: EditorHistoryEntry[];
16
+  lastHistoryEditBlockId: string | null;
17
+  lastHistoryEditAt: number | null;
18
+  isHistoryApplying: boolean;
19
+  pendingStructuralOperations: number;
20
+
21
+  isLoading: boolean;
22
+  isSaving: boolean;
23
+  error: string | null;
24
+  savingProgress: { current: number; total: number } | null;
25
+
26
+  hasModified: boolean;
27
+  originalBlocksSnapshot: string | null;
28
+  failedBlocks: string[];
29
+  dirtyBlocks: Set<string>;
30
+  lastSavedSnapshot: string | null;
31
+  blockHashes: Map<string, string>;
32
+  pendingBlockUpdates: Map<string, BlockUpdate>;
33
+
34
+  currentSavePromise: Promise<void> | null;
35
+  saveAbortController: AbortController | null;
36
+  loadAbortController: AbortController | null;
37
+
38
+  autoSaveTimer: ReturnType<typeof setTimeout> | null;
39
+  autoSaveEnabled: boolean;
40
+  lastSaveTime: number | null;
41
+
42
+  retryAttempts: Map<string, number>;
43
+  retryTimer: ReturnType<typeof setTimeout> | null;
44
+
45
+  loadDocument: (documentId: string) => Promise<void>;
46
+  saveDocument: () => Promise<void>;
47
+  retryFailedBlocks: () => Promise<void>;
48
+  addBlock: (block: PartialBlock, afterBlockId?: string) => void;
49
+  updateBlock: (id: string, updates: BlockUpdate) => void;
50
+  applyRemoteBlockUpdate: (id: string, updates: BlockUpdate) => void;
51
+  applyRemoteBlockInsert: (block: DocumentBlock) => void;
52
+  applyRemoteBlockDelete: (id: string) => void;
53
+  markAsModified: () => void;
54
+  markAsSaved: () => void;
55
+  deleteBlock: (id: string) => Promise<void>;
56
+  moveBlock: (id: string, targetOrder: number) => void;
57
+  selectBlock: (id: string | null) => void;
58
+  getBlockById: (id: string) => DocumentBlock | undefined;
59
+  getBlocksByType: (type: BlockType) => DocumentBlock[];
60
+  isBlockDirty: (id: string) => boolean;
61
+  getDirtyBlocks: () => DocumentBlock[];
62
+  saveBlock: (id: string) => Promise<void>;
63
+  recomputeBlockOrders: () => void;
64
+  setAutoSaveEnabled: (enabled: boolean) => void;
65
+  triggerAutoSave: () => void;
66
+  cancelAutoSave: () => void;
67
+  reset: () => void;
68
+  undo: () => Promise<void>;
69
+  redo: () => Promise<void>;
70
+}

+ 121 - 0
src/utils/editorStoreHelpers.ts

@@ -0,0 +1,121 @@
1
+import hashSum from 'hash-sum';
2
+import type { BlockUpdate, DocumentBlock, TableBlock } from '../types/editor';
3
+import { serializeTableBlock } from './blockOperations';
4
+
5
+export function waitForStructuralOperations(isReady: () => boolean): Promise<void> {
6
+  return new Promise((resolve) => {
7
+    const check = () => {
8
+      if (isReady()) {
9
+        resolve();
10
+        return;
11
+      }
12
+      setTimeout(check, 16);
13
+    };
14
+    check();
15
+  });
16
+}
17
+
18
+export function computeInsertOrder(prevOrder: number, nextOrder: number): number {
19
+  const gap = nextOrder - prevOrder;
20
+  return gap > 1 ? Math.floor((prevOrder + nextOrder) / 2) : -1;
21
+}
22
+
23
+export function rebalanceOrders(blocks: DocumentBlock[]): DocumentBlock[] {
24
+  return [...blocks]
25
+    .sort((left, right) => left.block_order - right.block_order)
26
+    .map((block, index) => ({ ...block, block_order: index * 100 }));
27
+}
28
+
29
+export function snapshotBlocks(blocks: DocumentBlock[]): DocumentBlock[] {
30
+  return blocks.slice();
31
+}
32
+
33
+export function computeBlockHash(block: DocumentBlock): string {
34
+  return hashSum({
35
+    type: block.type,
36
+    content: block.content,
37
+    style: block.style,
38
+    word_style: block.word_style,
39
+    level: block.level,
40
+    block_order: block.block_order,
41
+  });
42
+}
43
+
44
+export function isReadonlyBlock(block: DocumentBlock): boolean {
45
+  const metadata = block.metadata as { readonly?: unknown; is_auto_generated?: unknown };
46
+  return metadata.readonly === true || metadata.is_auto_generated === true;
47
+}
48
+
49
+export function isCanceledRequest(error: unknown): boolean {
50
+  return (
51
+    error instanceof Error &&
52
+    (error.name === 'AbortError' ||
53
+      error.name === 'CanceledError' ||
54
+      error.message.toLowerCase().includes('cancel'))
55
+  );
56
+}
57
+
58
+export function serializeBlockForSave(
59
+  block: DocumentBlock
60
+): Pick<BlockUpdate, 'content' | 'style'> {
61
+  if (block.type === 'table') {
62
+    const serializedTable = serializeTableBlock(block as TableBlock);
63
+    return { content: serializedTable.content, style: block.style };
64
+  }
65
+
66
+  if ((block.type === 'heading' || block.type === 'paragraph') && Array.isArray(block.content)) {
67
+    const firstStyle = block.content[0]?.style;
68
+    const firstStyleSerialized = firstStyle ? JSON.stringify(firstStyle) : undefined;
69
+    const allSameStyle =
70
+      !!firstStyle &&
71
+      block.content.every((segment) => JSON.stringify(segment.style) === firstStyleSerialized);
72
+
73
+    return {
74
+      content: block.content.map((segment) => segment.text).join(''),
75
+      style:
76
+        allSameStyle && Object.keys(firstStyle).length > 0
77
+          ? { ...block.style, ...firstStyle }
78
+          : block.style,
79
+    };
80
+  }
81
+
82
+  return { content: block.content, style: block.style };
83
+}
84
+
85
+export function mergeBlockUpdates(
86
+  previous: BlockUpdate | undefined,
87
+  next: BlockUpdate
88
+): BlockUpdate {
89
+  return { ...(previous || {}), ...next };
90
+}
91
+
92
+export function serializePendingBlockUpdate(
93
+  block: DocumentBlock,
94
+  pending: BlockUpdate | undefined
95
+): BlockUpdate {
96
+  if (!pending) {
97
+    const serialized = serializeBlockForSave(block);
98
+    return {
99
+      type: block.type,
100
+      level: block.level,
101
+      content: serialized.content,
102
+      style: serialized.style,
103
+      word_style: block.word_style,
104
+      metadata: block.metadata,
105
+      block_order: block.block_order,
106
+    };
107
+  }
108
+
109
+  const payload: BlockUpdate = {};
110
+  if ('type' in pending) payload.type = block.type;
111
+  if ('level' in pending) payload.level = block.level;
112
+  if ('content' in pending || 'style' in pending) {
113
+    const serialized = serializeBlockForSave(block);
114
+    if ('content' in pending) payload.content = serialized.content;
115
+    if ('style' in pending) payload.style = serialized.style;
116
+  }
117
+  if ('word_style' in pending) payload.word_style = block.word_style;
118
+  if ('metadata' in pending) payload.metadata = block.metadata;
119
+  if ('block_order' in pending) payload.block_order = block.block_order;
120
+  return payload;
121
+}