ソースを参照

feat (编辑器):优化表格布局与会话管理,升级单元格缩放能力
· 新增会话历史追踪能力,提供SessionHistoryList组件与sessionService会话服务
· 优化列宽缩放逻辑:增加节流处理,依托tableUtils实现基于百分比的宽度归一化
· 新增tableUtils工具模块,提供宽度单位换算(百分比 ↔ 磅值)、百分比归一化工具方法
· 增强表格区块(TableBlock)功能:支持缩放实时视觉反馈、优化边界约束处理,新增ResizeState.currentSize尺寸跟踪
· 修复画布区块(BlockCanvas)与表格区块布局溢出问题,通过最大宽度、隐藏溢出约束实现管控
· 优化消息条目(MessageItem)文档预览逻辑,关联会话 ID,支持多文档会话场景
· 更新类型定义、区块操作与服务层,支撑基于会话的文档管理体系
· 重构表格单元格样式,在缩放操作过程中持续保持列宽约束

Zhang Yice 1 ヶ月 前
コミット
f54a85c13a

+ 11 - 10
src/components/ChatPanel/MessageItem.tsx

@@ -142,14 +142,12 @@ const MessageItem: React.FC<MessageItemProps> = memo(
142 142
     /**
143 143
      * Handle preview document click
144 144
      * 
145
-     * This function ensures the document exists in the document management
146
-     * system before opening the editor. It implements smart deduplication:
147
-     * 
148
-     * Steps:
149
-     * 1. Check localStorage cache for existing documentId
150
-     * 2. If cached, verify document still exists
151
-     * 3. If not cached or deleted, create new document via POST
152
-     * 4. Cache the documentId and open editor
145
+     * 点击预览文档时的处理逻辑:
146
+     * 1. 检查本地缓存是否已有该导出记录对应的 documentId
147
+     * 2. 如果有缓存且文档仍然存在,直接使用
148
+     * 3. 如果没有缓存或文档已被删除,调用 POST /api/v1/documents 创建新文档
149
+     * 4. 创建文档时传递 sessionId,确保同一会话中的多个文档共用相同的 sessionId
150
+     * 5. 缓存 documentId 并打开编辑器
153 151
      */
154 152
     const handlePreviewClick = useCallback(async () => {
155 153
       if (!exportRecord || !onPreviewDocument || isCreatingDocument) return;
@@ -174,7 +172,8 @@ const MessageItem: React.FC<MessageItemProps> = memo(
174 172
             // Document no longer exists, clear cache and create new
175 173
             localStorage.removeItem(cacheKey);
176 174
             
177
-            // Create new document
175
+            // Create new document with sessionId
176
+            // 重要: 传递 sessionId 确保文档关联到当前会话
178 177
             const response = await createDocument({
179 178
               userId: 'default-user', // TODO: Get from auth context
180 179
               fileUrl: exportRecord.downloadUrl,
@@ -185,7 +184,9 @@ const MessageItem: React.FC<MessageItemProps> = memo(
185 184
             localStorage.setItem(cacheKey, documentId);
186 185
           }
187 186
         } else {
188
-          // Step 3: No cache, create new document
187
+          // Step 3: No cache, create new document with sessionId
188
+          // 重要: 每次生成新文档都会调用此API,传递相同的sessionId
189
+          // 这样在数据库中就会有多条记录,id不同但session_id相同
189 190
           const response = await createDocument({
190 191
             userId: 'default-user', // TODO: Get from auth context
191 192
             fileUrl: exportRecord.downloadUrl,

+ 3 - 0
src/components/Editor/BlockCanvas.css

@@ -10,6 +10,7 @@
10 10
   background-color: #ffffff;
11 11
   min-height: 0; /* 修复flex子元素滚动问题 */
12 12
   height: 100%;
13
+  max-width: 100%;
13 14
 }
14 15
 
15 16
 /* 空状态 */
@@ -26,6 +27,8 @@
26 27
 .block-canvas .block-wrapper {
27 28
   margin-bottom: 16px;
28 29
   position: relative;
30
+  max-width: 100%;
31
+  overflow: hidden;
29 32
 }
30 33
 
31 34
 /* 滚动条样式 - Word风格 */

+ 3 - 1
src/components/Editor/blocks/TableBlock.css

@@ -4,16 +4,18 @@
4 4
 
5 5
 .table-block-wrapper {
6 6
   margin-bottom: 16px;
7
+  overflow: hidden;
7 8
 }
8 9
 
9 10
 .table-container {
10
-  overflow-x: auto;
11
+  overflow: hidden;
11 12
   margin: 8px 0;
12 13
 }
13 14
 
14 15
 .table-block {
15 16
   border-collapse: collapse;
16 17
   width: 100%;
18
+  max-width: 100%;
17 19
   background-color: #ffffff;
18 20
 }
19 21
 

+ 127 - 45
src/components/Editor/blocks/TableBlock.tsx

@@ -9,6 +9,7 @@ import type { TableBlock as TableBlockType, TableCell as TableCellType } from '.
9 9
 import { useEditorStore } from '../../../stores/editorStore';
10 10
 import { TableCell } from './TableCell';
11 11
 import { TableToolbar } from './TableToolbar';
12
+import { percentToPtWidths, ptToPercentWidths, pixelToPt, normalizePercents } from '../../../utils/tableUtils';
12 13
 import './TableBlock.css';
13 14
 
14 15
 // ══════════════════════════════════════════════════════════════════════════════
@@ -20,6 +21,7 @@ interface ResizeState {
20 21
   index: number;
21 22
   startPos: number;
22 23
   startSize: number;
24
+  currentSize?: number; // 当前尺寸(用于实时显示)
23 25
 }
24 26
 
25 27
 export interface TableBlockProps {
@@ -56,6 +58,9 @@ export const TableBlock: React.FC<TableBlockProps> = ({
56 58
   // 防抖计时器
57 59
   const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
58 60
   const lastContentRef = useRef<string>(JSON.stringify(block.content));
61
+  
62
+  // 节流标记 - 用于优化拖动性能
63
+  const updateThrottleRef = useRef<boolean>(false);
59 64
 
60 65
   // 处理单元格内容变更
61 66
   const handleCellChange = useCallback(
@@ -163,54 +168,97 @@ export const TableBlock: React.FC<TableBlockProps> = ({
163 168
     });
164 169
   }, [block.content.rows, readOnly]);
165 170
 
166
-  // 处理拖动
171
+  // 处理拖动 - 优化版本,使用节流和实时反馈
167 172
   const handleMouseMove = useCallback((e: MouseEvent) => {
168 173
     if (!resizeState || !tableRef.current) return;
169 174
 
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%
175
+    // 节流处理 - 每16ms更新一次(约60fps)
176
+    if (updateThrottleRef.current) return;
177
+    updateThrottleRef.current = true;
178
+    
179
+    requestAnimationFrame(() => {
180
+      updateThrottleRef.current = false;
178 181
       
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);
182
+      if (!resizeState || !tableRef.current) return;
183
+
184
+      if (resizeState.type === 'column') {
185
+        // 调整列宽 - 基于百分比
186
+        const deltaX = e.clientX - resizeState.startPos;
187
+        const tableWidth = tableRef.current.offsetWidth;
188
+        const deltaPercent = (deltaX / tableWidth) * 100;
189
+        
190
+        const newColWidths = [...block.metadata.col_widths];
191
+        let newWidth = resizeState.startSize + deltaPercent;
192
+        
193
+        // 如果有下一列,调整下一列宽度以保持总宽度不变
194
+        if (resizeState.index < newColWidths.length - 1) {
195
+          const nextColWidth = newColWidths[resizeState.index + 1];
196
+          const widthDiff = newWidth - resizeState.startSize;
197
+          const newNextWidth = nextColWidth - widthDiff;
198
+          
199
+          // 确保两列都满足最小宽度5%
200
+          if (newWidth < 5) {
201
+            newWidth = 5;
202
+          } else if (newNextWidth < 5) {
203
+            newWidth = resizeState.startSize + (nextColWidth - 5);
204
+          }
205
+          
206
+          newColWidths[resizeState.index] = newWidth;
207
+          newColWidths[resizeState.index + 1] = nextColWidth - (newWidth - resizeState.startSize);
208
+        } else {
209
+          // 最后一列,直接调整
210
+          newColWidths[resizeState.index] = Math.max(5, newWidth);
211
+        }
212
+        
213
+        // 归一化百分比,确保总和为100%
214
+        const normalizedWidths = normalizePercents(newColWidths);
215
+        
216
+        // 同步更新content.col_widths (pt单位)
217
+        const newColWidthsPt = percentToPtWidths(
218
+          normalizedWidths,
219
+          block.metadata.table_width
220
+        );
221
+        
222
+        // 更新状态以显示当前尺寸
223
+        setResizeState({
224
+          ...resizeState,
225
+          currentSize: Math.round(newColWidthsPt[resizeState.index] * 10) / 10,
226
+        });
227
+        
228
+        updateBlock(block.id, {
229
+          metadata: {
230
+            ...block.metadata,
231
+            col_widths: normalizedWidths,
232
+          },
233
+          content: {
234
+            ...block.content,
235
+            col_widths: newColWidthsPt,
236
+          },
237
+        });
238
+      } else if (resizeState.type === 'row') {
239
+        // 调整行高 - 使用精确的像素到pt转换
240
+        const deltaY = e.clientY - resizeState.startPos;
241
+        const deltaPt = pixelToPt(deltaY);
242
+        const newHeight = Math.max(15, resizeState.startSize + deltaPt); // 最小15pt
243
+        const roundedHeight = Math.round(newHeight * 10) / 10; // 保留1位小数
244
+        
245
+        // 更新状态以显示当前尺寸
246
+        setResizeState({
247
+          ...resizeState,
248
+          currentSize: roundedHeight,
249
+        });
184 250
         
185
-        newColWidths[resizeState.index] = newWidth;
186
-        newColWidths[resizeState.index + 1] = newNextWidth;
187
-      } else {
188
-        newColWidths[resizeState.index] = newWidth;
251
+        const newRows = block.content.rows.map((row, idx) => {
252
+          if (idx !== resizeState.index) return row;
253
+          return { ...row, height: roundedHeight };
254
+        });
255
+        
256
+        updateBlock(block.id, {
257
+          content: { ...block.content, rows: newRows },
258
+        });
189 259
       }
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]);
260
+    });
261
+  }, [resizeState, block.id, block.metadata, block.content, updateBlock]);
214 262
 
215 263
   // 结束拖动
216 264
   const handleMouseUp = useCallback(() => {
@@ -250,8 +298,18 @@ export const TableBlock: React.FC<TableBlockProps> = ({
250 298
       ? `${tableWidth}cm`
251 299
       : `${tableWidth}in`;
252 300
 
253
-  // 计算列宽
301
+  // 计算列宽 - 使用metadata中的百分比
254 302
   const colWidths = block.metadata.col_widths;
303
+  
304
+  // 如果没有metadata.col_widths,从content.col_widths推算百分比
305
+  const effectiveColWidths = colWidths && colWidths.length > 0 
306
+    ? colWidths 
307
+    : block.content.col_widths 
308
+      ? (() => {
309
+          const totalPt = block.content.col_widths.reduce((sum: number, w: number) => sum + w, 0);
310
+          return block.content.col_widths.map((w: number) => (w / totalPt) * 100);
311
+        })()
312
+      : [];
255 313
 
256 314
   return (
257 315
     <div className="table-block-wrapper" data-block-id={block.id}>
@@ -268,6 +326,30 @@ export const TableBlock: React.FC<TableBlockProps> = ({
268 326
         />
269 327
       )}
270 328
 
329
+      {/* 拖动尺寸提示 */}
330
+      {resizeState && resizeState.currentSize && (
331
+        <div 
332
+          className="resize-tooltip"
333
+          style={{
334
+            position: 'fixed',
335
+            left: resizeState.type === 'column' ? `${resizeState.startPos + 10}px` : '50%',
336
+            top: resizeState.type === 'row' ? `${resizeState.startPos + 10}px` : '50%',
337
+            transform: 'translate(-50%, -50%)',
338
+            background: 'rgba(0, 0, 0, 0.75)',
339
+            color: 'white',
340
+            padding: '4px 8px',
341
+            borderRadius: '4px',
342
+            fontSize: '12px',
343
+            pointerEvents: 'none',
344
+            zIndex: 1000,
345
+          }}
346
+        >
347
+          {resizeState.type === 'column' 
348
+            ? `宽度: ${resizeState.currentSize}pt` 
349
+            : `高度: ${resizeState.currentSize}pt`}
350
+        </div>
351
+      )}
352
+
271 353
       {/* 表格 */}
272 354
       <div className="table-container">
273 355
         <table
@@ -279,7 +361,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
279 361
           }}
280 362
         >
281 363
           <colgroup>
282
-            {colWidths.map((width, index) => (
364
+            {effectiveColWidths.map((width, index) => (
283 365
               <col key={index} style={{ width: `${width}%` }} />
284 366
             ))}
285 367
           </colgroup>
@@ -313,7 +395,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
313 395
                       }
314 396
                       onChange={handleCellChange}
315 397
                       onClick={handleCellClick}
316
-                      onColumnResize={colIndex < colWidths.length - 1 ? handleColumnResizeStart : undefined}
398
+                      onColumnResize={colIndex < effectiveColWidths.length - 1 ? handleColumnResizeStart : undefined}
317 399
                       onRowResize={rowIndex < block.content.rows.length - 1 ? handleRowResizeStart : undefined}
318 400
                     />
319 401
                   );

+ 33 - 12
src/components/Editor/blocks/TableCell.css

@@ -34,21 +34,21 @@
34 34
 .resize-handle {
35 35
   position: absolute;
36 36
   background-color: transparent;
37
-  transition: background-color 0.15s ease;
37
+  transition: background-color 0.15s ease, opacity 0.15s ease;
38 38
   z-index: 10;
39 39
   opacity: 0;
40 40
 }
41 41
 
42 42
 /* 鼠标悬停在单元格上时显示手柄 */
43 43
 .table-cell:hover .resize-handle {
44
-  opacity: 1;
44
+  opacity: 0.6;
45 45
 }
46 46
 
47 47
 /* 拖动时手柄高亮 */
48 48
 .resize-handle:active,
49 49
 .resize-handle:hover {
50
-  background-color: rgba(24, 144, 255, 0.5);
51
-  opacity: 1;
50
+  background-color: rgba(24, 144, 255, 0.3);
51
+  opacity: 1 !important;
52 52
 }
53 53
 
54 54
 /* 列宽调整手柄 - 右边界 */
@@ -58,6 +58,7 @@
58 58
   width: 8px;
59 59
   height: 100%;
60 60
   cursor: col-resize;
61
+  user-select: none;
61 62
 }
62 63
 
63 64
 .resize-handle-column::before {
@@ -67,14 +68,16 @@
67 68
   left: 50%;
68 69
   transform: translate(-50%, -50%);
69 70
   width: 2px;
70
-  height: 60%;
71
-  background-color: rgba(24, 144, 255, 0.6);
71
+  height: 70%;
72
+  background-color: rgba(24, 144, 255, 0.8);
72 73
   border-radius: 1px;
73 74
   opacity: 0;
74 75
   transition: opacity 0.15s ease;
76
+  pointer-events: none;
75 77
 }
76 78
 
77 79
 .resize-handle-column:hover::before,
80
+.resize-handle-column:active::before,
78 81
 .table-cell:hover .resize-handle-column::before {
79 82
   opacity: 1;
80 83
 }
@@ -86,6 +89,7 @@
86 89
   width: 100%;
87 90
   height: 8px;
88 91
   cursor: row-resize;
92
+  user-select: none;
89 93
 }
90 94
 
91 95
 .resize-handle-row::before {
@@ -94,15 +98,17 @@
94 98
   top: 50%;
95 99
   left: 50%;
96 100
   transform: translate(-50%, -50%);
97
-  width: 60%;
101
+  width: 70%;
98 102
   height: 2px;
99
-  background-color: rgba(24, 144, 255, 0.6);
103
+  background-color: rgba(24, 144, 255, 0.8);
100 104
   border-radius: 1px;
101 105
   opacity: 0;
102 106
   transition: opacity 0.15s ease;
107
+  pointer-events: none;
103 108
 }
104 109
 
105 110
 .resize-handle-row:hover::before,
111
+.resize-handle-row:active::before,
106 112
 .table-cell:hover .resize-handle-row::before {
107 113
   opacity: 1;
108 114
 }
@@ -114,19 +120,34 @@
114 120
   width: 12px;
115 121
   height: 12px;
116 122
   cursor: nwse-resize;
117
-  background-color: rgba(24, 144, 255, 0.3);
118
-  border: 1px solid rgba(24, 144, 255, 0.5);
123
+  background-color: rgba(24, 144, 255, 0.2);
124
+  border: 1px solid rgba(24, 144, 255, 0.6);
119 125
   border-radius: 2px;
120 126
   opacity: 0;
127
+  transition: opacity 0.15s ease, background-color 0.15s ease;
121 128
 }
122 129
 
123 130
 .resize-handle-corner:hover,
131
+.resize-handle-corner:active,
124 132
 .table-cell:hover .resize-handle-corner {
125
-  background-color: rgba(24, 144, 255, 0.6);
126
-  border-color: rgba(24, 144, 255, 0.8);
133
+  background-color: rgba(24, 144, 255, 0.5);
134
+  border-color: rgba(24, 144, 255, 0.9);
127 135
   opacity: 1;
128 136
 }
129 137
 
138
+/* 角落手柄图标 */
139
+.resize-handle-corner::after {
140
+  content: '';
141
+  position: absolute;
142
+  right: 2px;
143
+  bottom: 2px;
144
+  width: 0;
145
+  height: 0;
146
+  border-style: solid;
147
+  border-width: 0 0 6px 6px;
148
+  border-color: transparent transparent rgba(24, 144, 255, 0.8) transparent;
149
+}
150
+
130 151
 /* 只读模式下完全隐藏调整手柄 */
131 152
 .table-cell[readonly] .resize-handle {
132 153
   display: none;

+ 13 - 8
src/components/Editor/blocks/TableCell.tsx

@@ -66,6 +66,11 @@ export const TableCell: React.FC<TableCellProps> = ({
66 66
 
67 67
   // 解析样式
68 68
   const cellStyle = cellStyleToCSS(cell.style);
69
+  
70
+  // 如果单元格有明确的宽度(pt单位),添加到样式中
71
+  if (cell.width && cell.width > 0) {
72
+    cellStyle.width = `${cell.width}pt`;
73
+  }
69 74
 
70 75
   // 提取字体相关属性
71 76
   // 优先级:cell.style > 默认值(12pt)
@@ -118,12 +123,12 @@ export const TableCell: React.FC<TableCellProps> = ({
118 123
           fontSize: `${fontSize}pt`,
119 124
           fontFamily: fontFamily,
120 125
           lineHeight: 1.5,
121
-          textAlign: 'center', // 强制居中对齐
126
+          textAlign: cellStyle.textAlign || 'center',
122 127
         }}
123 128
       />
124 129
       
125
-      {/* 列宽调整手柄 */}
126
-      {!readOnly && onColumnResize && (
130
+      {/* 列宽调整手柄 - 只在非合并列的最后一个单元格显示 */}
131
+      {!readOnly && onColumnResize && cell.colspan === 1 && (
127 132
         <div
128 133
           className="resize-handle resize-handle-column"
129 134
           onMouseDown={handleColumnResizeMouseDown}
@@ -131,8 +136,8 @@ export const TableCell: React.FC<TableCellProps> = ({
131 136
         />
132 137
       )}
133 138
       
134
-      {/* 行高调整手柄 */}
135
-      {!readOnly && onRowResize && (
139
+      {/* 行高调整手柄 - 只在非合并行的最后一个单元格显示 */}
140
+      {!readOnly && onRowResize && cell.rowspan === 1 && (
136 141
         <div
137 142
           className="resize-handle resize-handle-row"
138 143
           onMouseDown={handleRowResizeMouseDown}
@@ -140,12 +145,12 @@ export const TableCell: React.FC<TableCellProps> = ({
140 145
         />
141 146
       )}
142 147
       
143
-      {/* 角落调整手柄(同时调整行高和列宽) */}
144
-      {!readOnly && onColumnResize && onRowResize && (
148
+      {/* 角落调整手柄(同时调整行高和列宽) - 只在普通单元格显示 */}
149
+      {!readOnly && onColumnResize && onRowResize && cell.rowspan === 1 && cell.colspan === 1 && (
145 150
         <div
146 151
           className="resize-handle resize-handle-corner"
147 152
           onMouseDown={(e) => {
148
-            // 同时触发两个方向的调整
153
+            // 优先触发列宽调整
149 154
             handleColumnResizeMouseDown(e);
150 155
           }}
151 156
           title="拖动调整尺寸"

+ 167 - 0
src/components/SessionList/SessionHistoryList.tsx

@@ -0,0 +1,167 @@
1
+/**
2
+ * SessionHistoryList.tsx - 会话历史列表组件
3
+ * 
4
+ * 显示用户的会话历史,支持删除操作
5
+ */
6
+
7
+import React, { useEffect, useState } from 'react';
8
+import { Button, List, Modal, message, Spin, Typography, Tag } from 'antd';
9
+import { DeleteOutlined, ExclamationCircleOutlined, FileTextOutlined } from '@ant-design/icons';
10
+import {
11
+  getSessionList,
12
+  deleteSessionHistory,
13
+  formatSessionTime,
14
+} from '../../services/sessionService';
15
+
16
+const { Title, Text } = Typography;
17
+const { confirm } = Modal;
18
+
19
+// ══════════════════════════════════════════════════════════════════════════════
20
+// Types
21
+// ══════════════════════════════════════════════════════════════════════════════
22
+
23
+interface Session {
24
+  sessionId: string;
25
+  documentCount: number;
26
+  latestDocument?: {
27
+    id: string;
28
+    updatedAt: number;
29
+  };
30
+}
31
+
32
+interface SessionHistoryListProps {
33
+  userId: string;
34
+  onSessionSelect?: (sessionId: string) => void;
35
+}
36
+
37
+// ══════════════════════════════════════════════════════════════════════════════
38
+// Component
39
+// ══════════════════════════════════════════════════════════════════════════════
40
+
41
+export const SessionHistoryList: React.FC<SessionHistoryListProps> = ({
42
+  userId,
43
+  onSessionSelect,
44
+}) => {
45
+  const [sessions, setSessions] = useState<Session[]>([]);
46
+  const [loading, setLoading] = useState(false);
47
+  const [deleting, setDeleting] = useState<string | null>(null);
48
+
49
+  // 加载会话列表
50
+  const loadSessions = async () => {
51
+    setLoading(true);
52
+    try {
53
+      const result = await getSessionList(userId);
54
+      setSessions(result.sessions);
55
+    } catch (error) {
56
+      message.error('加载会话列表失败');
57
+      console.error(error);
58
+    } finally {
59
+      setLoading(false);
60
+    }
61
+  };
62
+
63
+  // 初始加载
64
+  useEffect(() => {
65
+    loadSessions();
66
+  }, [userId]);
67
+
68
+  // 删除会话
69
+  const handleDeleteSession = (sessionId: string, documentCount: number) => {
70
+    confirm({
71
+      title: '确认删除会话历史?',
72
+      icon: <ExclamationCircleOutlined />,
73
+      content: `此操作将删除该会话的所有 ${documentCount} 个文档,且不可恢复。确定要继续吗?`,
74
+      okText: '确认删除',
75
+      okType: 'danger',
76
+      cancelText: '取消',
77
+      async onOk() {
78
+        setDeleting(sessionId);
79
+        try {
80
+          const result = await deleteSessionHistory(sessionId);
81
+          message.success(
82
+            `成功删除 ${result.data.deletedCount} 个文档`
83
+          );
84
+          // 重新加载列表
85
+          await loadSessions();
86
+        } catch (error) {
87
+          message.error('删除失败');
88
+          console.error(error);
89
+        } finally {
90
+          setDeleting(null);
91
+        }
92
+      },
93
+    });
94
+  };
95
+
96
+  // 渲染会话项
97
+  const renderSessionItem = (session: Session) => {
98
+    const isDeleting = deleting === session.sessionId;
99
+    const lastUpdateTime = session.latestDocument?.updatedAt || 0;
100
+
101
+    return (
102
+      <List.Item
103
+        actions={[
104
+          <Button
105
+            key="delete"
106
+            type="text"
107
+            danger
108
+            icon={<DeleteOutlined />}
109
+            loading={isDeleting}
110
+            onClick={(e) => {
111
+              e.stopPropagation();
112
+              handleDeleteSession(session.sessionId, session.documentCount);
113
+            }}
114
+          >
115
+            删除
116
+          </Button>,
117
+        ]}
118
+        onClick={() => onSessionSelect?.(session.sessionId)}
119
+        style={{ cursor: 'pointer' }}
120
+      >
121
+        <List.Item.Meta
122
+          avatar={<FileTextOutlined style={{ fontSize: 24, color: '#1890ff' }} />}
123
+          title={
124
+            <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
125
+              <Text strong>{session.sessionId}</Text>
126
+              <Tag color="blue">{session.documentCount} 个文档</Tag>
127
+            </div>
128
+          }
129
+          description={
130
+            <Text type="secondary">
131
+              最后更新: {formatSessionTime(lastUpdateTime)}
132
+            </Text>
133
+          }
134
+        />
135
+      </List.Item>
136
+    );
137
+  };
138
+
139
+  return (
140
+    <div className="session-history-list">
141
+      <div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
142
+        <Title level={4} style={{ margin: 0 }}>会话历史</Title>
143
+        <Button onClick={loadSessions} loading={loading}>
144
+          刷新
145
+        </Button>
146
+      </div>
147
+
148
+      <Spin spinning={loading}>
149
+        {sessions.length === 0 && !loading ? (
150
+          <div style={{ textAlign: 'center', padding: '40px 0', color: '#999' }}>
151
+            <FileTextOutlined style={{ fontSize: 48, marginBottom: 16 }} />
152
+            <div>暂无会话历史</div>
153
+          </div>
154
+        ) : (
155
+          <List
156
+            dataSource={sessions}
157
+            renderItem={renderSessionItem}
158
+            bordered
159
+            style={{ background: '#fff' }}
160
+          />
161
+        )}
162
+      </Spin>
163
+    </div>
164
+  );
165
+};
166
+
167
+export default SessionHistoryList;

+ 48 - 17
src/components/SessionList/SessionList.tsx

@@ -173,12 +173,25 @@ const SessionList: React.FC<SessionListProps> = ({ onSessionLoad }) => {
173 173
   };
174 174
 
175 175
   /**
176
-   * Handle deleting a single session
176
+   * Handle deleting a single session (从后端删除)
177 177
    */
178
-  const handleDeleteSession = (sessionId: string, e: React.MouseEvent) => {
178
+  const handleDeleteSession = async (sessionId: string, e: React.MouseEvent) => {
179 179
     // Stop propagation to prevent loading the session
180 180
     e.stopPropagation();
181
-    deleteSession(sessionId);
181
+    
182
+    try {
183
+      // 从后端删除会话文档
184
+      const { deleteSessionHistory } = await import('../../services/sessionService');
185
+      const result = await deleteSessionHistory(sessionId);
186
+      
187
+      // 从本地状态删除
188
+      deleteSession(sessionId);
189
+      
190
+      antdMessage.success(`成功删除会话,共删除 ${result.data.deletedCount} 个文档`);
191
+    } catch (error) {
192
+      console.error('删除会话失败:', error);
193
+      antdMessage.error('删除会话失败,请重试');
194
+    }
182 195
   };
183 196
 
184 197
   /**
@@ -214,24 +227,41 @@ const SessionList: React.FC<SessionListProps> = ({ onSessionLoad }) => {
214 227
   };
215 228
 
216 229
   /**
217
-   * Batch delete selected sessions
230
+   * Batch delete selected sessions (从后端删除)
218 231
    */
219
-  const handleBatchDelete = () => {
232
+  const handleBatchDelete = async () => {
220 233
     if (selectedSessionIds.size === 0) {
221 234
       antdMessage.warning('请先选择要删除的会话');
222 235
       return;
223 236
     }
224 237
 
225
-    // Delete all selected sessions
226
-    selectedSessionIds.forEach(sessionId => {
227
-      deleteSession(sessionId);
228
-    });
229
-
230
-    // Reset selection state
231
-    setSelectedSessionIds(new Set());
232
-    setIsSelectionMode(false);
233
-    
234
-    antdMessage.success(`已删除 ${selectedSessionIds.size} 个会话`);
238
+    try {
239
+      // 从后端批量删除
240
+      const { deleteSessions } = await import('../../services/sessionService');
241
+      const result = await deleteSessions(Array.from(selectedSessionIds));
242
+      
243
+      // 从本地状态删除成功的会话
244
+      selectedSessionIds.forEach(sessionId => {
245
+        deleteSession(sessionId);
246
+      });
247
+
248
+      // Reset selection state
249
+      setSelectedSessionIds(new Set());
250
+      setIsSelectionMode(false);
251
+      
252
+      if (result.failedCount > 0) {
253
+        antdMessage.warning(
254
+          `删除完成:成功 ${result.successCount} 个,失败 ${result.failedCount} 个`
255
+        );
256
+      } else {
257
+        antdMessage.success(
258
+          `成功删除 ${result.successCount} 个会话,共删除 ${result.totalDeletedDocuments} 个文档`
259
+        );
260
+      }
261
+    } catch (error) {
262
+      console.error('批量删除失败:', error);
263
+      antdMessage.error('批量删除失败,请重试');
264
+    }
235 265
   };
236 266
 
237 267
   /**
@@ -267,10 +297,11 @@ const SessionList: React.FC<SessionListProps> = ({ onSessionLoad }) => {
267 297
               <MessageOutlined />
268 298
               <Text type="secondary">{messageCount}</Text>
269 299
             </Space>
300
+            {/* 显示导出文档数量 - 同一会话中可能有多个文档 */}
270 301
             {exportCount > 0 && (
271 302
               <Space size={4}>
272 303
                 <FileTextOutlined />
273
-                <Text type="secondary">{exportCount}</Text>
304
+                <Text type="secondary">{exportCount}个文档</Text>
274 305
               </Space>
275 306
             )}
276 307
             <Text type="secondary" style={{ marginLeft: 'auto' }}>
@@ -281,7 +312,7 @@ const SessionList: React.FC<SessionListProps> = ({ onSessionLoad }) => {
281 312
         {!isSelectionMode && (
282 313
           <Popconfirm
283 314
             title="删除会话"
284
-            description="确定要删除这个会话吗?此操作不可恢复。"
315
+            description={`确定要删除这个会话吗?会话中的${exportCount}个文档也将被删除,此操作不可恢复。`}
285 316
             onConfirm={(e) => handleDeleteSession(session.id, e!)}
286 317
             okText="删除"
287 318
             cancelText="取消"

+ 30 - 0
src/services/documentService.ts

@@ -148,6 +148,35 @@ export const listDocuments = async (
148 148
 };
149 149
 
150 150
 /**
151
+ * List documents by session ID
152
+ *
153
+ * 根据sessionId查询该会话下的所有文档
154
+ * 这是一个便捷方法,自动设置sessionId过滤条件
155
+ *
156
+ * @param sessionId - Session ID to filter documents
157
+ * @param userId - User ID (默认为 'default-user')
158
+ * @param page - Page number (default: 1)
159
+ * @param pageSize - Items per page (default: 100)
160
+ * @returns Paginated list of documents for the session
161
+ * @throws {Error} When the request fails
162
+ */
163
+export const listDocumentsBySession = async (
164
+  sessionId: string,
165
+  userId: string = 'default-user',
166
+  page: number = 1,
167
+  pageSize: number = 100
168
+): Promise<ListDocumentsResponse> => {
169
+  return listDocuments({
170
+    userId,
171
+    sessionId,
172
+    page,
173
+    pageSize,
174
+    sortBy: 'created_at',
175
+    sortOrder: 'desc',
176
+  });
177
+};
178
+
179
+/**
151 180
  * Document service object (alternative export pattern)
152 181
  * Groups all document operations into a single namespace
153 182
  */
@@ -156,6 +185,7 @@ export const documentService = {
156 185
   get: getDocument,
157 186
   delete: deleteDocuments,
158 187
   list: listDocuments,
188
+  listBySession: listDocumentsBySession,
159 189
 };
160 190
 
161 191
 export default documentService;

+ 274 - 0
src/services/sessionService.ts

@@ -0,0 +1,274 @@
1
+/**
2
+ * sessionService.ts - 会话管理服务
3
+ * 
4
+ * 提供会话历史的增删改查功能
5
+ */
6
+
7
+import axios from 'axios';
8
+
9
+const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
10
+
11
+// ══════════════════════════════════════════════════════════════════════════════
12
+// 类型定义
13
+// ══════════════════════════════════════════════════════════════════════════════
14
+
15
+export interface DeleteSessionResponse {
16
+  code: number;
17
+  message: string;
18
+  data: {
19
+    deletedCount: number;
20
+    sessionId: string;
21
+    message: string;
22
+  };
23
+}
24
+
25
+export interface SessionDocument {
26
+  id: string;
27
+  sessionId: string;
28
+  userId: string;
29
+  contentDbPath: string;
30
+  createdAt: number;
31
+  updatedAt: number;
32
+}
33
+
34
+export interface ListDocumentsResponse {
35
+  code: number;
36
+  message: string;
37
+  data: {
38
+    documents: SessionDocument[];
39
+    pagination: {
40
+      page: number;
41
+      pageSize: number;
42
+      total: number;
43
+      totalPages: number;
44
+    };
45
+  };
46
+}
47
+
48
+// ══════════════════════════════════════════════════════════════════════════════
49
+// API 调用
50
+// ══════════════════════════════════════════════════════════════════════════════
51
+
52
+/**
53
+ * 删除会话历史(删除指定 session_id 的所有文档)
54
+ * 
55
+ * @param sessionId - 会话 ID
56
+ * @returns 删除结果
57
+ */
58
+export async function deleteSessionHistory(
59
+  sessionId: string
60
+): Promise<DeleteSessionResponse> {
61
+  try {
62
+    const response = await axios.delete<DeleteSessionResponse>(
63
+      `${API_BASE_URL}/api/v1/documents/${sessionId}`
64
+    );
65
+    return response.data;
66
+  } catch (error) {
67
+    console.error('删除会话历史失败:', error);
68
+    throw error;
69
+  }
70
+}
71
+
72
+/**
73
+ * 获取会话的文档列表
74
+ * 
75
+ * @param sessionId - 会话 ID
76
+ * @param userId - 用户 ID
77
+ * @param page - 页码
78
+ * @param pageSize - 每页数量
79
+ * @returns 文档列表
80
+ */
81
+export async function getSessionDocuments(
82
+  sessionId: string,
83
+  userId: string,
84
+  page: number = 1,
85
+  pageSize: number = 20
86
+): Promise<ListDocumentsResponse> {
87
+  try {
88
+    const response = await axios.get<ListDocumentsResponse>(
89
+      `${API_BASE_URL}/api/v1/documents`,
90
+      {
91
+        params: {
92
+          userId,
93
+          sessionId,
94
+          page,
95
+          pageSize,
96
+        },
97
+      }
98
+    );
99
+    return response.data;
100
+  } catch (error) {
101
+    console.error('获取会话文档列表失败:', error);
102
+    throw error;
103
+  }
104
+}
105
+
106
+/**
107
+ * 获取所有会话列表(分组统计)
108
+ * 
109
+ * @param userId - 用户 ID
110
+ * @returns 会话列表
111
+ */
112
+export async function getSessionList(userId: string): Promise<{
113
+  sessions: Array<{
114
+    sessionId: string;
115
+    documentCount: number;
116
+    latestDocument?: SessionDocument;
117
+  }>;
118
+}> {
119
+  try {
120
+    // 获取所有文档
121
+    const response = await axios.get<ListDocumentsResponse>(
122
+      `${API_BASE_URL}/api/v1/documents`,
123
+      {
124
+        params: {
125
+          userId,
126
+          page: 1,
127
+          pageSize: 1000, // 获取所有文档
128
+        },
129
+      }
130
+    );
131
+
132
+    // 按 session_id 分组
133
+    const sessionMap = new Map<
134
+      string,
135
+      {
136
+        sessionId: string;
137
+        documentCount: number;
138
+        latestDocument?: SessionDocument;
139
+      }
140
+    >();
141
+
142
+    response.data.data.documents.forEach((doc) => {
143
+      const sessionId = doc.sessionId;
144
+      const session = sessionMap.get(sessionId) || {
145
+        sessionId,
146
+        documentCount: 0,
147
+      };
148
+
149
+      session.documentCount++;
150
+      
151
+      // 更新最新文档
152
+      if (
153
+        !session.latestDocument ||
154
+        doc.updatedAt > session.latestDocument.updatedAt
155
+      ) {
156
+        session.latestDocument = doc;
157
+      }
158
+
159
+      sessionMap.set(sessionId, session);
160
+    });
161
+
162
+    return {
163
+      sessions: Array.from(sessionMap.values()).sort((a, b) => {
164
+        // 按最新更新时间降序排序
165
+        const aTime = a.latestDocument?.updatedAt || 0;
166
+        const bTime = b.latestDocument?.updatedAt || 0;
167
+        return bTime - aTime;
168
+      }),
169
+    };
170
+  } catch (error) {
171
+    console.error('获取会话列表失败:', error);
172
+    throw error;
173
+  }
174
+}
175
+
176
+/**
177
+ * 批量删除多个会话
178
+ * 
179
+ * @param sessionIds - 会话 ID 数组
180
+ * @returns 删除结果
181
+ */
182
+export async function deleteSessions(
183
+  sessionIds: string[]
184
+): Promise<{
185
+  successCount: number;
186
+  failedCount: number;
187
+  totalDeletedDocuments: number;
188
+  errors: Array<{ sessionId: string; error: string }>;
189
+}> {
190
+  const results = {
191
+    successCount: 0,
192
+    failedCount: 0,
193
+    totalDeletedDocuments: 0,
194
+    errors: [] as Array<{ sessionId: string; error: string }>,
195
+  };
196
+
197
+  for (const sessionId of sessionIds) {
198
+    try {
199
+      const response = await deleteSessionHistory(sessionId);
200
+      results.successCount++;
201
+      results.totalDeletedDocuments += response.data.deletedCount;
202
+    } catch (error) {
203
+      results.failedCount++;
204
+      results.errors.push({
205
+        sessionId,
206
+        error: error instanceof Error ? error.message : '未知错误',
207
+      });
208
+    }
209
+  }
210
+
211
+  return results;
212
+}
213
+
214
+// ══════════════════════════════════════════════════════════════════════════════
215
+// React Hooks (可选)
216
+// ══════════════════════════════════════════════════════════════════════════════
217
+
218
+/**
219
+ * 使用会话删除功能的 Hook
220
+ * 
221
+ * @returns 删除函数和状态
222
+ */
223
+export function useDeleteSession() {
224
+  const [isDeleting, setIsDeleting] = React.useState(false);
225
+  const [error, setError] = React.useState<string | null>(null);
226
+
227
+  const deleteSession = async (sessionId: string) => {
228
+    setIsDeleting(true);
229
+    setError(null);
230
+
231
+    try {
232
+      const result = await deleteSessionHistory(sessionId);
233
+      return result;
234
+    } catch (err) {
235
+      const errorMessage =
236
+        err instanceof Error ? err.message : '删除会话失败';
237
+      setError(errorMessage);
238
+      throw err;
239
+    } finally {
240
+      setIsDeleting(false);
241
+    }
242
+  };
243
+
244
+  return { deleteSession, isDeleting, error };
245
+}
246
+
247
+// ══════════════════════════════════════════════════════════════════════════════
248
+// 工具函数
249
+// ══════════════════════════════════════════════════════════════════════════════
250
+
251
+/**
252
+ * 格式化会话时间
253
+ * 
254
+ * @param timestamp - 时间戳(毫秒)
255
+ * @returns 格式化的时间字符串
256
+ */
257
+export function formatSessionTime(timestamp: number): string {
258
+  const date = new Date(timestamp);
259
+  const now = new Date();
260
+  const diffMs = now.getTime() - date.getTime();
261
+  const diffMins = Math.floor(diffMs / 60000);
262
+  const diffHours = Math.floor(diffMs / 3600000);
263
+  const diffDays = Math.floor(diffMs / 86400000);
264
+
265
+  if (diffMins < 1) return '刚刚';
266
+  if (diffMins < 60) return `${diffMins} 分钟前`;
267
+  if (diffHours < 24) return `${diffHours} 小时前`;
268
+  if (diffDays < 7) return `${diffDays} 天前`;
269
+
270
+  return date.toLocaleDateString('zh-CN');
271
+}
272
+
273
+// 添加 React 导入(如果使用 Hooks)
274
+import React from 'react';

+ 19 - 3
src/services/workflowService.ts

@@ -26,6 +26,8 @@ interface WorkflowMessage {
26 26
  */
27 27
 interface WorkflowRequest {
28 28
   chatId: string;
29
+  sessionId?: string; // 新增:显式传递sessionId
30
+  timestamp?: number; // 新增:时间戳避免缓存
29 31
   stream?: boolean;
30 32
   detail?: boolean;
31 33
   messages: WorkflowMessage[];
@@ -143,14 +145,16 @@ export const shouldTriggerWorkflow = (input: string): boolean => {
143 145
  * 3. Calls the local backend API (http://192.168.0.195:8000/api/v1/export/records)
144 146
  * 4. Returns the export record with downloadUrl
145 147
  *
148
+ * 重要:工作流会传递session_id给后端,确保同一个会话中生成的多个文档共用同一个session_id
149
+ *
146 150
  * @param userInput - User's text input (e.g., "生成一个地质报告")
147
- * @param chatId - Chat session ID
151
+ * @param sessionId - Chat session ID (用于关联多个文档到同一会话)
148 152
  * @returns Promise resolving to AI response text and optional export record
149 153
  * @throws {Error} When workflow call fails
150 154
  */
151 155
 export const triggerDocumentWorkflow = async (
152 156
   userInput: string,
153
-  chatId: string
157
+  sessionId: string
154 158
 ): Promise<{ content: string; exportRecord?: ExportRecordInfo }> => {
155 159
   try {
156 160
     const config = getWorkflowConfig();
@@ -162,7 +166,17 @@ export const triggerDocumentWorkflow = async (
162 166
       };
163 167
     }
164 168
 
169
+    // ⭐ 重要: 添加时间戳避免工作流缓存
170
+    // 每次调用都会生成唯一的chatId,但sessionId保持不变
171
+    // 这样工作流会为每个请求生成新的文档,但它们都关联到同一个sessionId
172
+    const timestamp = Date.now();
173
+    const uniqueChatId = `${sessionId}__${timestamp}`;
174
+
165 175
     // Call the workflow API
176
+    // 关键点:
177
+    // 1. chatId: 带时间戳的唯一ID,确保每次请求都被视为新请求
178
+    // 2. sessionId: 原始会话ID,传递给工作流,工作流会将其传递给后端 /api/v1/export/records
179
+    // 3. 后端在创建文档时会使用这个sessionId,确保同一会话的多个文档共用相同的session_id
166 180
     const response = await fetch(config.apiUrl, {
167 181
       method: 'POST',
168 182
       headers: {
@@ -170,7 +184,9 @@ export const triggerDocumentWorkflow = async (
170 184
         Authorization: `Bearer ${config.apiKey}`,
171 185
       },
172 186
       body: JSON.stringify({
173
-        chatId,
187
+        chatId: uniqueChatId, // 带时间戳的唯一ID,避免工作流缓存
188
+        sessionId: sessionId, // 原始sessionId,供工作流调用后端API时使用
189
+        timestamp: timestamp, // 显式传递时间戳
174 190
         stream: false,
175 191
         detail: false,
176 192
         messages: [

+ 41 - 0
src/stores/chatStore.ts

@@ -10,6 +10,13 @@
10 10
  * - Sending messages to AI using external AI platform API
11 11
  * - Auto-generating Word documents for specific requests
12 12
  *
13
+ * ⭐ Session ID 和多文档关联逻辑:
14
+ * - 每个会话(session)有一个唯一的sessionId(使用UUID v4生成)
15
+ * - 在一个会话中生成的所有文档都会共用这个sessionId
16
+ * - 工作流会将sessionId传递给后端,后端在创建document时存储session_id字段
17
+ * - 通过session_id可以查询该会话下的所有文档
18
+ * - 删除会话时会删除所有关联的文档
19
+ *
13 20
  * @module stores/chatStore
14 21
  */
15 22
 
@@ -326,9 +333,43 @@ export const useChatStore = create<ChatStoreState>((set, get) => ({
326 333
 
327 334
       if (shouldUseWorkflow) {
328 335
         // Use workflow for document generation
336
+        // ⭐ 重要: 传递 sessionId 确保生成的文档与当前会话关联
337
+        // 在同一个会话中生成多个文档时:
338
+        // - 每次调用 triggerDocumentWorkflow 都会传递相同的 sessionId
339
+        // - 工作流会调用后端 /api/v1/export/records,并传递这个 sessionId
340
+        // - 后端在创建文档时会存储这个 session_id
341
+        // - 这样在数据库中就会有多条记录,id 不同但 session_id 相同
329 342
         const workflowResult = await triggerDocumentWorkflow(content, sessionId);
330 343
         aiResponse = workflowResult.content;
331 344
         exportRecord = workflowResult.exportRecord;
345
+        
346
+        // ⭐ 关键修改: 工作流返回 exportRecord 后,立即调用 POST /api/v1/documents
347
+        // 这样每次生成文档时都会在数据库中创建记录,而不是等用户点击"预览和编辑"
348
+        if (exportRecord) {
349
+          try {
350
+            // 动态导入 documentService 避免循环依赖
351
+            const { createDocument } = await import('../services/documentService');
352
+            
353
+            // 立即创建文档记录
354
+            const response = await createDocument({
355
+              userId: 'default-user', // TODO: 从认证上下文获取
356
+              fileUrl: exportRecord.downloadUrl,
357
+              sessionId: sessionId,
358
+            });
359
+            
360
+            // 更新 exportRecord 中的 documentId (用于后续预览)
361
+            exportRecord.documentId = response.documentId;
362
+            
363
+            // 缓存 recordId -> documentId 映射 (避免重复创建)
364
+            const cacheKey = `doc_cache_${sessionId}_${exportRecord.recordId}`;
365
+            localStorage.setItem(cacheKey, response.documentId);
366
+            
367
+            console.log(`✅ Document created automatically: ${response.documentId} with sessionId: ${sessionId}`);
368
+          } catch (error) {
369
+            console.error('❌ Failed to create document record:', error);
370
+            // 即使创建失败,也继续流程,用户可以稍后点击重试
371
+          }
372
+        }
332 373
       } else {
333 374
         // Use regular AI service for normal chat
334 375
         const aiResult = await getAIResponse(content, sessionId, get().messages);

+ 1 - 0
src/types/editor.ts

@@ -132,6 +132,7 @@ export interface TableRow {
132 132
  */
133 133
 export interface TableContent {
134 134
   rows: TableRow[];
135
+  col_widths?: number[];  // 列宽数组(pt单位),可选
135 136
 }
136 137
 
137 138
 export interface TableBlock extends BaseBlock {

+ 126 - 39
src/utils/blockOperations.ts

@@ -237,7 +237,7 @@ export function normalizeTableBlock(table: TableBlock): TableBlock {
237 237
  * 
238 238
  * @param colIndex 列索引(从1开始,可选)
239 239
  * @param width 单元格宽度(磅,可选)
240
- * @returns 空单元格
240
+ * @returns 空单元格,使用默认样式
241 241
  */
242 242
 export function createEmptyCell(colIndex?: number, width?: number): TableCell {
243 243
   return {
@@ -245,9 +245,13 @@ export function createEmptyCell(colIndex?: number, width?: number): TableCell {
245 245
     rowspan: 1,
246 246
     colspan: 1,
247 247
     col_index: colIndex,
248
-    style: {},
248
+    style: {
249
+      align: 'center' as const,      // 默认居中对齐
250
+      font_size: 12,                  // 默认字号12pt
251
+      font_name: '黑体',              // 默认字体黑体
252
+    },
249 253
     word_style: 'Normal',
250
-    width: width,
254
+    width: width !== undefined ? width : 100, // 默认宽度100磅
251 255
   };
252 256
 }
253 257
 
@@ -284,10 +288,14 @@ export function createEmptyRow(cols: number, colWidths?: number[], height?: numb
284 288
  * ```
285 289
  */
286 290
 export function insertTableRow(table: TableBlock, afterRow: number): TableBlock {
287
-  // 使用相邻行的高度作为新行的高度
288
-  const referenceHeight = table.content.rows[afterRow]?.height || 58;
291
+  // 使用默认行高,不继承上一行的高度
292
+  const defaultHeight = 58; // 默认行高58磅
293
+  
294
+  // 使用默认列宽,不继承表格的列宽
295
+  const defaultWidth = 100; // 默认列宽100磅
296
+  const defaultColWidths = Array(table.metadata.cols).fill(defaultWidth);
289 297
   
290
-  const newRow = createEmptyRow(table.metadata.cols, table.metadata.col_widths, referenceHeight);
298
+  const newRow = createEmptyRow(table.metadata.cols, defaultColWidths, defaultHeight);
291 299
   const rows = [...table.content.rows];
292 300
   rows.splice(afterRow + 1, 0, newRow);
293 301
   
@@ -314,14 +322,15 @@ export function insertTableRow(table: TableBlock, afterRow: number): TableBlock
314 322
  * ```
315 323
  */
316 324
 export function insertTableColumn(table: TableBlock, afterCol: number): TableBlock {
325
+  // 使用默认宽度,不继承上一列的宽度
326
+  const defaultWidth = 100; // 默认列宽100磅
327
+  
317 328
   const rows = table.content.rows.map((row) => {
318 329
     const cells = [...row.cells];
319 330
     
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));
331
+    // 插入新单元格,使用默认宽度和默认样式
332
+    const newCell = createEmptyCell(afterCol + 2, defaultWidth);
333
+    cells.splice(afterCol + 1, 0, newCell);
325 334
     
326 335
     // 更新后续单元格的 col_index
327 336
     for (let i = afterCol + 2; i < cells.length; i++) {
@@ -340,8 +349,7 @@ export function insertTableColumn(table: TableBlock, afterCol: number): TableBlo
340 349
   });
341 350
   
342 351
   const colWidths = [...table.metadata.col_widths];
343
-  const avgWidth = colWidths.reduce((sum, w) => sum + w, 0) / colWidths.length;
344
-  colWidths.splice(afterCol + 1, 0, avgWidth);
352
+  colWidths.splice(afterCol + 1, 0, defaultWidth);
345 353
   
346 354
   return {
347 355
     ...table,
@@ -545,6 +553,11 @@ export function mergeCells(
545 553
  * 将已合并的单元格拆分回独立单元格
546 554
  * 主单元格保留原有内容,其他单元格恢复为空单元格
547 555
  * 
556
+ * 关键逻辑:
557
+ * 1. 对于横向合并(colspan>1),需要在当前行**插入**新的单元格
558
+ * 2. 对于纵向合并(rowspan>1),需要恢复被隐藏的单元格(rowspan=0, colspan=0)
559
+ * 3. 对于同时横向和纵向合并,需要同时处理插入和恢复
560
+ * 
548 561
  * @param table 表格块
549 562
  * @param rowIndex 单元格所在行
550 563
  * @param colIndex 单元格所在列
@@ -581,39 +594,113 @@ export function splitCell(
581 594
   const colspan = targetCell.colspan || 1;
582 595
   
583 596
   const rows = table.content.rows.map((row, rowIdx) => {
584
-    // 不在合并范围内的行直接返回
585
-    if (rowIdx < rowIndex || rowIdx >= rowIndex + rowspan) {
586
-      return row;
597
+    const cells = [...row.cells];
598
+    
599
+    // 处理主单元格所在的行
600
+    if (rowIdx === rowIndex) {
601
+      // 1. 修改主单元格,清除合并标记
602
+      cells[colIndex] = {
603
+        ...cells[colIndex],
604
+        rowspan: 1,
605
+        colspan: 1,
606
+        col_index: colIndex + 1,
607
+        word_style: cells[colIndex].word_style || 'Normal',
608
+      };
609
+      
610
+      // 2. 如果有横向合并(colspan>1),在主单元格后面插入新的空单元格
611
+      if (colspan > 1) {
612
+        const newCells: TableCell[] = [];
613
+        for (let i = 0; i < colspan - 1; i++) {
614
+          newCells.push({
615
+            text: '',
616
+            rowspan: 1,
617
+            colspan: 1,
618
+            col_index: colIndex + 2 + i, // col_index从1开始
619
+            style: {
620
+              align: 'center' as const,
621
+              font_size: 12,
622
+              font_name: '黑体',
623
+            },
624
+            word_style: 'Normal',
625
+            width: cells[colIndex].width || 100,
626
+          });
627
+        }
628
+        // 在主单元格后插入新单元格
629
+        cells.splice(colIndex + 1, 0, ...newCells);
630
+        
631
+        // 更新后续单元格的 col_index
632
+        for (let i = colIndex + colspan; i < cells.length; i++) {
633
+          cells[i] = {
634
+            ...cells[i],
635
+            col_index: (cells[i].col_index || 0) + (colspan - 1),
636
+          };
637
+        }
638
+      }
639
+      
640
+      return { cells, height: row.height };
587 641
     }
588 642
     
589
-    const cells = row.cells.map((cell, colIdx) => {
590
-      // 不在合并范围内的列直接返回
591
-      if (colIdx < colIndex || colIdx >= colIndex + colspan) {
643
+    // 处理被纵向合并影响的其他行
644
+    if (rowIdx > rowIndex && rowIdx < rowIndex + rowspan) {
645
+      // 查找并恢复被隐藏的单元格
646
+      let foundHiddenCell = false;
647
+      const newCells = cells.map((cell, cellIdx) => {
648
+        // 找到对应列位置的被隐藏单元格
649
+        if (cellIdx === colIndex && (cell.rowspan === 0 || cell.colspan === 0)) {
650
+          foundHiddenCell = true;
651
+          
652
+          // 恢复为单个正常单元格
653
+          return {
654
+            text: '',
655
+            rowspan: 1,
656
+            colspan: 1,
657
+            col_index: colIndex + 1,
658
+            style: {
659
+              align: 'center' as const,
660
+              font_size: 12,
661
+              font_name: '黑体',
662
+            },
663
+            word_style: 'Normal',
664
+            width: cell.width || 100,
665
+          } as TableCell;
666
+        }
592 667
         return cell;
593
-      }
668
+      });
594 669
       
595
-      // 主单元格:恢复为普通单元格,清除合并标记
596
-      if (rowIdx === rowIndex && colIdx === colIndex) {
597
-        return {
598
-          ...cell,
599
-          rowspan: 1,
600
-          colspan: 1,
601
-          col_index: colIdx + 1, // 确保 col_index 存在
602
-          word_style: cell.word_style || 'Normal', // 确保 word_style 存在
603
-        };
670
+      // 如果主单元格有横向合并,需要插入额外的单元格
671
+      if (foundHiddenCell && colspan > 1) {
672
+        const additionalCells: TableCell[] = [];
673
+        for (let i = 0; i < colspan - 1; i++) {
674
+          additionalCells.push({
675
+            text: '',
676
+            rowspan: 1,
677
+            colspan: 1,
678
+            col_index: colIndex + 2 + i,
679
+            style: {
680
+              align: 'center' as const,
681
+              font_size: 12,
682
+              font_name: '黑体',
683
+            },
684
+            word_style: 'Normal',
685
+            width: newCells[colIndex].width || 100,
686
+          });
687
+        }
688
+        newCells.splice(colIndex + 1, 0, ...additionalCells);
689
+        
690
+        // 更新后续单元格的 col_index
691
+        for (let i = colIndex + colspan; i < newCells.length; i++) {
692
+          newCells[i] = {
693
+            ...newCells[i],
694
+            col_index: (newCells[i].col_index || 0) + (colspan - 1),
695
+          };
696
+        }
604 697
       }
605 698
       
606
-      // 被合并的单元格:恢复为独立空单元格
607
-      return createEmptyCell(
608
-        colIdx + 1, // col_index从1开始
609
-        cell.width // 保留原有宽度
610
-      );
611
-    });
699
+      return { cells: newCells, height: row.height };
700
+    }
612 701
     
613
-    return { 
614
-      cells,
615
-      height: row.height, // 保留行高
616
-    };
702
+    // 其他行不受影响
703
+    return { cells, height: row.height };
617 704
   });
618 705
   
619 706
   return {

+ 118 - 0
src/utils/tableUtils.ts

@@ -0,0 +1,118 @@
1
+/**
2
+ * tableUtils.ts - 表格工具函数
3
+ * 
4
+ * 处理表格尺寸计算和转换
5
+ */
6
+
7
+/**
8
+ * 计算列宽百分比转pt
9
+ * 
10
+ * @param percentWidths - 百分比宽度数组
11
+ * @param tableWidthPercent - 表格宽度百分比(默认100)
12
+ * @param pageWidthPt - 页面可用宽度(pt单位,默认A4纸宽度减去边距)
13
+ * @returns pt单位的列宽数组
14
+ */
15
+export function percentToPtWidths(
16
+  percentWidths: number[],
17
+  tableWidthPercent: number = 100,
18
+  pageWidthPt: number = 478 // A4纸宽度595pt - 左右边距各71pt ≈ 453pt
19
+): number[] {
20
+  const tableActualWidthPt = pageWidthPt * (tableWidthPercent / 100);
21
+  return percentWidths.map(percent => 
22
+    Math.round((tableActualWidthPt * percent / 100) * 10) / 10
23
+  );
24
+}
25
+
26
+/**
27
+ * 计算列宽pt转百分比
28
+ * 
29
+ * @param ptWidths - pt单位的列宽数组
30
+ * @returns 百分比宽度数组
31
+ */
32
+export function ptToPercentWidths(ptWidths: number[]): number[] {
33
+  const totalPt = ptWidths.reduce((sum, w) => sum + w, 0);
34
+  if (totalPt === 0) return ptWidths.map(() => 0);
35
+  
36
+  const percentWidths = ptWidths.map(w => (w / totalPt) * 100);
37
+  
38
+  // 归一化,确保总和为100%
39
+  const totalPercent = percentWidths.reduce((sum, w) => sum + w, 0);
40
+  if (Math.abs(totalPercent - 100) > 0.1) {
41
+    const factor = 100 / totalPercent;
42
+    return percentWidths.map(w => Math.round(w * factor * 100) / 100);
43
+  }
44
+  
45
+  return percentWidths.map(w => Math.round(w * 100) / 100);
46
+}
47
+
48
+/**
49
+ * 像素转pt (在96 DPI下)
50
+ * 
51
+ * @param pixels - 像素值
52
+ * @returns pt值
53
+ */
54
+export function pixelToPt(pixels: number): number {
55
+  // 1pt = 4/3 像素 (在96 DPI下)
56
+  // 1像素 = 3/4 pt = 0.75pt
57
+  return Math.round(pixels * 0.75 * 10) / 10;
58
+}
59
+
60
+/**
61
+ * pt转像素 (在96 DPI下)
62
+ * 
63
+ * @param pt - pt值
64
+ * @returns 像素值
65
+ */
66
+export function ptToPixel(pt: number): number {
67
+  // 1pt = 4/3 像素
68
+  return Math.round(pt * 1.333 * 10) / 10;
69
+}
70
+
71
+/**
72
+ * 归一化百分比数组,确保总和为100%
73
+ * 
74
+ * @param percentWidths - 百分比宽度数组
75
+ * @returns 归一化后的百分比数组
76
+ */
77
+export function normalizePercents(percentWidths: number[]): number[] {
78
+  const total = percentWidths.reduce((sum, w) => sum + w, 0);
79
+  if (total === 0) return percentWidths;
80
+  
81
+  if (Math.abs(total - 100) < 0.1) {
82
+    return percentWidths; // 已经接近100%
83
+  }
84
+  
85
+  const factor = 100 / total;
86
+  return percentWidths.map(w => Math.round(w * factor * 100) / 100);
87
+}
88
+
89
+/**
90
+ * 验证并修复表格数据的一致性
91
+ * 
92
+ * @param content - 表格内容
93
+ * @param metadata - 表格元数据
94
+ * @returns 修复后的数据
95
+ */
96
+export function validateTableData(
97
+  content: { rows: any[]; col_widths?: number[] },
98
+  metadata: { cols: number; col_widths: number[]; table_width: number }
99
+): {
100
+  content: { rows: any[]; col_widths?: number[] };
101
+  metadata: { cols: number; col_widths: number[]; table_width: number };
102
+} {
103
+  // 确保metadata.col_widths存在且长度正确
104
+  if (!metadata.col_widths || metadata.col_widths.length !== metadata.cols) {
105
+    // 平均分配
106
+    metadata.col_widths = Array(metadata.cols).fill(100 / metadata.cols);
107
+  }
108
+  
109
+  // 如果content.col_widths不存在,从metadata计算
110
+  if (!content.col_widths || content.col_widths.length !== metadata.cols) {
111
+    content.col_widths = percentToPtWidths(
112
+      metadata.col_widths,
113
+      metadata.table_width
114
+    );
115
+  }
116
+  
117
+  return { content, metadata };
118
+}