浏览代码

feat(编辑器): 提取图片验证逻辑,优化块删除时的脏块管理

- 新增 imageUpload.ts 工具模块,统一封装图片上传验证与尺寸验证逻辑
- 重构 BlockMenu 中的图片上传处理,使用提取的验证函数替代重复代码
- 优化 ImageBlock 图片上传流程,完整验证图片格式与尺寸
- 改进编辑存储中的块保存逻辑,仅清理未修改的块标记,避免覆盖并发编辑
- 增强块删除时的顺序更新,只发送顺序字段而非完整块快照
- 修复脏块集合管理,在块删除后正确更新状态
Zhang Yice 1 月之前
父节点
当前提交
6082c39f9b

+ 7 - 30
src/components/Editor/blocks/BlockMenu.tsx

@@ -10,6 +10,7 @@ import React from 'react';
10 10
 import { Button, Dropdown, Upload, message } from 'antd';
11 11
 import type { MenuProps } from 'antd';
12 12
 import type { RcFile } from 'antd/es/upload/interface';
13
+import { validateImageDimensions, validateImageUpload } from '../../../utils/imageUpload';
13 14
 import {
14 15
   DeleteOutlined,
15 16
   PlusOutlined,
@@ -74,24 +75,9 @@ export const BlockMenu: React.FC<BlockMenuProps> = ({
74 75
    * 处理图片上传
75 76
    */
76 77
   const handleImageUpload = (file: RcFile): boolean => {
77
-    // 文件大小限制(10MB)
78
-    const isLt10M = file.size / 1024 / 1024 < 10;
79
-    if (!isLt10M) {
80
-      message.error('图片大小不能超过10MB');
81
-      return false;
82
-    }
83
-
84
-    // 只允许浏览器可安全解码的常见栅格图片,避免将 SVG 等可执行载荷写入文档。
85
-    const allowedImageTypes = new Set([
86
-      'image/jpeg',
87
-      'image/png',
88
-      'image/gif',
89
-      'image/webp',
90
-      'image/bmp',
91
-    ]);
92
-    const isImage = allowedImageTypes.has(file.type.toLowerCase());
93
-    if (!isImage) {
94
-      message.error('只支持 JPG、PNG、GIF、WebP 或 BMP 图片');
78
+    const validationError = validateImageUpload(file);
79
+    if (validationError) {
80
+      message.error(validationError);
95 81
       return false;
96 82
     }
97 83
 
@@ -106,18 +92,9 @@ export const BlockMenu: React.FC<BlockMenuProps> = ({
106 92
       img.onload = () => {
107 93
         if (!isMountedRef.current) return;
108 94
 
109
-        const maxDimension = 10000;
110
-        const maxPixels = 40_000_000;
111
-        if (
112
-          !Number.isFinite(img.width) ||
113
-          !Number.isFinite(img.height) ||
114
-          img.width <= 0 ||
115
-          img.height <= 0 ||
116
-          img.width > maxDimension ||
117
-          img.height > maxDimension ||
118
-          img.width * img.height > maxPixels
119
-        ) {
120
-          message.error('图片尺寸过大,请选择较小的图片');
95
+        const dimensionError = validateImageDimensions(img.width, img.height);
96
+        if (dimensionError) {
97
+          message.error(dimensionError);
121 98
           return;
122 99
         }
123 100
 

+ 31 - 8
src/components/Editor/blocks/ImageBlock.tsx

@@ -16,6 +16,7 @@ import type { ImageBlock as ImageBlockType } from '../../../types/editor';
16 16
 import { useEditorStore } from '../../../stores/editorStore';
17 17
 import { BlockMenu } from './BlockMenu';
18 18
 import { ToolbarLauncher } from '../RichTextEditor/RichTextToolbar';
19
+import { validateImageDimensions, validateImageUpload } from '../../../utils/imageUpload';
19 20
 import './ImageBlock.css';
20 21
 
21 22
 export interface ImageBlockProps {
@@ -54,17 +55,39 @@ export const ImageBlock: React.FC<ImageBlockProps> = ({
54 55
   // 处理图片上传
55 56
   const handleUpload = useCallback(
56 57
     (file: File) => {
58
+      const validationError = validateImageUpload(file);
59
+      if (validationError) {
60
+        message.error(validationError);
61
+        return false;
62
+      }
63
+
57 64
       const reader = new FileReader();
58 65
       reader.onload = (e) => {
59 66
         const dataUrl = e.target?.result as string;
60
-        updateBlock(block.id, {
61
-          content: dataUrl,
62
-          metadata: {
63
-            ...block.metadata,
64
-            alt: file.name,
65
-          },
66
-        });
67
-        message.success('图片上传成功');
67
+        if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/')) {
68
+          message.error('图片格式无效');
69
+          return;
70
+        }
71
+
72
+        const image = new Image();
73
+        image.onload = () => {
74
+          const dimensionError = validateImageDimensions(image.width, image.height);
75
+          if (dimensionError) {
76
+            message.error(dimensionError);
77
+            return;
78
+          }
79
+
80
+          updateBlock(block.id, {
81
+            content: dataUrl,
82
+            metadata: {
83
+              ...block.metadata,
84
+              alt: file.name,
85
+            },
86
+          });
87
+          message.success('图片上传成功');
88
+        };
89
+        image.onerror = () => message.error('图片加载失败');
90
+        image.src = dataUrl;
68 91
       };
69 92
       reader.onerror = () => {
70 93
         message.error('图片读取失败');

+ 46 - 34
src/stores/editorStore.ts

@@ -588,30 +588,40 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
588 588
           
589 589
           throw new Error(`${failedIndices.length} 个块保存失败`);
590 590
         } else {
591
-          // 全部保存成功
592
-          const snapshot = JSON.stringify(blocks);
593
-          
594
-          // 更新成功保存的块的哈希值
595
-          const newBlockHashes = new Map(get().blockHashes);
591
+          // 仅清理本次保存成功且期间没有再次修改的块,避免覆盖保存期间的新编辑。
592
+          const latestState = get();
593
+          const newDirtyBlocks = new Set(latestState.dirtyBlocks);
594
+          const newBlockHashes = new Map(latestState.blockHashes);
596 595
           successIds.forEach(id => {
597
-            const block = blocks.find(b => b.id === id);
598
-            if (block) {
599
-              newBlockHashes.set(id, computeBlockHash(block));
596
+            const savedBlock = blocks.find(block => block.id === id);
597
+            const latestBlock = latestState.blocks.find(block => block.id === id);
598
+            if (!savedBlock || !latestBlock) return;
599
+
600
+            const savedHash = computeBlockHash(savedBlock);
601
+            const latestHash = computeBlockHash(latestBlock);
602
+            if (savedHash === latestHash) {
603
+              newBlockHashes.set(id, savedHash);
604
+              newDirtyBlocks.delete(id);
600 605
             }
601 606
           });
602
-          
603
-          set({ 
607
+
608
+          const snapshots = newDirtyBlocks.size === 0
609
+            ? {
610
+              originalBlocksSnapshot: JSON.stringify(latestState.blocks),
611
+              lastSavedSnapshot: JSON.stringify(latestState.blocks),
612
+            }
613
+            : {};
614
+          set({
604 615
             isSaving: false,
605
-            hasModified: false,
606
-            originalBlocksSnapshot: snapshot,
607
-            lastSavedSnapshot: snapshot,
616
+            hasModified: newDirtyBlocks.size > 0,
608 617
             failedBlocks: [],
609
-            dirtyBlocks: new Set<string>(),
618
+            dirtyBlocks: newDirtyBlocks,
610 619
             blockHashes: newBlockHashes,
611 620
             currentSavePromise: null,
612 621
             saveAbortController: null,
613 622
             savingProgress: null,
614 623
             lastSaveTime: Date.now(),
624
+            ...snapshots,
615 625
           });
616 626
         }
617 627
       } catch (error: unknown) {
@@ -1024,6 +1034,8 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
1024 1034
     if (!blockToDelete) {
1025 1035
       return;
1026 1036
     }
1037
+
1038
+    const wasDirty = dirtyBlocks.has(id);
1027 1039
     
1028 1040
     // 先从本地状态删除(乐观更新)
1029 1041
     const newBlocks = blocks.filter((block) => block.id !== id);
@@ -1065,25 +1077,13 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
1065 1077
       
1066 1078
       // 3. 如果有块需要更新顺序,批量调用 PUT API 更新
1067 1079
       if (blocksNeedUpdate.length > 0) {
1068
-        // 并发调用 PUT API 更新所有受影响的块
1069
-        const updatePromises = blocksNeedUpdate.map(({ block, newOrder }) => {
1070
-          // 对于TOC块,只更新block_order,不传递其他字段
1071
-          // 因为后端对TOC块有严格的验证限制
1072
-          if (block.type === 'toc') {
1073
-            return blockService.updateBlock(documentId, block.id, {
1074
-              block_order: newOrder,
1075
-            });
1076
-          }
1077
-          
1078
-          // 非TOC块:传递完整的数据
1079
-          return blockService.updateBlock(documentId, block.id, {
1080
-            content: block.content,
1081
-            style: block.style,
1082
-            word_style: block.word_style,
1083
-            metadata: block.metadata,
1080
+        // 并发调用 PUT API 更新所有受影响块的顺序。
1081
+        // 只发送顺序字段,避免用删除前的块快照覆盖并发编辑。
1082
+        const updatePromises = blocksNeedUpdate.map(({ block, newOrder }) =>
1083
+          blockService.updateBlock(documentId, block.id, {
1084 1084
             block_order: newOrder,
1085
-          });
1086
-        });
1085
+          })
1086
+        );
1087 1087
         
1088 1088
         await Promise.all(updatePromises);
1089 1089
         
@@ -1106,14 +1106,19 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
1106 1106
         });
1107 1107
         
1108 1108
         // 5. 更新本地状态,不标记为已修改(因为后端已经同步)
1109
+        const latestState = get();
1109 1110
         set({ 
1110 1111
           blocks: updatedBlocks,
1111 1112
           blockHashes: updatedBlockHashes,
1112
-          hasModified: false, // 所有操作都已同步到后端
1113
+          dirtyBlocks: latestState.dirtyBlocks,
1114
+          hasModified: latestState.dirtyBlocks.size > 0,
1113 1115
         });
1114 1116
       } else {
1115 1117
         // 没有块需要更新顺序(可能删除的是最后一个块)
1116
-        set({ hasModified: false }); // 标记为已同步
1118
+        const latestState = get();
1119
+        set({
1120
+          hasModified: latestState.dirtyBlocks.size > 0,
1121
+        });
1117 1122
       }
1118 1123
     } catch (error: unknown) {
1119 1124
       // 删除或更新失败,回滚本地状态
@@ -1130,10 +1135,17 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
1130 1135
         if (wasOriginalBlock) {
1131 1136
           restoredBlockHashes.set(id, computeBlockHash(blockToDelete));
1132 1137
         }
1138
+
1139
+        const restoredDirtyBlocks = new Set(get().dirtyBlocks);
1140
+        if (wasDirty) {
1141
+          restoredDirtyBlocks.add(id);
1142
+        }
1133 1143
         
1134 1144
         set({ 
1135 1145
           blocks: restoredBlocks,
1136 1146
           blockHashes: restoredBlockHashes,
1147
+          dirtyBlocks: restoredDirtyBlocks,
1148
+          hasModified: restoredDirtyBlocks.size > 0,
1137 1149
         });
1138 1150
       }
1139 1151
       

+ 39 - 0
src/utils/imageUpload.ts

@@ -0,0 +1,39 @@
1
+const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024;
2
+export const MAX_IMAGE_DIMENSION = 10000;
3
+export const MAX_IMAGE_PIXELS = 40_000_000;
4
+
5
+const ALLOWED_IMAGE_TYPES = new Set([
6
+  'image/jpeg',
7
+  'image/png',
8
+  'image/gif',
9
+  'image/webp',
10
+  'image/bmp',
11
+]);
12
+
13
+export function validateImageUpload(file: Pick<File, 'size' | 'type'>): string | null {
14
+  if (!Number.isFinite(file.size) || file.size <= 0 || file.size > MAX_IMAGE_SIZE_BYTES) {
15
+    return '图片大小必须大于0且不能超过10MB';
16
+  }
17
+
18
+  if (!ALLOWED_IMAGE_TYPES.has(file.type.toLowerCase())) {
19
+    return '只支持 JPG、PNG、GIF、WebP 或 BMP 图片';
20
+  }
21
+
22
+  return null;
23
+}
24
+
25
+export function validateImageDimensions(width: number, height: number): string | null {
26
+  if (
27
+    !Number.isFinite(width) ||
28
+    !Number.isFinite(height) ||
29
+    width <= 0 ||
30
+    height <= 0 ||
31
+    width > MAX_IMAGE_DIMENSION ||
32
+    height > MAX_IMAGE_DIMENSION ||
33
+    width * height > MAX_IMAGE_PIXELS
34
+  ) {
35
+    return '图片尺寸过大,请选择较小的图片';
36
+  }
37
+
38
+  return null;
39
+}