Procházet zdrojové kódy

feat(配置): 更新环境变量配置,添加开发和生产环境的API代理支持,增强安全性
feat(聊天面板): 添加下载链接验证,确保文档地址安全
feat(AI服务): 根据环境配置选择API URL和密钥,优化错误处理
feat(错误处理): 限制错误消息长度,避免过长信息影响用户体验
feat(编辑器): 添加块序列化功能,优化保存逻辑

Zhang Yice před 1 měsícem
rodič
revize
866fdafae1

+ 5 - 16
.env.example

@@ -13,24 +13,13 @@ VITE_APP_TITLE=AX Document Editor
13
 # Enable debug mode for development
13
 # Enable debug mode for development
14
 # Default: false
14
 # Default: false
15
 VITE_DEBUG=false
15
 VITE_DEBUG=false
16
+
17
+# Development-only direct AI endpoints. Never ship these keys in a production build.
16
 VITE_AI_API_URL=
18
 VITE_AI_API_URL=
17
 VITE_AI_API_KEY=
19
 VITE_AI_API_KEY=
18
 VITE_WORKFLOW_API_URL=
20
 VITE_WORKFLOW_API_URL=
19
 VITE_WORKFLOW_API_KEY=
21
 VITE_WORKFLOW_API_KEY=
20
 
22
 
21
-# AI Chat API Configuration
22
-# AI Platform API URL for chat completions (v1 - regular chat)
23
-VITE_AI_API_URL=http://114.242.25.27:3000/api/v1/chat/completions
24
-
25
-# AI Platform API Key
26
-# Get this from: 发布渠道 → API 访问 → 创建新 Key
27
-VITE_AI_API_KEY=your_api_key_here
28
-
29
-# Workflow API Configuration
30
-# Workflow API URL for document generation (v2 - with workflow)
31
-# This workflow connects to local backend export records API
32
-VITE_WORKFLOW_API_URL=http://114.242.25.27:3000/api/v2/chat/completions
33
-
34
-# Workflow API Key
35
-# Use the XAgent API key provided by the platform
36
-VITE_WORKFLOW_API_KEY=your_workflow_api_key_here
23
+# Production same-origin proxy endpoints. The proxy stores the upstream API keys server-side.
24
+VITE_AI_PROXY_URL=/api/v1/ai/chat
25
+VITE_WORKFLOW_PROXY_URL=/api/v1/ai/workflow

+ 13 - 1
src/components/ChatPanel/MessageItem.tsx

@@ -17,7 +17,11 @@ import { formatDate } from '../../utils/formatDate';
17
 import type { ChatMessage } from '../../types/chat';
17
 import type { ChatMessage } from '../../types/chat';
18
 import { useDocumentStore } from '../../stores/documentStore';
18
 import { useDocumentStore } from '../../stores/documentStore';
19
 import { useChatStore } from '../../stores/chatStore';
19
 import { useChatStore } from '../../stores/chatStore';
20
-import { downloadBlob, getFileNameFromContentDisposition } from '../../utils/download';
20
+import {
21
+  downloadBlob,
22
+  getFileNameFromContentDisposition,
23
+  isAllowedHttpUrl,
24
+} from '../../utils/download';
21
 
25
 
22
 const { Text } = Typography;
26
 const { Text } = Typography;
23
 
27
 
@@ -154,6 +158,10 @@ const MessageItem: React.FC<MessageItemProps> = memo(
154
      */
158
      */
155
     const handlePreviewClick = useCallback(async () => {
159
     const handlePreviewClick = useCallback(async () => {
156
       if (!exportRecord || !onPreviewDocument || isCreatingDocument) return;
160
       if (!exportRecord || !onPreviewDocument || isCreatingDocument) return;
161
+      if (!isAllowedHttpUrl(exportRecord.downloadUrl)) {
162
+        antdMessage.error('文档地址不受信任,无法打开');
163
+        return;
164
+      }
157
 
165
 
158
       try {
166
       try {
159
         setIsCreatingDocument(true);
167
         setIsCreatingDocument(true);
@@ -223,6 +231,10 @@ const MessageItem: React.FC<MessageItemProps> = memo(
223
       async (e: React.MouseEvent) => {
231
       async (e: React.MouseEvent) => {
224
         e.stopPropagation(); // Prevent card click
232
         e.stopPropagation(); // Prevent card click
225
         if (!exportRecord?.downloadUrl) return;
233
         if (!exportRecord?.downloadUrl) return;
234
+        if (!isAllowedHttpUrl(exportRecord.downloadUrl)) {
235
+          antdMessage.error('文档地址不受信任,无法下载');
236
+          return;
237
+        }
226
         
238
         
227
         try {
239
         try {
228
           // Extract recordId from downloadUrl
240
           // Extract recordId from downloadUrl

+ 19 - 6
src/services/aiChatService.ts

@@ -78,12 +78,19 @@ interface AIChatConfig {
78
  * Get AI Chat configuration from environment variables
78
  * Get AI Chat configuration from environment variables
79
  */
79
  */
80
 const getAIChatConfig = (): AIChatConfig => {
80
 const getAIChatConfig = (): AIChatConfig => {
81
+  const isDevelopment = import.meta.env.DEV;
81
   return {
82
   return {
82
-    apiUrl: import.meta.env.VITE_AI_API_URL,
83
-    apiKey: import.meta.env.VITE_AI_API_KEY,
83
+    apiUrl: isDevelopment
84
+      ? import.meta.env.VITE_AI_API_URL
85
+      : import.meta.env.VITE_AI_PROXY_URL,
86
+    apiKey: isDevelopment ? import.meta.env.VITE_AI_API_KEY : '',
84
   };
87
   };
85
 };
88
 };
86
 
89
 
90
+const getAuthorizationHeaders = (apiKey: string): Record<string, string> => (
91
+  apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
92
+);
93
+
87
 /**
94
 /**
88
  * Send a chat completion request to AI platform
95
  * Send a chat completion request to AI platform
89
  *
96
  *
@@ -97,7 +104,10 @@ export const sendChatCompletion = async (
97
   try {
104
   try {
98
     const config = getAIChatConfig();
105
     const config = getAIChatConfig();
99
 
106
 
100
-    if (!config.apiUrl || !config.apiKey) {
107
+    if (!config.apiUrl) {
108
+      if (import.meta.env.PROD) {
109
+        throw new Error('AI 服务未配置,请先配置服务端代理');
110
+      }
101
       return mockChatCompletion(request);
111
       return mockChatCompletion(request);
102
     }
112
     }
103
 
113
 
@@ -105,7 +115,7 @@ export const sendChatCompletion = async (
105
       method: 'POST',
115
       method: 'POST',
106
       headers: {
116
       headers: {
107
         'Content-Type': 'application/json',
117
         'Content-Type': 'application/json',
108
-        Authorization: `Bearer ${config.apiKey}`,
118
+        ...getAuthorizationHeaders(config.apiKey),
109
       },
119
       },
110
       body: JSON.stringify({
120
       body: JSON.stringify({
111
         chatId: request.chatId,
121
         chatId: request.chatId,
@@ -154,7 +164,10 @@ export const sendStreamingChatCompletion = async (
154
   try {
164
   try {
155
     const config = getAIChatConfig();
165
     const config = getAIChatConfig();
156
 
166
 
157
-    if (!config.apiUrl || !config.apiKey) {
167
+    if (!config.apiUrl) {
168
+      if (import.meta.env.PROD) {
169
+        throw new Error('AI 服务未配置,请先配置服务端代理');
170
+      }
158
       const mockResponse = await mockChatCompletion(request);
171
       const mockResponse = await mockChatCompletion(request);
159
       for (const chunk of mockResponse.content.split(' ')) {
172
       for (const chunk of mockResponse.content.split(' ')) {
160
         await new Promise((resolve) => setTimeout(resolve, 50));
173
         await new Promise((resolve) => setTimeout(resolve, 50));
@@ -167,7 +180,7 @@ export const sendStreamingChatCompletion = async (
167
       method: 'POST',
180
       method: 'POST',
168
       headers: {
181
       headers: {
169
         'Content-Type': 'application/json',
182
         'Content-Type': 'application/json',
170
-        Authorization: `Bearer ${config.apiKey}`,
183
+        ...getAuthorizationHeaders(config.apiKey),
171
       },
184
       },
172
       body: JSON.stringify({
185
       body: JSON.stringify({
173
         ...request,
186
         ...request,

+ 15 - 5
src/services/api.ts

@@ -59,6 +59,14 @@ const getBaseURL = (): string => {
59
  * Default request timeout in milliseconds (30 seconds)
59
  * Default request timeout in milliseconds (30 seconds)
60
  */
60
  */
61
 const DEFAULT_TIMEOUT = 30000;
61
 const DEFAULT_TIMEOUT = 30000;
62
+const MAX_ERROR_MESSAGE_LENGTH = 500;
63
+
64
+const limitErrorMessage = (value: string): string => {
65
+  const normalized = value.trim();
66
+  return normalized.length > MAX_ERROR_MESSAGE_LENGTH
67
+    ? `${normalized.slice(0, MAX_ERROR_MESSAGE_LENGTH)}...`
68
+    : normalized;
69
+};
62
 
70
 
63
 /**
71
 /**
64
  * Create and configure axios instance
72
  * Create and configure axios instance
@@ -184,15 +192,15 @@ export const normalizeAxiosError = (error: AxiosError): ApiError => {
184
 
192
 
185
     // Extract error message from response
193
     // Extract error message from response
186
     if (apiData?.message) {
194
     if (apiData?.message) {
187
-      message = apiData.message;
195
+      message = limitErrorMessage(apiData.message);
188
     } else if (typeof data === 'string') {
196
     } else if (typeof data === 'string') {
189
-      message = data;
197
+      message = limitErrorMessage(data);
190
     }
198
     }
191
 
199
 
192
     // Specific client error messages
200
     // Specific client error messages
193
     switch (status) {
201
     switch (status) {
194
       case 400:
202
       case 400:
195
-        message = apiData?.message || '请求参数错误';
203
+        message = apiData?.message ? limitErrorMessage(apiData.message) : '请求参数错误';
196
         break;
204
         break;
197
       case 401:
205
       case 401:
198
         message = '未授权,请先登录';
206
         message = '未授权,请先登录';
@@ -204,7 +212,7 @@ export const normalizeAxiosError = (error: AxiosError): ApiError => {
204
         message = '请求的资源不存在';
212
         message = '请求的资源不存在';
205
         break;
213
         break;
206
       case 422:
214
       case 422:
207
-        message = apiData?.message || '数据验证失败';
215
+        message = apiData?.message ? limitErrorMessage(apiData.message) : '数据验证失败';
208
         break;
216
         break;
209
     }
217
     }
210
 
218
 
@@ -219,7 +227,9 @@ export const normalizeAxiosError = (error: AxiosError): ApiError => {
219
 
227
 
220
   // Server errors (5xx)
228
   // Server errors (5xx)
221
   if (status >= 500) {
229
   if (status >= 500) {
222
-    const message = apiData?.message || '服务器错误,请稍后重试'
230
+    const message = apiData?.message
231
+      ? limitErrorMessage(apiData.message)
232
+      : '服务器错误,请稍后重试';
223
 
233
 
224
     return {
234
     return {
225
       type: 'server',
235
       type: 'server',

+ 10 - 3
src/services/workflowService.ts

@@ -81,12 +81,19 @@ interface WorkflowConfig {
81
  * Get Workflow configuration from environment variables
81
  * Get Workflow configuration from environment variables
82
  */
82
  */
83
 const getWorkflowConfig = (): WorkflowConfig => {
83
 const getWorkflowConfig = (): WorkflowConfig => {
84
+  const isDevelopment = import.meta.env.DEV;
84
   return {
85
   return {
85
-    apiUrl: import.meta.env.VITE_WORKFLOW_API_URL,
86
-    apiKey: import.meta.env.VITE_WORKFLOW_API_KEY,
86
+    apiUrl: isDevelopment
87
+      ? import.meta.env.VITE_WORKFLOW_API_URL
88
+      : import.meta.env.VITE_WORKFLOW_PROXY_URL,
89
+    apiKey: isDevelopment ? import.meta.env.VITE_WORKFLOW_API_KEY : '',
87
   };
90
   };
88
 };
91
 };
89
 
92
 
93
+const getAuthorizationHeaders = (apiKey: string): Record<string, string> => (
94
+  apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
95
+);
96
+
90
 /**
97
 /**
91
  * Check if user input should trigger document generation workflow
98
  * Check if user input should trigger document generation workflow
92
  *
99
  *
@@ -170,7 +177,7 @@ export const triggerDocumentWorkflow = async (
170
       method: 'POST',
177
       method: 'POST',
171
       headers: {
178
       headers: {
172
         'Content-Type': 'application/json',
179
         'Content-Type': 'application/json',
173
-        Authorization: `Bearer ${config.apiKey}`,
180
+        ...getAuthorizationHeaders(config.apiKey),
174
       },
181
       },
175
       body: JSON.stringify({
182
       body: JSON.stringify({
176
         chatId: uniqueChatId, // 带时间戳的唯一ID,避免工作流缓存
183
         chatId: uniqueChatId, // 带时间戳的唯一ID,避免工作流缓存

+ 1 - 1
src/stores/chatStore.ts

@@ -79,7 +79,7 @@ const loadSessionsFromStorage = (): ChatSession[] => {
79
         messages: session.messages.slice(-MAX_MESSAGES_PER_SESSION),
79
         messages: session.messages.slice(-MAX_MESSAGES_PER_SESSION),
80
       }));
80
       }));
81
     // Sort by updatedAt descending (most recent first)
81
     // Sort by updatedAt descending (most recent first)
82
-    return sessions.sort((a, b) => b.updatedAt - a.updatedAt);
82
+    return sessions.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, MAX_SESSIONS);
83
   } catch (error) {
83
   } catch (error) {
84
     console.warn('读取本地会话失败,将使用空会话列表', error);
84
     console.warn('读取本地会话失败,将使用空会话列表', error);
85
     return [];
85
     return [];

+ 65 - 95
src/stores/editorStore.ts

@@ -82,6 +82,35 @@ function isCanceledRequest(error: unknown): boolean {
82
   );
82
   );
83
 }
83
 }
84
 
84
 
85
+function serializeBlockForSave(block: DocumentBlock): Pick<BlockUpdate, 'content' | 'style'> {
86
+  if (block.type === 'table') {
87
+    const serializedTable = serializeTableBlock(block as TableBlock);
88
+    return {
89
+      content: serializedTable.content,
90
+      style: block.style,
91
+    };
92
+  }
93
+
94
+  if ((block.type === 'heading' || block.type === 'paragraph') && Array.isArray(block.content)) {
95
+    const firstStyle = block.content[0]?.style;
96
+    const allSameStyle = !!firstStyle && block.content.every((segment) =>
97
+      JSON.stringify(segment.style) === JSON.stringify(firstStyle),
98
+    );
99
+
100
+    return {
101
+      content: block.content.map((segment) => segment.text).join(''),
102
+      style: allSameStyle && Object.keys(firstStyle).length > 0
103
+        ? { ...block.style, ...firstStyle }
104
+        : block.style,
105
+    };
106
+  }
107
+
108
+  return {
109
+    content: block.content,
110
+    style: block.style,
111
+  };
112
+}
113
+
85
 // ══════════════════════════════════════════════════════════════════════════════
114
 // ══════════════════════════════════════════════════════════════════════════════
86
 // Store State Interface
115
 // Store State Interface
87
 // ══════════════════════════════════════════════════════════════════════════════
116
 // ══════════════════════════════════════════════════════════════════════════════
@@ -582,34 +611,7 @@ export const useEditorStore = create<EditorStore>((set, get) => {
582
             // 获取该块的重试次数
611
             // 获取该块的重试次数
583
             const attempts = retryAttempts.get(block.id) || 0;
612
             const attempts = retryAttempts.get(block.id) || 0;
584
             
613
             
585
-            // 序列化块内容(将富文本数组转为纯字符串,并提取样式到style字段)
586
-            let contentToSave = block.content;
587
-            let styleToSave = block.style;
588
-            
589
-            if (block.type === 'table') {
590
-              const serializedTable = serializeTableBlock(block as TableBlock);
591
-              contentToSave = serializedTable.content;
592
-            } else if (block.type === 'heading' || block.type === 'paragraph') {
593
-              // 对于标题和段落块,如果content是富文本数组,需要序列化
594
-              if (Array.isArray(block.content)) {
595
-                // 提取纯文本
596
-                contentToSave = block.content.map(seg => seg.text).join('');
597
-                
598
-                // 如果所有片段的样式一致,提取到块级style
599
-                const allSegments = block.content;
600
-                if (allSegments.length > 0) {
601
-                  const firstStyle = allSegments[0].style;
602
-                  const allSameStyle = allSegments.every(seg => 
603
-                    JSON.stringify(seg.style) === JSON.stringify(firstStyle)
604
-                  );
605
-                  
606
-                  if (allSameStyle && Object.keys(firstStyle).length > 0) {
607
-                    // 所有片段样式一致,合并到块级style
608
-                    styleToSave = { ...block.style, ...firstStyle };
609
-                  }
610
-                }
611
-              }
612
-            }
614
+            const { content: contentToSave, style: styleToSave } = serializeBlockForSave(block);
613
             
615
             
614
             try {
616
             try {
615
               const result = await blockService.updateBlock(
617
               const result = await blockService.updateBlock(
@@ -825,6 +827,9 @@ export const useEditorStore = create<EditorStore>((set, get) => {
825
         const blocksToRetry = blocks.filter(block => 
827
         const blocksToRetry = blocks.filter(block => 
826
           failedBlocks.includes(block.id)
828
           failedBlocks.includes(block.id)
827
         );
829
         );
830
+        const savedHashes = new Map(
831
+          blocksToRetry.map((block) => [block.id, computeBlockHash(block)]),
832
+        );
828
         
833
         
829
         const totalBlocks = blocksToRetry.length;
834
         const totalBlocks = blocksToRetry.length;
830
         
835
         
@@ -837,34 +842,7 @@ export const useEditorStore = create<EditorStore>((set, get) => {
837
         // 使用并发限制器重试保存
842
         // 使用并发限制器重试保存
838
         const tasks = blocksToRetry.map(block => 
843
         const tasks = blocksToRetry.map(block => 
839
           saveConcurrencyLimit(() => {
844
           saveConcurrencyLimit(() => {
840
-            // 序列化块内容(将富文本数组转为纯字符串,并提取样式到style字段)
841
-            let contentToSave = block.content;
842
-            let styleToSave = block.style;
843
-            
844
-            if (block.type === 'table') {
845
-              const serializedTable = serializeTableBlock(block as TableBlock);
846
-              contentToSave = serializedTable.content;
847
-            } else if (block.type === 'heading' || block.type === 'paragraph') {
848
-              // 对于标题和段落块,如果content是富文本数组,需要序列化
849
-              if (Array.isArray(block.content)) {
850
-                // 提取纯文本
851
-                contentToSave = block.content.map(seg => seg.text).join('');
852
-                
853
-                // 如果所有片段的样式一致,提取到块级style
854
-                const allSegments = block.content;
855
-                if (allSegments.length > 0) {
856
-                  const firstStyle = allSegments[0].style;
857
-                  const allSameStyle = allSegments.every(seg => 
858
-                    JSON.stringify(seg.style) === JSON.stringify(firstStyle)
859
-                  );
860
-                  
861
-                  if (allSameStyle && Object.keys(firstStyle).length > 0) {
862
-                    // 所有片段样式一致,合并到块级style
863
-                    styleToSave = { ...block.style, ...firstStyle };
864
-                  }
865
-                }
866
-              }
867
-            }
845
+            const { content: contentToSave, style: styleToSave } = serializeBlockForSave(block);
868
             
846
             
869
             return blockService.updateBlock(
847
             return blockService.updateBlock(
870
               documentId, 
848
               documentId, 
@@ -901,23 +879,20 @@ export const useEditorStore = create<EditorStore>((set, get) => {
901
           }
879
           }
902
         });
880
         });
903
         
881
         
904
-        // 从脏块集合中移除成功的块
905
-        const { dirtyBlocks } = get();
906
-        const newDirtyBlocks = new Set(dirtyBlocks);
907
-        successIds.forEach(id => newDirtyBlocks.delete(id));
882
+        const latestState = get();
883
+        const newDirtyBlocks = new Set(latestState.dirtyBlocks);
884
+        const newBlockHashes = new Map(latestState.blockHashes);
885
+        successIds.forEach(id => {
886
+          const latestBlock = latestState.blocks.find((block) => block.id === id);
887
+          const savedHash = savedHashes.get(id);
888
+          if (latestBlock && savedHash === computeBlockHash(latestBlock)) {
889
+            newDirtyBlocks.delete(id);
890
+            newBlockHashes.set(id, savedHash);
891
+          }
892
+        });
908
         
893
         
909
         if (stillFailedIds.length > 0) {
894
         if (stillFailedIds.length > 0) {
910
           // 仍有块保存失败
895
           // 仍有块保存失败
911
-          
912
-          // 更新成功保存的块的哈希值
913
-          const newBlockHashes = new Map(get().blockHashes);
914
-          successIds.forEach(id => {
915
-            const block = blocks.find(b => b.id === id);
916
-            if (block) {
917
-              newBlockHashes.set(id, computeBlockHash(block));
918
-            }
919
-          });
920
-          
921
           set({ 
896
           set({ 
922
             isSaving: false,
897
             isSaving: false,
923
             failedBlocks: stillFailedIds,
898
             failedBlocks: stillFailedIds,
@@ -932,28 +907,23 @@ export const useEditorStore = create<EditorStore>((set, get) => {
932
           throw new Error(`${stillFailedIds.length} 个块保存失败`);
907
           throw new Error(`${stillFailedIds.length} 个块保存失败`);
933
         } else {
908
         } else {
934
           // 全部重试成功
909
           // 全部重试成功
935
-          const snapshot = JSON.stringify(blocks);
936
-          
937
-          // 更新所有成功保存的块的哈希值
938
-          const newBlockHashes = new Map(get().blockHashes);
939
-          successIds.forEach(id => {
940
-            const block = blocks.find(b => b.id === id);
941
-            if (block) {
942
-              newBlockHashes.set(id, computeBlockHash(block));
910
+          const latestBlocks = get().blocks;
911
+          const snapshots = newDirtyBlocks.size === 0
912
+            ? {
913
+              originalBlocksSnapshot: JSON.stringify(latestBlocks),
914
+              lastSavedSnapshot: JSON.stringify(latestBlocks),
943
             }
915
             }
944
-          });
945
-          
916
+            : {};
946
           set({ 
917
           set({ 
947
             isSaving: false,
918
             isSaving: false,
948
             hasModified: newDirtyBlocks.size > 0,
919
             hasModified: newDirtyBlocks.size > 0,
949
-            originalBlocksSnapshot: snapshot,
950
-            lastSavedSnapshot: snapshot,
951
             failedBlocks: [],
920
             failedBlocks: [],
952
             dirtyBlocks: newDirtyBlocks,
921
             dirtyBlocks: newDirtyBlocks,
953
-            blockHashes: newBlockHashes, // 更新哈希表
922
+            blockHashes: newBlockHashes,
954
             currentSavePromise: null,
923
             currentSavePromise: null,
955
             saveAbortController: null,
924
             saveAbortController: null,
956
             savingProgress: null,
925
             savingProgress: null,
926
+            ...snapshots,
957
           });
927
           });
958
         }
928
         }
959
       } catch (error: unknown) {
929
       } catch (error: unknown) {
@@ -1183,14 +1153,14 @@ export const useEditorStore = create<EditorStore>((set, get) => {
1183
       return;
1153
       return;
1184
     }
1154
     }
1185
 
1155
 
1186
-    set({ pendingStructuralOperations: pendingStructuralOperations + 1 });
1187
-    
1188
     // 找到要删除的块
1156
     // 找到要删除的块
1189
     const blockToDelete = blocks.find(b => b.id === id);
1157
     const blockToDelete = blocks.find(b => b.id === id);
1190
     if (!blockToDelete) {
1158
     if (!blockToDelete) {
1191
       return;
1159
       return;
1192
     }
1160
     }
1193
 
1161
 
1162
+    set({ pendingStructuralOperations: pendingStructuralOperations + 1 });
1163
+
1194
     const wasDirty = dirtyBlocks.has(id);
1164
     const wasDirty = dirtyBlocks.has(id);
1195
     
1165
     
1196
     // 先从本地状态删除(乐观更新)
1166
     // 先从本地状态删除(乐观更新)
@@ -1475,20 +1445,16 @@ export const useEditorStore = create<EditorStore>((set, get) => {
1475
     }
1445
     }
1476
     
1446
     
1477
     set({ isSaving: true, error: null });
1447
     set({ isSaving: true, error: null });
1448
+    const savedHash = currentHash;
1478
     
1449
     
1479
     try {
1450
     try {
1480
-      // 序列化表格块的content(将富文本数组转为纯字符串)
1481
-      let contentToSave = block.content;
1482
-      if (block.type === 'table') {
1483
-        const serializedTable = serializeTableBlock(block as TableBlock);
1484
-        contentToSave = serializedTable.content;
1485
-      }
1451
+      const { content: contentToSave, style: styleToSave } = serializeBlockForSave(block);
1486
       
1452
       
1487
       await blockService.updateBlock(documentId, id, {
1453
       await blockService.updateBlock(documentId, id, {
1488
         type: block.type,
1454
         type: block.type,
1489
         level: block.level,
1455
         level: block.level,
1490
         content: contentToSave,
1456
         content: contentToSave,
1491
-        style: block.style,
1457
+        style: styleToSave,
1492
         word_style: block.word_style,
1458
         word_style: block.word_style,
1493
         metadata: block.metadata,
1459
         metadata: block.metadata,
1494
         block_order: block.block_order,
1460
         block_order: block.block_order,
@@ -1500,13 +1466,17 @@ export const useEditorStore = create<EditorStore>((set, get) => {
1500
       const latestBlock = latestBlocks.find(b => b.id === id);
1466
       const latestBlock = latestBlocks.find(b => b.id === id);
1501
       
1467
       
1502
       if (latestBlock) {
1468
       if (latestBlock) {
1503
-        // 保存成功后,从脏块集合中移除该块
1469
+        const latestHash = computeBlockHash(latestBlock);
1470
+        if (latestHash !== savedHash) {
1471
+          set({ isSaving: false });
1472
+          return;
1473
+        }
1474
+
1504
         const newDirtyBlocks = new Set(latestDirtyBlocks);
1475
         const newDirtyBlocks = new Set(latestDirtyBlocks);
1505
         newDirtyBlocks.delete(id);
1476
         newDirtyBlocks.delete(id);
1506
         
1477
         
1507
-        // **关键修复**: 使用保存成功时的块内容计算哈希值
1508
         const newBlockHashes = new Map(latestBlockHashes);
1478
         const newBlockHashes = new Map(latestBlockHashes);
1509
-        newBlockHashes.set(id, computeBlockHash(latestBlock));
1479
+        newBlockHashes.set(id, savedHash);
1510
         
1480
         
1511
         set({ 
1481
         set({ 
1512
           isSaving: false,
1482
           isSaving: false,

+ 18 - 0
src/utils/download.ts

@@ -1,5 +1,23 @@
1
 const UNSAFE_FILE_NAME_CHARS = /[\\/:*?"<>|]/g;
1
 const UNSAFE_FILE_NAME_CHARS = /[\\/:*?"<>|]/g;
2
 
2
 
3
+export function isAllowedHttpUrl(value: string): boolean {
4
+  try {
5
+    const url = new URL(value, window.location.origin);
6
+    if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
7
+      return false;
8
+    }
9
+
10
+    if (!/^(https?:)?\/\//i.test(value)) {
11
+      return true;
12
+    }
13
+
14
+    const configuredBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
15
+    return url.origin === new URL(configuredBaseUrl).origin;
16
+  } catch {
17
+    return false;
18
+  }
19
+}
20
+
3
 export function safeFileName(fileName: string, fallback = 'download'): string {
21
 export function safeFileName(fileName: string, fallback = 'download'): string {
4
   const normalized = Array.from(fileName, (character) =>
22
   const normalized = Array.from(fileName, (character) =>
5
     character.charCodeAt(0) < 32 ? '_' : character
23
     character.charCodeAt(0) < 32 ? '_' : character