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

feat (编辑器):优化目录区块交互能力,完善表格单元格拖拽缩放功能
· 为目录区块新增切换按钮,支持目录内容显示 / 隐藏
· 实现可折叠目录界面,配套展开 / 收起图标与平滑过渡动画
· 为表格区块添加行列拖拽缩放功能,支持鼠标拖动调整宽高
· 提取编辑器全局基础字号、字体族配置,统一全文文字渲染样式
· 持久化存储表格缩放状态(缩放类型、行列索引、拖拽位置、尺寸数值)
· 更新目录区块元数据,记录折叠 / 展开状态,实现数据持久保存
· 为目录容器添加淡入动画,提升用户使用体验
· 优化表格单元格样式,拖拽操作时提供更清晰的视觉反馈
· 升级富文本转换器,支持传入字体样式参数以定制 HTML 导出内容
· 新增缩放状态类型接口,统一管理表格行列的缩放操作逻辑

Zhang Yice 1 місяць тому
батько
коміт
23b6c3ba68

+ 9 - 2
src/components/Editor/RichTextEditor/RichTextEditor.tsx

@@ -84,13 +84,20 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
84 84
       return;
85 85
     }
86 86
     
87
-    const html = richTextToHtml(value);
87
+    // 提取baseStyle中的字号和字体
88
+    const baseFontSize = baseStyle.fontSize ? parseFloat(String(baseStyle.fontSize)) : undefined;
89
+    const baseFontFamily = baseStyle.fontFamily ? String(baseStyle.fontFamily) : undefined;
90
+    
91
+    const html = richTextToHtml(value, {
92
+      fontSize: baseFontSize,
93
+      fontFamily: baseFontFamily,
94
+    });
88 95
     
89 96
     // 只在内容真正改变时更新,避免光标跳动
90 97
     if (editorRef.current.innerHTML !== html) {
91 98
       editorRef.current.innerHTML = html;
92 99
     }
93
-  }, [value]);
100
+  }, [value, baseStyle.fontSize, baseStyle.fontFamily]);
94 101
 
95 102
   // ── 自动聚焦 ───────────────────────────────────────────────────────────────
96 103
   useEffect(() => {

+ 48 - 0
src/components/Editor/blocks/TOCBlock.css

@@ -6,11 +6,59 @@
6 6
   margin: 16px 0;
7 7
 }
8 8
 
9
+.toc-toggle-button {
10
+  display: flex;
11
+  align-items: center;
12
+  gap: 8px;
13
+  padding: 10px 16px;
14
+  background-color: #ffffff;
15
+  border: 1px solid #d9d9d9;
16
+  border-radius: 6px;
17
+  cursor: pointer;
18
+  transition: all 0.2s ease;
19
+  font-size: 14px;
20
+  font-weight: 500;
21
+  color: #262626;
22
+  width: 100%;
23
+  justify-content: center;
24
+}
25
+
26
+.toc-toggle-button:hover {
27
+  background-color: #f5f7fa;
28
+  border-color: #40a9ff;
29
+  color: #1890ff;
30
+}
31
+
32
+.toc-toggle-button:active {
33
+  background-color: #e6f7ff;
34
+}
35
+
36
+.toc-toggle-icon {
37
+  font-size: 12px;
38
+  transition: transform 0.2s ease;
39
+}
40
+
41
+.toc-toggle-text {
42
+  font-size: 14px;
43
+}
44
+
9 45
 .toc-block {
10 46
   padding: 20px;
11 47
   background-color: #f5f7fa;
12 48
   border: 2px dashed #d9d9d9;
13 49
   border-radius: 8px;
50
+  animation: fadeIn 0.2s ease-in;
51
+}
52
+
53
+@keyframes fadeIn {
54
+  from {
55
+    opacity: 0;
56
+    transform: translateY(-10px);
57
+  }
58
+  to {
59
+    opacity: 1;
60
+    transform: translateY(0);
61
+  }
14 62
 }
15 63
 
16 64
 .toc-title {

+ 36 - 14
src/components/Editor/blocks/TOCBlock.tsx

@@ -8,6 +8,7 @@
8 8
 
9 9
 import React from 'react';
10 10
 import type { TOCBlock as TOCBlockType } from '../../../types/editor';
11
+import { useEditorStore } from '../../../stores/editorStore';
11 12
 import './TOCBlock.css';
12 13
 
13 14
 export interface TOCBlockProps {
@@ -19,25 +20,46 @@ export interface TOCBlockProps {
19 20
  * TOCBlock - 目录块(只读)
20 21
  * 
21 22
  * TOC块是自动生成的,用户无法编辑
23
+ * 通过按钮控制删除/添加目录块内容
22 24
  */
23 25
 export const TOCBlock: React.FC<TOCBlockProps> = ({ block }) => {
26
+  const { updateBlock } = useEditorStore();
27
+  
28
+  // 检查块是否被隐藏(通过metadata中的hidden标志)
29
+  const isHidden = (block.metadata as any)?.hidden === true;
30
+
31
+  const handleToggle = () => {
32
+    // 切换隐藏状态
33
+    updateBlock(block.id, {
34
+      metadata: {
35
+        ...block.metadata,
36
+        hidden: !isHidden,
37
+      },
38
+    });
39
+  };
40
+
24 41
   return (
25 42
     <div className="toc-block-wrapper" data-block-id={block.id}>
26
-      <div className="toc-block">
27
-        <div className="toc-title">{block.content.title || '目录'}</div>
28
-        <div className="toc-notice">
29
-          <span className="toc-icon">ℹ️</span>
30
-          <span>目录内容由系统自动生成,无法手动编辑</span>
31
-        </div>
32
-        <div className="toc-placeholder">
33
-          <p>目录将在导出文档时自动生成,包含以下内容:</p>
34
-          <ul>
35
-            <li>文档中的所有标题</li>
36
-            <li>页码(在打印版本中)</li>
37
-            <li>可点击的超链接(在电子版本中)</li>
38
-          </ul>
43
+      <button 
44
+        className="toc-toggle-button"
45
+        onClick={handleToggle}
46
+        type="button"
47
+      >
48
+        <span className="toc-toggle-icon">{isHidden ? '▶' : '▼'}</span>
49
+        <span className="toc-toggle-text">
50
+          {isHidden ? '添加目录' : '删除目录'}
51
+        </span>
52
+      </button>
53
+      
54
+      {!isHidden && (
55
+        <div className="toc-block">
56
+          <div className="toc-title">{block.content.title || '目录'}</div>
57
+          <div className="toc-notice">
58
+            <span className="toc-icon">ℹ️</span>
59
+            <span>目录内容由系统自动生成,无法手动编辑</span>
60
+          </div>
39 61
         </div>
40
-      </div>
62
+      )}
41 63
     </div>
42 64
   );
43 65
 };

+ 138 - 1
src/components/Editor/blocks/TableBlock.tsx

@@ -11,6 +11,17 @@ import { TableCell } from './TableCell';
11 11
 import { TableToolbar } from './TableToolbar';
12 12
 import './TableBlock.css';
13 13
 
14
+// ══════════════════════════════════════════════════════════════════════════════
15
+// Types
16
+// ══════════════════════════════════════════════════════════════════════════════
17
+
18
+interface ResizeState {
19
+  type: 'column' | 'row';
20
+  index: number;
21
+  startPos: number;
22
+  startSize: number;
23
+}
24
+
14 25
 export interface TableBlockProps {
15 26
   block: TableBlockType;
16 27
   readOnly?: boolean;
@@ -38,6 +49,10 @@ export const TableBlock: React.FC<TableBlockProps> = ({
38 49
     endCol: number;
39 50
   } | null>(null);
40 51
   
52
+  // 拖动调整尺寸状态
53
+  const [resizeState, setResizeState] = useState<ResizeState | null>(null);
54
+  const tableRef = useRef<HTMLTableElement>(null);
55
+  
41 56
   // 防抖计时器
42 57
   const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
43 58
   const lastContentRef = useRef<string>(JSON.stringify(block.content));
@@ -53,6 +68,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
53 68
             if (cIdx !== colIndex) return cell;
54 69
             return { ...cell, ...updates };
55 70
           }),
71
+          height: row.height, // 保留行高
56 72
         };
57 73
       });
58 74
 
@@ -111,6 +127,119 @@ export const TableBlock: React.FC<TableBlockProps> = ({
111 127
     }
112 128
   }, [selectedCell]);
113 129
 
130
+  // ══════════════════════════════════════════════════════════════════════════════
131
+  // 列宽调整
132
+  // ══════════════════════════════════════════════════════════════════════════════
133
+
134
+  // 开始调整列宽
135
+  const handleColumnResizeStart = useCallback((colIndex: number, e: React.MouseEvent) => {
136
+    if (readOnly) return;
137
+    
138
+    e.preventDefault();
139
+    e.stopPropagation();
140
+    
141
+    const colWidths = block.metadata.col_widths;
142
+    setResizeState({
143
+      type: 'column',
144
+      index: colIndex,
145
+      startPos: e.clientX,
146
+      startSize: colWidths[colIndex],
147
+    });
148
+  }, [block.metadata.col_widths, readOnly]);
149
+
150
+  // 开始调整行高
151
+  const handleRowResizeStart = useCallback((rowIndex: number, e: React.MouseEvent) => {
152
+    if (readOnly) return;
153
+    
154
+    e.preventDefault();
155
+    e.stopPropagation();
156
+    
157
+    const currentHeight = block.content.rows[rowIndex].height || 20;
158
+    setResizeState({
159
+      type: 'row',
160
+      index: rowIndex,
161
+      startPos: e.clientY,
162
+      startSize: currentHeight,
163
+    });
164
+  }, [block.content.rows, readOnly]);
165
+
166
+  // 处理拖动
167
+  const handleMouseMove = useCallback((e: MouseEvent) => {
168
+    if (!resizeState || !tableRef.current) return;
169
+
170
+    if (resizeState.type === 'column') {
171
+      // 调整列宽
172
+      const deltaX = e.clientX - resizeState.startPos;
173
+      const tableWidth = tableRef.current.offsetWidth;
174
+      const deltaPercent = (deltaX / tableWidth) * 100;
175
+      
176
+      const newColWidths = [...block.metadata.col_widths];
177
+      const newWidth = Math.max(5, resizeState.startSize + deltaPercent); // 最小5%
178
+      
179
+      // 如果有下一列,调整下一列宽度以保持总宽度不变
180
+      if (resizeState.index < newColWidths.length - 1) {
181
+        const nextColWidth = newColWidths[resizeState.index + 1];
182
+        const widthDiff = newWidth - resizeState.startSize;
183
+        const newNextWidth = Math.max(5, nextColWidth - widthDiff);
184
+        
185
+        newColWidths[resizeState.index] = newWidth;
186
+        newColWidths[resizeState.index + 1] = newNextWidth;
187
+      } else {
188
+        newColWidths[resizeState.index] = newWidth;
189
+      }
190
+      
191
+      updateBlock(block.id, {
192
+        metadata: {
193
+          ...block.metadata,
194
+          col_widths: newColWidths,
195
+        },
196
+      });
197
+    } else if (resizeState.type === 'row') {
198
+      // 调整行高
199
+      const deltaY = e.clientY - resizeState.startPos;
200
+      // 每像素约等于0.75pt
201
+      const deltaPt = deltaY * 0.75;
202
+      const newHeight = Math.max(15, resizeState.startSize + deltaPt); // 最小15pt
203
+      
204
+      const newRows = block.content.rows.map((row, idx) => {
205
+        if (idx !== resizeState.index) return row;
206
+        return { ...row, height: newHeight };
207
+      });
208
+      
209
+      updateBlock(block.id, {
210
+        content: { rows: newRows },
211
+      });
212
+    }
213
+  }, [resizeState, block.id, block.metadata, block.content.rows, updateBlock]);
214
+
215
+  // 结束拖动
216
+  const handleMouseUp = useCallback(() => {
217
+    if (resizeState && documentId && !readOnly) {
218
+      // 保存更改
219
+      saveBlock(block.id).catch(() => {
220
+        // Auto-save failed
221
+      });
222
+    }
223
+    setResizeState(null);
224
+  }, [resizeState, documentId, readOnly, saveBlock, block.id]);
225
+
226
+  // 绑定全局鼠标事件
227
+  useEffect(() => {
228
+    if (resizeState) {
229
+      document.addEventListener('mousemove', handleMouseMove);
230
+      document.addEventListener('mouseup', handleMouseUp);
231
+      document.body.style.cursor = resizeState.type === 'column' ? 'col-resize' : 'row-resize';
232
+      document.body.style.userSelect = 'none';
233
+      
234
+      return () => {
235
+        document.removeEventListener('mousemove', handleMouseMove);
236
+        document.removeEventListener('mouseup', handleMouseUp);
237
+        document.body.style.cursor = '';
238
+        document.body.style.userSelect = '';
239
+      };
240
+    }
241
+  }, [resizeState, handleMouseMove, handleMouseUp]);
242
+
114 243
   // 计算表格宽度
115 244
   const tableWidth = block.metadata.table_width;
116 245
   const tableWidthUnit = block.metadata.table_width_unit;
@@ -142,6 +271,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
142 271
       {/* 表格 */}
143 272
       <div className="table-container">
144 273
         <table
274
+          ref={tableRef}
145 275
           className="table-block"
146 276
           style={{
147 277
             width: tableWidthStyle,
@@ -155,7 +285,12 @@ export const TableBlock: React.FC<TableBlockProps> = ({
155 285
           </colgroup>
156 286
           <tbody>
157 287
             {block.content.rows.map((row, rowIndex) => (
158
-              <tr key={rowIndex}>
288
+              <tr 
289
+                key={rowIndex}
290
+                style={{
291
+                  height: row.height ? `${row.height}pt` : undefined,
292
+                }}
293
+              >
159 294
                 {row.cells.map((cell, colIndex) => {
160 295
                   // 检查单元格是否在选择范围内
161 296
                   const isInRange = selectedRange
@@ -178,6 +313,8 @@ export const TableBlock: React.FC<TableBlockProps> = ({
178 313
                       }
179 314
                       onChange={handleCellChange}
180 315
                       onClick={handleCellClick}
316
+                      onColumnResize={colIndex < colWidths.length - 1 ? handleColumnResizeStart : undefined}
317
+                      onRowResize={rowIndex < block.content.rows.length - 1 ? handleRowResizeStart : undefined}
181 318
                     />
182 319
                   );
183 320
                 })}

+ 117 - 0
src/components/Editor/blocks/TableCell.css

@@ -25,3 +25,120 @@
25 25
 .table-cell .rich-text-editor {
26 26
   min-height: 20px;
27 27
 }
28
+
29
+/* ══════════════════════════════════════════════════════════════════════════════
30
+   调整尺寸手柄
31
+   ══════════════════════════════════════════════════════════════════════════════ */
32
+
33
+/* 调整手柄基础样式 */
34
+.resize-handle {
35
+  position: absolute;
36
+  background-color: transparent;
37
+  transition: background-color 0.15s ease;
38
+  z-index: 10;
39
+  opacity: 0;
40
+}
41
+
42
+/* 鼠标悬停在单元格上时显示手柄 */
43
+.table-cell:hover .resize-handle {
44
+  opacity: 1;
45
+}
46
+
47
+/* 拖动时手柄高亮 */
48
+.resize-handle:active,
49
+.resize-handle:hover {
50
+  background-color: rgba(24, 144, 255, 0.5);
51
+  opacity: 1;
52
+}
53
+
54
+/* 列宽调整手柄 - 右边界 */
55
+.resize-handle-column {
56
+  top: 0;
57
+  right: -4px;
58
+  width: 8px;
59
+  height: 100%;
60
+  cursor: col-resize;
61
+}
62
+
63
+.resize-handle-column::before {
64
+  content: '';
65
+  position: absolute;
66
+  top: 50%;
67
+  left: 50%;
68
+  transform: translate(-50%, -50%);
69
+  width: 2px;
70
+  height: 60%;
71
+  background-color: rgba(24, 144, 255, 0.6);
72
+  border-radius: 1px;
73
+  opacity: 0;
74
+  transition: opacity 0.15s ease;
75
+}
76
+
77
+.resize-handle-column:hover::before,
78
+.table-cell:hover .resize-handle-column::before {
79
+  opacity: 1;
80
+}
81
+
82
+/* 行高调整手柄 - 下边界 */
83
+.resize-handle-row {
84
+  left: 0;
85
+  bottom: -4px;
86
+  width: 100%;
87
+  height: 8px;
88
+  cursor: row-resize;
89
+}
90
+
91
+.resize-handle-row::before {
92
+  content: '';
93
+  position: absolute;
94
+  top: 50%;
95
+  left: 50%;
96
+  transform: translate(-50%, -50%);
97
+  width: 60%;
98
+  height: 2px;
99
+  background-color: rgba(24, 144, 255, 0.6);
100
+  border-radius: 1px;
101
+  opacity: 0;
102
+  transition: opacity 0.15s ease;
103
+}
104
+
105
+.resize-handle-row:hover::before,
106
+.table-cell:hover .resize-handle-row::before {
107
+  opacity: 1;
108
+}
109
+
110
+/* 角落调整手柄 - 右下角 */
111
+.resize-handle-corner {
112
+  right: -4px;
113
+  bottom: -4px;
114
+  width: 12px;
115
+  height: 12px;
116
+  cursor: nwse-resize;
117
+  background-color: rgba(24, 144, 255, 0.3);
118
+  border: 1px solid rgba(24, 144, 255, 0.5);
119
+  border-radius: 2px;
120
+  opacity: 0;
121
+}
122
+
123
+.resize-handle-corner:hover,
124
+.table-cell:hover .resize-handle-corner {
125
+  background-color: rgba(24, 144, 255, 0.6);
126
+  border-color: rgba(24, 144, 255, 0.8);
127
+  opacity: 1;
128
+}
129
+
130
+/* 只读模式下完全隐藏调整手柄 */
131
+.table-cell[readonly] .resize-handle {
132
+  display: none;
133
+}
134
+
135
+/* 拖动时的全局样式 */
136
+body.resizing {
137
+  cursor: col-resize !important;
138
+  user-select: none !important;
139
+}
140
+
141
+body.resizing-row {
142
+  cursor: row-resize !important;
143
+  user-select: none !important;
144
+}

+ 56 - 1
src/components/Editor/blocks/TableCell.tsx

@@ -29,6 +29,10 @@ export interface TableCellProps {
29 29
   onChange?: (rowIndex: number, colIndex: number, updates: Partial<TableCellType>) => void;
30 30
   /** 单元格点击回调 */
31 31
   onClick?: (rowIndex: number, colIndex: number, shiftKey: boolean) => void;
32
+  /** 列宽调整回调 */
33
+  onColumnResize?: (colIndex: number, e: React.MouseEvent) => void;
34
+  /** 行高调整回调 */
35
+  onRowResize?: (rowIndex: number, e: React.MouseEvent) => void;
32 36
 }
33 37
 
34 38
 // ══════════════════════════════════════════════════════════════════════════════
@@ -52,6 +56,8 @@ export const TableCell: React.FC<TableCellProps> = ({
52 56
   isSelected = false,
53 57
   onChange,
54 58
   onClick,
59
+  onColumnResize,
60
+  onRowResize,
55 61
 }) => {
56 62
   // 被合并的单元格不渲染
57 63
   if (cell.rowspan === 0 || cell.colspan === 0) {
@@ -61,6 +67,12 @@ export const TableCell: React.FC<TableCellProps> = ({
61 67
   // 解析样式
62 68
   const cellStyle = cellStyleToCSS(cell.style);
63 69
 
70
+  // 提取字体相关属性
71
+  // 优先级:cell.style > 默认值(12pt)
72
+  // 注意:如果 font_size 明确为 0,说明需要使用默认值
73
+  const fontSize = cell.style?.font_size || 12;
74
+  const fontFamily = cell.style?.font_name || '宋体';
75
+
64 76
   // 处理内容变更
65 77
   const handleContentChange = useCallback(
66 78
     (newText: RichText[]) => {
@@ -74,6 +86,18 @@ export const TableCell: React.FC<TableCellProps> = ({
74 86
     onClick?.(rowIndex, colIndex, e.shiftKey);
75 87
   }, [rowIndex, colIndex, onClick]);
76 88
 
89
+  // 处理列宽调整
90
+  const handleColumnResizeMouseDown = useCallback((e: React.MouseEvent) => {
91
+    e.stopPropagation();
92
+    onColumnResize?.(colIndex, e);
93
+  }, [colIndex, onColumnResize]);
94
+
95
+  // 处理行高调整
96
+  const handleRowResizeMouseDown = useCallback((e: React.MouseEvent) => {
97
+    e.stopPropagation();
98
+    onRowResize?.(rowIndex, e);
99
+  }, [rowIndex, onRowResize]);
100
+
77 101
   return (
78 102
     <td
79 103
       className={`table-cell ${isSelected ? 'selected' : ''}`}
@@ -91,11 +115,42 @@ export const TableCell: React.FC<TableCellProps> = ({
91 115
         placeholder=""
92 116
         singleLine={false}
93 117
         baseStyle={{
94
-          fontSize: '12px',
118
+          fontSize: `${fontSize}pt`,
119
+          fontFamily: fontFamily,
95 120
           lineHeight: 1.5,
96 121
           textAlign: 'center', // 强制居中对齐
97 122
         }}
98 123
       />
124
+      
125
+      {/* 列宽调整手柄 */}
126
+      {!readOnly && onColumnResize && (
127
+        <div
128
+          className="resize-handle resize-handle-column"
129
+          onMouseDown={handleColumnResizeMouseDown}
130
+          title="拖动调整列宽"
131
+        />
132
+      )}
133
+      
134
+      {/* 行高调整手柄 */}
135
+      {!readOnly && onRowResize && (
136
+        <div
137
+          className="resize-handle resize-handle-row"
138
+          onMouseDown={handleRowResizeMouseDown}
139
+          title="拖动调整行高"
140
+        />
141
+      )}
142
+      
143
+      {/* 角落调整手柄(同时调整行高和列宽) */}
144
+      {!readOnly && onColumnResize && onRowResize && (
145
+        <div
146
+          className="resize-handle resize-handle-corner"
147
+          onMouseDown={(e) => {
148
+            // 同时触发两个方向的调整
149
+            handleColumnResizeMouseDown(e);
150
+          }}
151
+          title="拖动调整尺寸"
152
+        />
153
+      )}
99 154
     </td>
100 155
   );
101 156
 };

+ 45 - 14
src/stores/editorStore.ts

@@ -18,8 +18,10 @@ import type {
18 18
   PartialBlock,
19 19
   BlockUpdate,
20 20
   BlockType,
21
+  TableBlock,
21 22
 } from '../types/editor';
22 23
 import { blockService } from '../services/blockService';
24
+import { normalizeTableBlock, serializeTableBlock } from '../utils/blockOperations';
23 25
 
24 26
 // 并发保存限制器(最多同时进行 5 个请求)
25 27
 const saveConcurrencyLimit = pLimit(5);
@@ -254,19 +256,27 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
254 256
     try {
255 257
       const data = await blockService.getBlocks(documentId);
256 258
       
259
+      // 规范化表格块的数据结构,确保与后端期望格式一致
260
+      const normalizedBlocks = data.blocks.map(block => {
261
+        if (block.type === 'table') {
262
+          return normalizeTableBlock(block as TableBlock);
263
+        }
264
+        return block;
265
+      });
266
+      
257 267
       // 保存原始blocks快照,用于检测修改
258
-      const snapshot = JSON.stringify(data.blocks);
268
+      const snapshot = JSON.stringify(normalizedBlocks);
259 269
       
260 270
       // 计算所有块的初始哈希值
261 271
       const initialHashes = new Map<string, string>();
262
-      data.blocks.forEach(block => {
272
+      normalizedBlocks.forEach(block => {
263 273
         initialHashes.set(block.id, computeBlockHash(block));
264 274
       });
265 275
       
266 276
       set({
267 277
         documentId,
268 278
         documentTitle: '未命名文档', // 后端不返回title,使用默认值
269
-        blocks: data.blocks,
279
+        blocks: normalizedBlocks,
270 280
         isLoading: false,
271 281
         hasModified: false, // 初始状态为未修改
272 282
         originalBlocksSnapshot: snapshot,
@@ -353,12 +363,19 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
353 363
         
354 364
         // 使用并发限制器并行保存(最多同时 5 个请求)
355 365
         const tasks = blocksToSave.map(block => 
356
-          saveConcurrencyLimit(() => 
357
-            blockService.updateBlock(
366
+          saveConcurrencyLimit(() => {
367
+            // 序列化表格块的content(将富文本数组转为纯字符串)
368
+            let contentToSave = block.content;
369
+            if (block.type === 'table') {
370
+              const serializedTable = serializeTableBlock(block as TableBlock);
371
+              contentToSave = serializedTable.content;
372
+            }
373
+            
374
+            return blockService.updateBlock(
358 375
               documentId, 
359 376
               block.id, 
360 377
               {
361
-                content: block.content as any,
378
+                content: contentToSave as any,
362 379
                 style: block.style,
363 380
                 word_style: block.word_style,
364 381
                 metadata: block.metadata,
@@ -369,8 +386,8 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
369 386
               completedCount++;
370 387
               set({ savingProgress: { current: completedCount, total: totalBlocks } });
371 388
               return result;
372
-            })
373
-          )
389
+            });
390
+          })
374 391
         );
375 392
         
376 393
         // 等待所有任务完成(使用 allSettled 容错)
@@ -529,12 +546,19 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
529 546
         
530 547
         // 使用并发限制器重试保存
531 548
         const tasks = blocksToRetry.map(block => 
532
-          saveConcurrencyLimit(() => 
533
-            blockService.updateBlock(
549
+          saveConcurrencyLimit(() => {
550
+            // 序列化表格块的content(将富文本数组转为纯字符串)
551
+            let contentToSave = block.content;
552
+            if (block.type === 'table') {
553
+              const serializedTable = serializeTableBlock(block as TableBlock);
554
+              contentToSave = serializedTable.content;
555
+            }
556
+            
557
+            return blockService.updateBlock(
534 558
               documentId, 
535 559
               block.id, 
536 560
               {
537
-                content: block.content as any,
561
+                content: contentToSave as any,
538 562
                 style: block.style,
539 563
                 word_style: block.word_style,
540 564
                 metadata: block.metadata,
@@ -545,8 +569,8 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
545 569
               completedCount++;
546 570
               set({ savingProgress: { current: completedCount, total: totalBlocks } });
547 571
               return result;
548
-            })
549
-          )
572
+            });
573
+          })
550 574
         );
551 575
         
552 576
         const results = await Promise.allSettled(tasks);
@@ -861,8 +885,15 @@ export const useEditorStore = create<EditorStore>((set, get) => ({
861 885
     set({ isSaving: true, error: null });
862 886
     
863 887
     try {
888
+      // 序列化表格块的content(将富文本数组转为纯字符串)
889
+      let contentToSave = block.content;
890
+      if (block.type === 'table') {
891
+        const serializedTable = serializeTableBlock(block as TableBlock);
892
+        contentToSave = serializedTable.content;
893
+      }
894
+      
864 895
       await blockService.updateBlock(documentId, id, {
865
-        content: block.content as any, // 类型断言:不同block类型的content类型不同
896
+        content: contentToSave as any, // 类型断言:不同block类型的content类型不同
866 897
         style: block.style,
867 898
         word_style: block.word_style,
868 899
         metadata: block.metadata,

+ 3 - 7
src/types/editor.ts

@@ -104,13 +104,6 @@ export interface CellStyleOverrides extends StyleOverrides {
104 104
   border_color?: string;
105 105
   border_width?: number;
106 106
   vertical_align?: 'top' | 'center' | 'bottom';
107
-  // 合并单元格时保存的原始数据(包含text和style)
108
-  _mergedCells?: Array<{
109
-    rowOffset: number;
110
-    colOffset: number;
111
-    text: string | RichText[];
112
-    style?: CellStyleOverrides; // 保存原始样式
113
-  }>;
114 107
 }
115 108
 
116 109
 /**
@@ -120,8 +113,10 @@ export interface TableCell {
120 113
   text: string | RichText[];
121 114
   rowspan: number;
122 115
   colspan: number;
116
+  col_index?: number;     // 列索引(从1开始)
123 117
   width?: number;         // 单元格宽度(磅)
124 118
   style: CellStyleOverrides;
119
+  word_style?: string;    // Word样式名
125 120
 }
126 121
 
127 122
 /**
@@ -209,6 +204,7 @@ export interface TOCMetadata {
209 204
   is_auto_generated: boolean;
210 205
   readonly: boolean;
211 206
   deletable: boolean;
207
+  hidden?: boolean; // 目录是否隐藏
212 208
 }
213 209
 
214 210
 export interface TOCBlock extends BaseBlock {

+ 248 - 79
src/utils/blockOperations.ts

@@ -99,16 +99,155 @@ export function rebalanceBlockOrders(blocks: DocumentBlock[]): DocumentBlock[] {
99 99
 // ══════════════════════════════════════════════════════════════════════════════
100 100
 
101 101
 /**
102
+ * 将 text 字段从富文本数组转换为纯字符串
103
+ * 
104
+ * @param text 文本内容(字符串或富文本数组)
105
+ * @returns 纯字符串
106
+ */
107
+export function flattenTextToString(text: string | RichText[]): string {
108
+  if (typeof text === 'string') {
109
+    return text;
110
+  }
111
+  // 如果是 RichText 数组,提取所有的纯文本并连接
112
+  return text.map(seg => seg.text).join('');
113
+}
114
+
115
+/**
116
+ * 序列化表格单元格为后端格式
117
+ * 将富文本数组的 text 字段转换为纯字符串
118
+ * 
119
+ * @param cell 前端单元格数据
120
+ * @param colIndex 列索引(从1开始)
121
+ * @param defaultWidth 默认宽度(磅)
122
+ * @returns 序列化后的单元格(text为纯字符串)
123
+ */
124
+export function serializeTableCell(
125
+  cell: TableCell,
126
+  colIndex: number,
127
+  defaultWidth: number = 100
128
+): any {
129
+  return {
130
+    text: flattenTextToString(cell.text), // 转换为纯字符串
131
+    rowspan: cell.rowspan || 1,
132
+    colspan: cell.colspan || 1,
133
+    col_index: cell.col_index !== undefined ? cell.col_index : colIndex,
134
+    style: {
135
+      align: cell.style?.align || 'center',
136
+      font_size: cell.style?.font_size || 12,
137
+      font_name: cell.style?.font_name || '黑体',
138
+      ...cell.style, // 保留其他自定义样式
139
+    },
140
+    word_style: cell.word_style || 'Normal',
141
+    width: cell.width !== undefined ? cell.width : defaultWidth,
142
+  };
143
+}
144
+
145
+/**
146
+ * 序列化表格块为后端格式
147
+ * 将所有单元格的 text 从富文本数组转换为纯字符串
148
+ * 
149
+ * @param table 表格块
150
+ * @returns 序列化后的表格块
151
+ */
152
+export function serializeTableBlock(table: TableBlock): any {
153
+  // 计算每列的平均宽度作为默认值
154
+  const avgWidth = table.metadata.col_widths.length > 0
155
+    ? table.metadata.col_widths.reduce((sum, w) => sum + w, 0) / table.metadata.col_widths.length
156
+    : 100;
157
+
158
+  // 默认行高(磅)
159
+  const defaultHeight = 58;
160
+
161
+  const rows = table.content.rows.map((row) => ({
162
+    cells: row.cells.map((cell, colIdx) => 
163
+      serializeTableCell(cell, colIdx + 1, avgWidth)
164
+    ),
165
+    // 保留原有的 height,只有在没有 height 时才使用默认值
166
+    height: row.height !== undefined ? row.height : defaultHeight,
167
+  }));
168
+
169
+  return {
170
+    ...table,
171
+    content: { rows },
172
+  };
173
+}
174
+
175
+/**
176
+ * 规范化表格单元格数据结构
177
+ * 确保单元格包含所有必需字段,与后端期望的格式一致
178
+ * 
179
+ * @param cell 原始单元格数据
180
+ * @param colIndex 列索引(从1开始)
181
+ * @param defaultWidth 默认宽度(磅)
182
+ * @returns 规范化后的单元格
183
+ */
184
+export function normalizeTableCell(
185
+  cell: Partial<TableCell>,
186
+  colIndex: number,
187
+  defaultWidth: number = 100
188
+): TableCell {
189
+  return {
190
+    text: cell.text || '',
191
+    rowspan: cell.rowspan || 1,
192
+    colspan: cell.colspan || 1,
193
+    col_index: cell.col_index !== undefined ? cell.col_index : colIndex,
194
+    style: {
195
+      align: cell.style?.align || 'center',
196
+      font_size: cell.style?.font_size || 12,
197
+      font_name: cell.style?.font_name || '黑体',
198
+      ...cell.style, // 保留其他自定义样式
199
+    },
200
+    word_style: cell.word_style || 'Normal',
201
+    width: cell.width !== undefined ? cell.width : defaultWidth,
202
+  };
203
+}
204
+
205
+/**
206
+ * 规范化整个表格的数据结构
207
+ * 确保所有单元格都包含完整的字段信息
208
+ * 
209
+ * @param table 表格块
210
+ * @returns 规范化后的表格块
211
+ */
212
+export function normalizeTableBlock(table: TableBlock): TableBlock {
213
+  // 计算每列的平均宽度作为默认值
214
+  const avgWidth = table.metadata.col_widths.length > 0
215
+    ? table.metadata.col_widths.reduce((sum, w) => sum + w, 0) / table.metadata.col_widths.length
216
+    : 100;
217
+
218
+  // 默认行高(磅)
219
+  const defaultHeight = 58;
220
+
221
+  const rows = table.content.rows.map((row) => ({
222
+    cells: row.cells.map((cell, colIdx) => 
223
+      normalizeTableCell(cell, colIdx + 1, avgWidth)
224
+    ),
225
+    // 保留原有的 height,只有在没有 height 时才使用默认值
226
+    height: row.height !== undefined ? row.height : defaultHeight,
227
+  }));
228
+
229
+  return {
230
+    ...table,
231
+    content: { rows },
232
+  };
233
+}
234
+
235
+/**
102 236
  * 创建空的表格单元格
103 237
  * 
238
+ * @param colIndex 列索引(从1开始,可选)
239
+ * @param width 单元格宽度(磅,可选)
104 240
  * @returns 空单元格
105 241
  */
106
-export function createEmptyCell(): TableCell {
242
+export function createEmptyCell(colIndex?: number, width?: number): TableCell {
107 243
   return {
108 244
     text: '',
109 245
     rowspan: 1,
110 246
     colspan: 1,
247
+    col_index: colIndex,
111 248
     style: {},
249
+    word_style: 'Normal',
250
+    width: width,
112 251
   };
113 252
 }
114 253
 
@@ -116,11 +255,19 @@ export function createEmptyCell(): TableCell {
116 255
  * 创建空的表格行
117 256
  * 
118 257
  * @param cols 列数
258
+ * @param colWidths 列宽数组(磅,可选)
259
+ * @param height 行高(磅,可选,默认58)
119 260
  * @returns 表格行
120 261
  */
121
-export function createEmptyRow(cols: number): TableRow {
262
+export function createEmptyRow(cols: number, colWidths?: number[], height?: number): TableRow {
122 263
   return {
123
-    cells: Array(cols).fill(null).map(() => createEmptyCell()),
264
+    cells: Array(cols).fill(null).map((_, index) => 
265
+      createEmptyCell(
266
+        index + 1, // col_index从1开始
267
+        colWidths?.[index]
268
+      )
269
+    ),
270
+    height: height !== undefined ? height : 58, // 默认行高58磅
124 271
   };
125 272
 }
126 273
 
@@ -137,7 +284,10 @@ export function createEmptyRow(cols: number): TableRow {
137 284
  * ```
138 285
  */
139 286
 export function insertTableRow(table: TableBlock, afterRow: number): TableBlock {
140
-  const newRow = createEmptyRow(table.metadata.cols);
287
+  // 使用相邻行的高度作为新行的高度
288
+  const referenceHeight = table.content.rows[afterRow]?.height || 58;
289
+  
290
+  const newRow = createEmptyRow(table.metadata.cols, table.metadata.col_widths, referenceHeight);
141 291
   const rows = [...table.content.rows];
142 292
   rows.splice(afterRow + 1, 0, newRow);
143 293
   
@@ -166,8 +316,27 @@ export function insertTableRow(table: TableBlock, afterRow: number): TableBlock
166 316
 export function insertTableColumn(table: TableBlock, afterCol: number): TableBlock {
167 317
   const rows = table.content.rows.map((row) => {
168 318
     const cells = [...row.cells];
169
-    cells.splice(afterCol + 1, 0, createEmptyCell());
170
-    return { cells };
319
+    
320
+    // 计算新列的宽度
321
+    const avgWidth = table.metadata.col_widths.reduce((sum, w) => sum + w, 0) / table.metadata.col_widths.length;
322
+    
323
+    // 插入新单元格,并设置正确的 col_index 和 width
324
+    cells.splice(afterCol + 1, 0, createEmptyCell(afterCol + 2, avgWidth));
325
+    
326
+    // 更新后续单元格的 col_index
327
+    for (let i = afterCol + 2; i < cells.length; i++) {
328
+      if (cells[i].col_index !== undefined) {
329
+        cells[i] = {
330
+          ...cells[i],
331
+          col_index: i + 1,
332
+        };
333
+      }
334
+    }
335
+    
336
+    return { 
337
+      cells,
338
+      height: row.height, // 保留行高
339
+    };
171 340
   });
172 341
   
173 342
   const colWidths = [...table.metadata.col_widths];
@@ -221,9 +390,18 @@ export function deleteTableColumn(table: TableBlock, colIndex: number): TableBlo
221 390
     throw new Error('表格至少需要一列');
222 391
   }
223 392
   
224
-  const rows = table.content.rows.map((row) => ({
225
-    cells: row.cells.filter((_, i) => i !== colIndex),
226
-  }));
393
+  const rows = table.content.rows.map((row) => {
394
+    const cells = row.cells.filter((_, i) => i !== colIndex);
395
+    
396
+    // 更新剩余单元格的 col_index
397
+    return {
398
+      cells: cells.map((cell, i) => ({
399
+        ...cell,
400
+        col_index: i + 1,
401
+      })),
402
+      height: row.height, // 保留行高
403
+    };
404
+  });
227 405
   
228 406
   const colWidths = table.metadata.col_widths.filter((_, i) => i !== colIndex);
229 407
   
@@ -242,7 +420,8 @@ export function deleteTableColumn(table: TableBlock, colIndex: number): TableBlo
242 420
  * 合并单元格
243 421
  * 
244 422
  * 支持横向合并(colspan)和纵向合并(rowspan)
245
- * 合并时会保存所有被合并单元格的原始内容和样式,以便拆分时恢复
423
+ * 合并后的主单元格会保存所有被合并单元格的文本内容(用空格连接)
424
+ * 被合并的单元格会被标记为隐藏(rowspan=0, colspan=0)
246 425
  * 
247 426
  * @param table 表格块
248 427
  * @param startRow 起始行
@@ -255,11 +434,11 @@ export function deleteTableColumn(table: TableBlock, colIndex: number): TableBlo
255 434
  * ```ts
256 435
  * // 横向合并: a + b (第0行,第0-1列)
257 436
  * mergeCells(table, 0, 0, 0, 1);
258
- * // 结果: a单元格 colspan=2, 保存b的内容和样式到 _mergedCells
437
+ * // 结果: a单元格 colspan=2, text包含a和b的内容
259 438
  * 
260 439
  * // 纵向合并: a + d (第0-1行,第0列)
261 440
  * mergeCells(table, 0, 0, 1, 0);
262
- * // 结果: a单元格 rowspan=2, 保存d的内容和样式到 _mergedCells
441
+ * // 结果: a单元格 rowspan=2, text包含a和d的内容
263 442
  * ```
264 443
  */
265 444
 export function mergeCells(
@@ -269,32 +448,30 @@ export function mergeCells(
269 448
   endRow: number,
270 449
   endCol: number
271 450
 ): TableBlock {
272
-  // 收集所有被合并单元格的内容和样式(保存位置偏移量)
273
-  const mergedCells: Array<{
274
-    rowOffset: number;
275
-    colOffset: number;
276
-    text: string | RichText[];
277
-    style: any; // 保存原始样式
278
-  }> = [];
451
+  // 计算合并范围
452
+  const rowSpan = endRow - startRow + 1;
453
+  const colSpan = endCol - startCol + 1;
454
+  
455
+  // 获取主单元格(左上角)
456
+  const mainCell = table.content.rows[startRow].cells[startCol];
279 457
   
280 458
   // 收集主单元格内容用于显示
281 459
   const displayTexts: string[] = [];
282 460
   
461
+  // 辅助函数:将 text 字段规范化为纯字符串
462
+  const normalizeText = (text: string | RichText[]): string => {
463
+    if (typeof text === 'string') {
464
+      return text;
465
+    }
466
+    // 如果是 RichText 数组,提取所有的纯文本
467
+    return text.map(seg => seg.text).join('');
468
+  };
469
+  
283 470
   table.content.rows.forEach((row, rowIdx) => {
284 471
     if (rowIdx >= startRow && rowIdx <= endRow) {
285 472
       row.cells.forEach((cell, colIdx) => {
286 473
         if (colIdx >= startCol && colIdx <= endCol) {
287
-          const text = typeof cell.text === 'string' 
288
-            ? cell.text 
289
-            : cell.text.map(seg => seg.text).join('');
290
-          
291
-          // 保存所有单元格的原始数据和样式(包括主单元格)
292
-          mergedCells.push({
293
-            rowOffset: rowIdx - startRow,
294
-            colOffset: colIdx - startCol,
295
-            text: cell.text,
296
-            style: { ...cell.style }, // 深拷贝样式
297
-          });
474
+          const text = normalizeText(cell.text);
298 475
           
299 476
           // 用于显示的文本
300 477
           if (text.trim()) {
@@ -316,29 +493,44 @@ export function mergeCells(
316 493
       }
317 494
       
318 495
       if (rowIdx === startRow && colIdx === startCol) {
319
-        // 主单元格,设置rowspan和colspan,保存原始数据
496
+        // 主单元格:设置完整的标准格式
497
+        // 合并后的文本统一为纯字符串格式
498
+        const mergedText = displayTexts.join(' ');
499
+        
500
+        // 构建标准格式的单元格对象(字段顺序与后端一致)
320 501
         return {
321
-          ...cell,
322
-          text: displayTexts.join(' ') || cell.text,
323
-          rowspan: endRow - startRow + 1,
324
-          colspan: endCol - startCol + 1,
502
+          text: mergedText || normalizeText(cell.text),
503
+          rowspan: rowSpan,
504
+          colspan: colSpan,
505
+          col_index: startCol + 1, // 列索引从1开始
325 506
           style: {
326
-            ...cell.style,
327
-            _mergedCells: mergedCells, // 保存所有单元格的原始内容和样式
507
+            // 保留原有样式,确保必要字段存在
508
+            align: cell.style?.align || 'center',
509
+            font_size: cell.style?.font_size || 12,
510
+            font_name: cell.style?.font_name || '黑体',
511
+            ...cell.style, // 其他自定义样式
328 512
           },
513
+          word_style: cell.word_style || 'Normal',
514
+          width: cell.width !== undefined ? cell.width : 100, // 保留原有width或使用默认值
329 515
         };
330 516
       }
331 517
       
332
-      // 被合并的单元格,标记为隐藏
518
+      // 被合并的单元格标记为隐藏
333 519
       return {
334
-        ...cell,
335 520
         text: '', // 清空显示内容
336 521
         rowspan: 0,
337 522
         colspan: 0,
523
+        col_index: colIdx + 1, // 保持列索引
524
+        style: cell.style || {},
525
+        word_style: cell.word_style || 'Normal',
526
+        width: cell.width,
338 527
       };
339 528
     });
340 529
     
341
-    return { cells };
530
+    return { 
531
+      cells,
532
+      height: row.height, // 保留行高
533
+    };
342 534
   });
343 535
   
344 536
   return {
@@ -350,7 +542,8 @@ export function mergeCells(
350 542
 /**
351 543
  * 拆分单元格
352 544
  * 
353
- * 将已合并的单元格拆分回独立单元格,并恢复原始内容和样式到对应位置
545
+ * 将已合并的单元格拆分回独立单元格
546
+ * 主单元格保留原有内容,其他单元格恢复为空单元格
354 547
  * 
355 548
  * @param table 表格块
356 549
  * @param rowIndex 单元格所在行
@@ -361,11 +554,11 @@ export function mergeCells(
361 554
  * ```ts
362 555
  * // 拆分横向合并的单元格 (a+b)
363 556
  * splitCell(table, 0, 0);
364
- * // 结果: a单元格恢复为普通单元格(内容为原始a,样式为原始a样式), b单元格恢复(内容为原始b,样式为原始b样式)
557
+ * // 结果: a单元格保留内容,b单元格变为空单元格
365 558
  * 
366 559
  * // 拆分纵向合并的单元格 (a+d)  
367 560
  * splitCell(table, 0, 0);
368
- * // 结果: a单元格恢复为普通单元格(内容为原始a,样式为原始a样式), d单元格恢复(内容为原始d,样式为原始d样式)
561
+ * // 结果: a单元格保留内容,d单元格变为空单元格
369 562
  * ```
370 563
  */
371 564
 export function splitCell(
@@ -387,16 +580,6 @@ export function splitCell(
387 580
   const rowspan = targetCell.rowspan || 1;
388 581
   const colspan = targetCell.colspan || 1;
389 582
   
390
-  // 获取保存的原始单元格数据(包含text和style)
391
-  const mergedCells = targetCell.style._mergedCells || [];
392
-  
393
-  // 创建一个映射,用于快速查找原始内容和样式
394
-  const cellDataMap = new Map<string, { text: string | RichText[]; style: any }>();
395
-  mergedCells.forEach(({ rowOffset, colOffset, text, style }) => {
396
-    const key = `${rowOffset}-${colOffset}`;
397
-    cellDataMap.set(key, { text, style: style || {} });
398
-  });
399
-  
400 583
   const rows = table.content.rows.map((row, rowIdx) => {
401 584
     // 不在合并范围内的行直接返回
402 585
     if (rowIdx < rowIndex || rowIdx >= rowIndex + rowspan) {
@@ -409,42 +592,28 @@ export function splitCell(
409 592
         return cell;
410 593
       }
411 594
       
412
-      // 计算当前单元格在合并区域中的偏移量
413
-      const rowOffset = rowIdx - rowIndex;
414
-      const colOffset = colIdx - colIndex;
415
-      const key = `${rowOffset}-${colOffset}`;
416
-      
417
-      // 从保存的数据中恢复原始内容和样式
418
-      const cellData = cellDataMap.get(key);
419
-      const originalText = cellData?.text || '';
420
-      const originalStyle = cellData?.style || {};
421
-      
422
-      // 主单元格:恢复为普通单元格,清除合并标记
595
+      // 主单元格:恢复为普通单元格,清除合并标记
423 596
       if (rowIdx === rowIndex && colIdx === colIndex) {
424
-        const newStyle = { ...originalStyle };
425
-        delete newStyle._mergedCells; // 清除保存的合并数据
426
-        
427 597
         return {
428 598
           ...cell,
429
-          text: originalText,
430 599
           rowspan: 1,
431 600
           colspan: 1,
432
-          style: newStyle, // 使用原始样式
601
+          col_index: colIdx + 1, // 确保 col_index 存在
602
+          word_style: cell.word_style || 'Normal', // 确保 word_style 存在
433 603
         };
434 604
       }
435 605
       
436
-      // 被合并的单元格:恢复为独立单元格,并恢复原始内容和样式
437
-      const recoveredStyle = { ...originalStyle };
438
-      delete recoveredStyle._mergedCells;
439
-      
440
-      return {
441
-        ...createEmptyCell(),
442
-        text: originalText,
443
-        style: recoveredStyle, // 使用原始样式
444
-      };
606
+      // 被合并的单元格:恢复为独立空单元格
607
+      return createEmptyCell(
608
+        colIdx + 1, // col_index从1开始
609
+        cell.width // 保留原有宽度
610
+      );
445 611
     });
446 612
     
447
-    return { cells };
613
+    return { 
614
+      cells,
615
+      height: row.height, // 保留行高
616
+    };
448 617
   });
449 618
   
450 619
   return {

+ 11 - 3
src/utils/richTextConverter.ts

@@ -100,6 +100,7 @@ export function hexToRgb(hex: string): string {
100 100
  * RichText数组转HTML字符串(用于contenteditable渲染)
101 101
  * 
102 102
  * @param content 富文本内容
103
+ * @param baseStyle 基础样式(可选,用于提供默认字号和字体)
103 104
  * @returns HTML字符串
104 105
  * 
105 106
  * @example
@@ -111,7 +112,7 @@ export function hexToRgb(hex: string): string {
111 112
  * // "<span>普通</span><strong>加粗</strong>"
112 113
  * ```
113 114
  */
114
-export function richTextToHtml(content: string | RichText[]): string {
115
+export function richTextToHtml(content: string | RichText[], baseStyle?: { fontSize?: number; fontFamily?: string }): string {
115 116
   if (typeof content === 'string') {
116 117
     return escapeHtml(content);
117 118
   }
@@ -119,12 +120,19 @@ export function richTextToHtml(content: string | RichText[]): string {
119 120
   return content
120 121
     .map((segment) => {
121 122
       let html = escapeHtml(segment.text);
122
-      const { bold, italic, underline, color, font_size } = segment.style;
123
+      const { bold, italic, underline, color, font_size, font_name } = segment.style;
123 124
       
124 125
       // 构建内联样式
125 126
       const styles: string[] = [];
126 127
       if (color) styles.push(`color: #${color}`);
127
-      if (font_size) styles.push(`font-size: ${font_size}pt`);
128
+      
129
+      // 字号:优先使用片段自己的字号,否则使用基础样式的字号
130
+      const finalFontSize = font_size !== undefined ? font_size : baseStyle?.fontSize;
131
+      if (finalFontSize) styles.push(`font-size: ${finalFontSize}pt`);
132
+      
133
+      // 字体:优先使用片段自己的字体,否则使用基础样式的字体
134
+      const finalFontName = font_name || baseStyle?.fontFamily;
135
+      if (finalFontName) styles.push(`font-family: ${finalFontName}`);
128 136
       
129 137
       // 包装HTML标签
130 138
       if (bold) html = `<strong>${html}</strong>`;