Przeglądaj źródła

feat(DocumentManagement): Add document outline and viewer components

- Remove EDITOR_DEFAULT_EDIT_MODE.md documentation file
- Add DocumentOutline component for displaying document structure
- Add DocumentViewer component for document preview functionality
- Update DocumentManagement to improve export handling with better error messages and file name display
- Refactor DocumentManagementLayout with new layout structure
- Update EditorPanel with refinements to editor functionality
- Enhance MarkdownPreview component styling and behavior
- Improve exportService with better URL formatting and response handling
- Update export types to include fileName and enhanced metadata
- Streamline document viewing workflow with dedicated viewer component
Zhang Yice 2 miesięcy temu
rodzic
commit
3bba6331de

+ 0 - 324
EDITOR_DEFAULT_EDIT_MODE.md

@@ -1,324 +0,0 @@
1
-# EditorPanel 默认编辑模式
2
-
3
-## ✨ 功能说明
4
-
5
-EditorPanel现在默认以**编辑模式**打开,用户可以直接编辑文档内容,无需点击"编辑"按钮。
6
-
7
-### 修改内容
8
-
9
-1. **移除预览/编辑切换**: 不再有预览模式和编辑模式的切换
10
-2. **移除"编辑"按钮**: 工具栏不再显示"编辑"按钮
11
-3. **默认可编辑**: 打开编辑器后内容区域直接可编辑
12
-4. **简化工具栏**: 只保留"导出"、"保存"、"关闭"按钮
13
-
14
-## 🎨 界面对比
15
-
16
-### 修改前
17
-```
18
-┌─────────────────────────────────────────────────┐
19
-│ 文档标题  [导出] [编辑] [X]                      │
20
-├─────────────────────────────────────────────────┤
21
-│                                                 │
22
-│  # 地质报告 (预览模式,只读)                     │
23
-│                                                 │
24
-│  用户需要点击[编辑]按钮才能编辑                   │
25
-│                                                 │
26
-└─────────────────────────────────────────────────┘
27
-```
28
-
29
-### 修改后
30
-```
31
-┌─────────────────────────────────────────────────┐
32
-│ 文档标题  [导出] [保存] [X]                      │
33
-├─────────────────────────────────────────────────┤
34
-│                                                 │
35
-│  # 地质报告 (编辑器,直接可编辑)                  │
36
-│                                                 │
37
-│  用户可以立即开始编辑,无需额外操作                │
38
-│                                                 │
39
-└─────────────────────────────────────────────────┘
40
-```
41
-
42
-## 📝 修改的内容
43
-
44
-### 1. 移除的功能
45
-
46
-- ❌ `isEditMode` 状态变量
47
-- ❌ `handleToggleEdit` 函数(编辑/预览切换)
48
-- ❌ `handleCancelEdit` 函数(取消编辑)
49
-- ❌ "编辑"按钮
50
-- ❌ "取消"按钮  
51
-- ❌ Markdown预览模式
52
-- ❌ ReactMarkdown相关导入
53
-- ❌ markdownStyle样式
54
-- ❌ markdownComponents配置
55
-- ❌ markdownContent状态
56
-
57
-### 2. 保留的功能
58
-
59
-- ✅ 文档标题显示
60
-- ✅ "保存"按钮和功能
61
-- ✅ "导出"按钮和功能
62
-- ✅ "关闭"按钮
63
-- ✅ Markdown编辑器(TextArea)
64
-- ✅ 加载状态
65
-- ✅ 错误处理
66
-
67
-### 3. 简化的工具栏
68
-
69
-**之前(两种模式):**
70
-```typescript
71
-// 预览模式
72
-[导出] [编辑] | [X]
73
-
74
-// 编辑模式
75
-[取消] [保存] | [X]
76
-```
77
-
78
-**现在(单一模式):**
79
-```typescript
80
-// 始终编辑模式
81
-[导出] [保存] | [X]
82
-```
83
-
84
-## 🔧 技术细节
85
-
86
-### 状态管理简化
87
-
88
-**之前:**
89
-```typescript
90
-const [isEditMode, setIsEditMode] = useState(false);
91
-const [markdownContent, setMarkdownContent] = useState('');
92
-const [editContent, setEditContent] = useState('');
93
-
94
-// 需要同步两个内容状态
95
-```
96
-
97
-**现在:**
98
-```typescript
99
-const [editContent, setEditContent] = useState('');
100
-
101
-// 只需要一个内容状态
102
-```
103
-
104
-### 加载文档简化
105
-
106
-**之前:**
107
-```typescript
108
-const data = await getDocumentContent(docId);
109
-setMarkdownContent(data.content);  // 预览内容
110
-setEditContent(data.content);      // 编辑内容
111
-```
112
-
113
-**现在:**
114
-```typescript
115
-const data = await getDocumentContent(docId);
116
-setEditContent(data.content);      // 直接设置编辑内容
117
-```
118
-
119
-### 保存文档简化
120
-
121
-**之前:**
122
-```typescript
123
-await updateDocument(documentId, { content: editContent });
124
-setMarkdownContent(editContent);  // 同步到预览
125
-setIsEditMode(false);             // 切换到预览模式
126
-```
127
-
128
-**现在:**
129
-```typescript
130
-await updateDocument(documentId, { content: editContent });
131
-// 保持在编辑模式,无需切换
132
-```
133
-
134
-### 渲染逻辑简化
135
-
136
-**之前:**
137
-```typescript
138
-{isEditMode ? (
139
-  <TextArea value={editContent} ... />
140
-) : (
141
-  <ReactMarkdown>{markdownContent}</ReactMarkdown>
142
-)}
143
-```
144
-
145
-**现在:**
146
-```typescript
147
-<TextArea value={editContent} ... />
148
-// 始终显示编辑器
149
-```
150
-
151
-## 📊 代码量对比
152
-
153
-| 项目 | 之前 | 现在 | 减少 |
154
-|------|------|------|------|
155
-| 状态变量 | 4 | 3 | -25% |
156
-| 函数 | 6 | 4 | -33% |
157
-| 依赖导入 | 8 | 4 | -50% |
158
-| 样式定义 | 5 | 3 | -40% |
159
-| 条件渲染 | 3 | 0 | -100% |
160
-
161
-## 🎯 用户体验改进
162
-
163
-### 操作步骤对比
164
-
165
-**之前:**
166
-```
167
-1. 点击文档链接
168
-2. 等待加载
169
-3. 看到预览模式(只读)
170
-4. 点击"编辑"按钮
171
-5. 进入编辑模式
172
-6. 开始编辑
173
-```
174
-**6步操作**
175
-
176
-**现在:**
177
-```
178
-1. 点击文档链接  
179
-2. 等待加载
180
-3. 直接开始编辑
181
-```
182
-**3步操作,减少50%**
183
-
184
-### 认知负担
185
-
186
-**之前:**
187
-- 用户需要理解"预览"和"编辑"两种模式
188
-- 需要记住点击"编辑"按钮才能修改
189
-- 担心误操作时有"取消"按钮
190
-
191
-**现在:**
192
-- 只有一种模式:编辑
193
-- 打开即可编辑,符合直觉
194
-- 简化的界面,减少决策
195
-
196
-## 💡 设计理念
197
-
198
-### 为什么移除预览模式?
199
-
200
-1. **明确意图**: 用户点击文档链接的目的就是要编辑它
201
-2. **减少步骤**: 移除不必要的中间状态
202
-3. **所见即所编**: Markdown本身就很易读
203
-4. **简化界面**: 减少按钮,降低复杂度
204
-5. **快速上手**: 新用户无需学习模式切换
205
-
206
-### 如果需要预览怎么办?
207
-
208
-Markdown编辑器中的内容本身就是相对易读的:
209
-
210
-```markdown
211
-# 标题
212
-## 子标题
213
-- 列表项
214
-- 列表项
215
-
216
-这是一段文字...
217
-```
218
-
219
-如果确实需要预览渲染后的效果:
220
-- 可以使用"导出"功能查看Word文档
221
-- 或者在未来版本中添加"预览"标签页(分屏显示)
222
-
223
-## 🚀 使用方法
224
-
225
-### 1. 打开文档
226
-```
227
-在聊天中点击文档链接
228
-→ 编辑器自动打开
229
-→ 内容已加载到编辑器
230
-→ 光标就绪,可以立即输入
231
-```
232
-
233
-### 2. 编辑文档
234
-```
235
-直接在编辑器中修改Markdown内容
236
-→ 实时显示字符数和行数
237
-→ 支持Markdown语法高亮
238
-→ 自动保存草稿(未来功能)
239
-```
240
-
241
-### 3. 保存更改
242
-```
243
-点击[保存]按钮
244
-→ 内容保存到后端
245
-→ 提示"保存成功"
246
-→ 继续编辑
247
-```
248
-
249
-### 4. 导出文档
250
-```
251
-点击[导出]按钮
252
-→ 生成Word文档
253
-→ 自动下载
254
-→ 可在Word中查看最终效果
255
-```
256
-
257
-### 5. 关闭编辑器
258
-```
259
-点击[X]按钮
260
-→ 返回导出记录列表
261
-→ 未保存的更改会丢失(会有提示)
262
-```
263
-
264
-## ⚠️ 注意事项
265
-
266
-### 未保存提示
267
-
268
-建议在关闭编辑器前添加确认提示:
269
-```typescript
270
-const handleClose = () => {
271
-  if (hasUnsavedChanges) {
272
-    Modal.confirm({
273
-      title: '有未保存的更改',
274
-      content: '是否保存后再关闭?',
275
-      okText: '保存并关闭',
276
-      cancelText: '放弃更改',
277
-      onOk: async () => {
278
-        await handleSave();
279
-        onClose();
280
-      },
281
-      onCancel: () => {
282
-        onClose();
283
-      },
284
-    });
285
-  } else {
286
-    onClose();
287
-  }
288
-};
289
-```
290
-
291
-### 自动保存(未来功能)
292
-
293
-可以添加自动保存功能:
294
-```typescript
295
-useEffect(() => {
296
-  const timer = setInterval(() => {
297
-    if (hasChanges) {
298
-      handleSave();
299
-    }
300
-  }, 30000); // 每30秒自动保存
301
-
302
-  return () => clearInterval(timer);
303
-}, [hasChanges, handleSave]);
304
-```
305
-
306
-## 🔄 未来优化
307
-
308
-1. **分屏预览**: 左侧编辑器,右侧实时预览
309
-2. **Markdown工具栏**: 快捷按钮插入格式
310
-3. **语法高亮**: 编辑器中高亮显示Markdown语法
311
-4. **自动保存**: 定时保存或实时同步
312
-5. **版本历史**: 记录编辑历史,可回退
313
-6. **协作编辑**: 多人同时编辑同一文档
314
-
315
-## ✨ 总结
316
-
317
-- **简化流程**: 从6步减少到3步
318
-- **提升效率**: 打开即编辑,无需切换
319
-- **降低复杂度**: 移除模式切换,单一界面
320
-- **代码优化**: 减少50%的代码量和依赖
321
-
322
----
323
-
324
-**编辑器现在默认就是编辑模式,可以立即使用!** 🎉

+ 11 - 6
src/components/DocumentManagement/DocumentManagement.tsx

@@ -131,18 +131,23 @@ export const DocumentManagement: React.FC<DocumentManagementProps> = ({
131 131
     setExporting((prev) => ({ ...prev, [documentId]: true }));
132 132
 
133 133
     try {
134
-      const result = await exportToWord({ documentId });
134
+      const result = await exportToWord({ 
135
+        documentId,
136
+        // styleId: null,  // 后端暂不支持样式ID(阶段 1 功能)
137
+      });
135 138
 
136
-      message.success('文档导出成功!');
139
+      message.success(`导出成功: ${result.fileName}`);
137 140
 
138 141
       if (result.warning) {
139 142
         message.warning(result.warning, 5);
140 143
       }
141 144
 
142
-      // Open download URL in new tab
143
-      window.open(result.downloadUrl, '_blank');
145
+      // The downloadUrl is now properly formatted with base URL in the service
146
+      if (result.downloadUrl) {
147
+        window.open(result.downloadUrl, '_blank');
148
+      }
144 149
     } catch (error) {
145
-      message.error('文档导出失败');
150
+      message.error('导出失败: ' + (error instanceof Error ? error.message : '未知错误'));
146 151
     } finally {
147 152
       setExporting((prev) => ({ ...prev, [documentId]: false }));
148 153
     }
@@ -334,7 +339,7 @@ export const DocumentManagement: React.FC<DocumentManagementProps> = ({
334 339
             key="export"
335 340
             type="primary"
336 341
             icon={<DownloadOutlined />}
337
-            loading={viewingDocument && exporting[viewingDocument.id]}
342
+            loading={viewingDocument ? exporting[viewingDocument.id] || false : false}
338 343
             onClick={() => viewingDocument && handleExportDocument(viewingDocument.id)}
339 344
           >
340 345
             导出Word

+ 30 - 5
src/components/DocumentManagementLayout/DocumentManagementLayout.tsx

@@ -28,6 +28,7 @@ import {
28 28
   type DocumentStructure,
29 29
   type OutlineItem,
30 30
 } from '../../services/documentContentService';
31
+import { exportToWord } from '../../services/exportService';
31 32
 
32 33
 // ── Styles ────────────────────────────────────────────────────────────────
33 34
 
@@ -234,12 +235,36 @@ const DocumentManagementLayout: React.FC<DocumentManagementLayoutProps> = ({
234 235
   /**
235 236
    * Handle export button click
236 237
    */
237
-  const handleExport = useCallback(() => {
238
-    if (documentData) {
239
-      message.info('导出功能开发中');
240
-      // TODO: Implement export functionality
238
+  const handleExport = useCallback(async () => {
239
+    if (!selectedRecordId) {
240
+      message.warning('请先选择要导出的文档');
241
+      return;
241 242
     }
242
-  }, [documentData]);
243
+
244
+    try {
245
+      setLoading(true);
246
+      
247
+      const result = await exportToWord({
248
+        documentId: selectedRecordId,
249
+        // styleId: null,  // 后端暂不支持样式ID(阶段 1 功能)
250
+      });
251
+      
252
+      message.success(`导出成功: ${result.fileName}`);
253
+      
254
+      // Open download URL in new tab
255
+      if (result.downloadUrl) {
256
+        const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://192.168.0.195:8000';
257
+        const fullUrl = result.downloadUrl.startsWith('http') 
258
+          ? result.downloadUrl 
259
+          : `${baseUrl}${result.downloadUrl}`;
260
+        window.open(fullUrl, '_blank');
261
+      }
262
+    } catch (error) {
263
+      message.error('导出失败: ' + (error instanceof Error ? error.message : '未知错误'));
264
+    } finally {
265
+      setLoading(false);
266
+    }
267
+  }, [selectedRecordId]);
243 268
 
244 269
   /**
245 270
    * Handle share button click

+ 164 - 0
src/components/DocumentOutline/DocumentOutline.tsx

@@ -0,0 +1,164 @@
1
+/**
2
+ * DocumentOutline Component
3
+ *
4
+ * Displays a hierarchical document outline (table of contents) with:
5
+ * - Nested heading structure
6
+ * - Active item highlighting
7
+ * - Click navigation to sections
8
+ * - Section numbering
9
+ *
10
+ * @module components/DocumentOutline
11
+ */
12
+
13
+import React from 'react';
14
+import { Tree } from 'antd';
15
+import type { DataNode } from 'antd/es/tree';
16
+import type { OutlineItem } from '../../services/documentContentService';
17
+import { FileTextOutlined } from '@ant-design/icons';
18
+
19
+// ── Styles ────────────────────────────────────────────────────────────────
20
+
21
+const containerStyle: React.CSSProperties = {
22
+  height: '100%',
23
+  display: 'flex',
24
+  flexDirection: 'column',
25
+  overflow: 'hidden',
26
+  backgroundColor: '#fafafa',
27
+};
28
+
29
+const headerStyle: React.CSSProperties = {
30
+  padding: '16px',
31
+  borderBottom: '1px solid #e8e8e8',
32
+  backgroundColor: '#ffffff',
33
+  fontWeight: 600,
34
+  fontSize: '14px',
35
+  color: '#262626',
36
+  display: 'flex',
37
+  alignItems: 'center',
38
+  gap: '8px',
39
+};
40
+
41
+const treeContainerStyle: React.CSSProperties = {
42
+  flex: 1,
43
+  overflow: 'auto',
44
+  padding: '12px',
45
+};
46
+
47
+// ── Component Props ─────────────────────────────────────────────────────────
48
+
49
+export interface DocumentOutlineProps {
50
+  /** Outline data (hierarchical structure) */
51
+  outline: OutlineItem[];
52
+  /** Currently active item ID */
53
+  activeId?: string;
54
+  /** Callback when outline item is clicked */
55
+  onItemClick: (item: OutlineItem) => void;
56
+}
57
+
58
+// ── Helper Functions ────────────────────────────────────────────────────────
59
+
60
+/**
61
+ * Convert OutlineItem to Ant Design Tree DataNode
62
+ */
63
+const convertToTreeData = (items: OutlineItem[], onItemClick: (item: OutlineItem) => void): DataNode[] => {
64
+  return items.map((item) => ({
65
+    key: item.id,
66
+    title: (
67
+      <span
68
+        style={{
69
+          fontSize: '13px',
70
+          color: '#595959',
71
+          cursor: 'pointer',
72
+        }}
73
+        onClick={() => onItemClick(item)}
74
+      >
75
+        {item.sectionNumber && <span style={{ marginRight: '8px', color: '#8c8c8c' }}>{item.sectionNumber}</span>}
76
+        {item.text}
77
+      </span>
78
+    ),
79
+    children: item.children && item.children.length > 0 ? convertToTreeData(item.children, onItemClick) : undefined,
80
+  }));
81
+};
82
+
83
+// ── Component ────────────────────────────────────────────────────────────────
84
+
85
+/**
86
+ * DocumentOutline
87
+ *
88
+ * Renders a hierarchical outline of the document with navigation support.
89
+ *
90
+ * @example
91
+ * ```tsx
92
+ * <DocumentOutline
93
+ *   outline={documentOutline}
94
+ *   activeId="heading-5"
95
+ *   onItemClick={(item) => scrollToSection(item.id)}
96
+ * />
97
+ * ```
98
+ */
99
+export const DocumentOutline: React.FC<DocumentOutlineProps> = ({
100
+  outline,
101
+  activeId,
102
+  onItemClick,
103
+}) => {
104
+  // Convert outline to tree data
105
+  const treeData = convertToTreeData(outline, onItemClick);
106
+
107
+  // Get all keys for default expansion
108
+  const getAllKeys = (items: OutlineItem[]): string[] => {
109
+    let keys: string[] = [];
110
+    items.forEach((item) => {
111
+      keys.push(item.id);
112
+      if (item.children && item.children.length > 0) {
113
+        keys = keys.concat(getAllKeys(item.children));
114
+      }
115
+    });
116
+    return keys;
117
+  };
118
+
119
+  const expandedKeys = getAllKeys(outline);
120
+
121
+  // Handle empty outline
122
+  if (!outline || outline.length === 0) {
123
+    return (
124
+      <div style={containerStyle}>
125
+        <div style={headerStyle}>
126
+          <FileTextOutlined />
127
+          <span>文档大纲</span>
128
+        </div>
129
+        <div style={{ ...treeContainerStyle, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
130
+          <span style={{ color: '#8c8c8c', fontSize: '13px' }}>暂无大纲</span>
131
+        </div>
132
+      </div>
133
+    );
134
+  }
135
+
136
+  return (
137
+    <div style={containerStyle} data-testid="document-outline">
138
+      {/* Header */}
139
+      <div style={headerStyle}>
140
+        <FileTextOutlined />
141
+        <span>文档大纲</span>
142
+      </div>
143
+
144
+      {/* Tree */}
145
+      <div style={treeContainerStyle}>
146
+        <Tree
147
+          treeData={treeData}
148
+          defaultExpandAll
149
+          expandedKeys={expandedKeys}
150
+          selectedKeys={activeId ? [activeId] : []}
151
+          showLine={{ showLeafIcon: false }}
152
+          showIcon={false}
153
+          blockNode
154
+          style={{
155
+            backgroundColor: 'transparent',
156
+            fontSize: '13px',
157
+          }}
158
+        />
159
+      </div>
160
+    </div>
161
+  );
162
+};
163
+
164
+export default DocumentOutline;

+ 6 - 0
src/components/DocumentOutline/index.ts

@@ -0,0 +1,6 @@
1
+/**
2
+ * DocumentOutline Component Export
3
+ */
4
+
5
+export { DocumentOutline } from './DocumentOutline';
6
+export type { DocumentOutlineProps } from './DocumentOutline';

+ 242 - 0
src/components/DocumentViewer/DocumentViewer.tsx

@@ -0,0 +1,242 @@
1
+/**
2
+ * DocumentViewer Component
3
+ *
4
+ * Renders structured document content with:
5
+ * - Headings with proper hierarchy
6
+ * - Paragraphs with text formatting
7
+ * - Tables with borders and styling
8
+ * - Scroll spy for active section tracking
9
+ *
10
+ * @module components/DocumentViewer
11
+ */
12
+
13
+import React, { useEffect, useRef } from 'react';
14
+import { Table } from 'antd';
15
+import type { ContentBlock } from '../../services/documentContentService';
16
+
17
+// ── Styles ────────────────────────────────────────────────────────────────
18
+
19
+const containerStyle: React.CSSProperties = {
20
+  height: '100%',
21
+  overflow: 'auto',
22
+  padding: '32px 48px',
23
+  backgroundColor: '#ffffff',
24
+};
25
+
26
+const contentStyle: React.CSSProperties = {
27
+  maxWidth: '900px',
28
+  margin: '0 auto',
29
+  fontSize: '14px',
30
+  lineHeight: '1.8',
31
+  color: '#262626',
32
+};
33
+
34
+const headingBaseStyle: React.CSSProperties = {
35
+  fontWeight: 600,
36
+  color: '#262626',
37
+  marginTop: '32px',
38
+  marginBottom: '16px',
39
+  scrollMarginTop: '80px', // For smooth scroll with fixed headers
40
+};
41
+
42
+const paragraphStyle: React.CSSProperties = {
43
+  marginBottom: '16px',
44
+  textAlign: 'justify',
45
+  lineHeight: '1.8',
46
+};
47
+
48
+const sectionNumberStyle: React.CSSProperties = {
49
+  marginRight: '12px',
50
+  color: '#8c8c8c',
51
+  fontWeight: 'normal',
52
+};
53
+
54
+// ── Component Props ─────────────────────────────────────────────────────────
55
+
56
+export interface DocumentViewerProps {
57
+  /** Content blocks to render */
58
+  content: ContentBlock[];
59
+  /** Currently active section ID */
60
+  activeId?: string;
61
+  /** Callback when active section changes (scroll spy) */
62
+  onActiveChange?: (id: string) => void;
63
+}
64
+
65
+// ── Component ────────────────────────────────────────────────────────────────
66
+
67
+/**
68
+ * DocumentViewer
69
+ *
70
+ * Displays structured document content with headings, paragraphs, and tables.
71
+ * Supports scroll-based navigation and active section tracking.
72
+ *
73
+ * @example
74
+ * ```tsx
75
+ * <DocumentViewer
76
+ *   content={documentContent}
77
+ *   activeId="heading-5"
78
+ *   onActiveChange={(id) => setActiveId(id)}
79
+ * />
80
+ * ```
81
+ */
82
+export const DocumentViewer: React.FC<DocumentViewerProps> = ({
83
+  content,
84
+  activeId,
85
+  onActiveChange,
86
+}) => {
87
+  const containerRef = useRef<HTMLDivElement>(null);
88
+
89
+  /**
90
+   * Scroll to active element when activeId changes
91
+   */
92
+  useEffect(() => {
93
+    if (activeId && containerRef.current) {
94
+      const element = containerRef.current.querySelector(`#${activeId}`);
95
+      if (element) {
96
+        element.scrollIntoView({ behavior: 'smooth', block: 'start' });
97
+      }
98
+    }
99
+  }, [activeId]);
100
+
101
+  /**
102
+   * Setup scroll spy to track active section
103
+   */
104
+  useEffect(() => {
105
+    if (!onActiveChange || !containerRef.current) return;
106
+
107
+    const container = containerRef.current;
108
+    const headings = container.querySelectorAll('[data-heading-id]');
109
+
110
+    const observer = new IntersectionObserver(
111
+      (entries) => {
112
+        entries.forEach((entry) => {
113
+          if (entry.isIntersecting) {
114
+            const id = entry.target.getAttribute('data-heading-id');
115
+            if (id) {
116
+              onActiveChange(id);
117
+            }
118
+          }
119
+        });
120
+      },
121
+      {
122
+        root: container,
123
+        rootMargin: '-80px 0px -80% 0px',
124
+        threshold: 0,
125
+      }
126
+    );
127
+
128
+    headings.forEach((heading) => observer.observe(heading));
129
+
130
+    return () => {
131
+      observer.disconnect();
132
+    };
133
+  }, [content, onActiveChange]);
134
+
135
+  /**
136
+   * Render a single content block
137
+   */
138
+  const renderBlock = (block: ContentBlock) => {
139
+    switch (block.type) {
140
+      case 'heading': {
141
+        const headingStyle: React.CSSProperties = {
142
+          ...headingBaseStyle,
143
+          fontSize: block.level === 1 ? '28px' : block.level === 2 ? '24px' : block.level === 3 ? '20px' : '16px',
144
+          borderBottom: block.level === 1 ? '1px solid #e8e8e8' : undefined,
145
+          paddingBottom: block.level === 1 ? '8px' : undefined,
146
+        };
147
+
148
+        const HeadingTag = `h${block.level}` as keyof JSX.IntrinsicElements;
149
+
150
+        return (
151
+          <HeadingTag
152
+            key={block.id}
153
+            id={block.id}
154
+            data-heading-id={block.id}
155
+            style={headingStyle}
156
+          >
157
+            {block.sectionNumber && <span style={sectionNumberStyle}>{block.sectionNumber}</span>}
158
+            {block.text}
159
+          </HeadingTag>
160
+        );
161
+      }
162
+
163
+      case 'paragraph': {
164
+        return (
165
+          <p key={block.id} id={block.id} style={paragraphStyle}>
166
+            {block.runs.map((run, runIndex) => {
167
+              const runStyle: React.CSSProperties = {
168
+                fontWeight: run.bold ? 600 : undefined,
169
+                fontStyle: run.italic ? 'italic' : undefined,
170
+                textDecoration: run.underline ? 'underline' : undefined,
171
+                fontFamily: run.fontName,
172
+                fontSize: run.fontSize ? `${run.fontSize}px` : undefined,
173
+                color: run.color,
174
+              };
175
+
176
+              return (
177
+                <span key={runIndex} style={runStyle}>
178
+                  {run.text}
179
+                </span>
180
+              );
181
+            })}
182
+          </p>
183
+        );
184
+      }
185
+
186
+      case 'table': {
187
+        // Convert table data to Ant Design Table format
188
+        const columns = block.rows[0]?.cells.map((cell, cellIndex) => ({
189
+          title: block.hasHeader ? cell.text : `列 ${cellIndex + 1}`,
190
+          dataIndex: `col${cellIndex}`,
191
+          key: `col${cellIndex}`,
192
+          render: (text: string) => text || '-',
193
+        })) || [];
194
+
195
+        const dataSource = (block.hasHeader ? block.rows.slice(1) : block.rows).map((row, rowIndex) => {
196
+          const rowData: Record<string, string> = { key: `row-${rowIndex}` };
197
+          row.cells.forEach((cell, cellIndex) => {
198
+            rowData[`col${cellIndex}`] = cell.text;
199
+          });
200
+          return rowData;
201
+        });
202
+
203
+        return (
204
+          <div key={block.id} id={block.id} style={{ marginBottom: '24px' }}>
205
+            <Table
206
+              columns={columns}
207
+              dataSource={dataSource}
208
+              pagination={false}
209
+              bordered
210
+              size="small"
211
+              style={{ fontSize: '13px' }}
212
+            />
213
+          </div>
214
+        );
215
+      }
216
+
217
+      default:
218
+        return null;
219
+    }
220
+  };
221
+
222
+  // Handle empty content
223
+  if (!content || content.length === 0) {
224
+    return (
225
+      <div style={containerStyle} ref={containerRef}>
226
+        <div style={{ ...contentStyle, textAlign: 'center', color: '#8c8c8c', padding: '40px 0' }}>
227
+          <p>文档内容为空</p>
228
+        </div>
229
+      </div>
230
+    );
231
+  }
232
+
233
+  return (
234
+    <div style={containerStyle} ref={containerRef} data-testid="document-viewer">
235
+      <div style={contentStyle}>
236
+        {content.map((block) => renderBlock(block))}
237
+      </div>
238
+    </div>
239
+  );
240
+};
241
+
242
+export default DocumentViewer;

+ 6 - 0
src/components/DocumentViewer/index.ts

@@ -0,0 +1,6 @@
1
+/**
2
+ * DocumentViewer Component Export
3
+ */
4
+
5
+export { DocumentViewer } from './DocumentViewer';
6
+export type { DocumentViewerProps } from './DocumentViewer';

+ 246 - 73
src/components/EditorPanel/EditorPanel.tsx

@@ -8,21 +8,24 @@
8 8
  * Features:
9 9
  * - Markdown preview with table support
10 10
  * - Edit mode with syntax highlighting
11
- * - Save changes back to backend
12 11
  * - Export to Word
13 12
  * - Close button to return to empty state
14 13
  *
15 14
  * @module components/EditorPanel
16 15
  */
17 16
 
18
-import React, { useState, useEffect, useCallback } from 'react';
19
-import { Button, Input, message, Spin, Divider } from 'antd';
17
+import React, { useState, useEffect, useCallback, useMemo } from 'react';
18
+import { Button, Input, message, Spin, Divider, Dropdown, Menu } from 'antd';
19
+import type { MenuProps } from 'antd';
20 20
 import {
21 21
   CloseOutlined,
22
-  SaveOutlined,
23 22
   DownloadOutlined,
23
+  FileWordOutlined,
24
+  FilePdfOutlined,
25
+  FileMarkdownOutlined,
26
+  DownOutlined,
24 27
 } from '@ant-design/icons';
25
-import { getDocumentContent, updateDocument } from '../../services/documentService';
28
+import { getDocumentContent } from '../../services/documentService';
26 29
 import { exportToWord } from '../../services/exportService';
27 30
 
28 31
 const { TextArea } = Input;
@@ -31,7 +34,60 @@ const { TextArea } = Input;
31 34
 
32 35
 const containerStyle: React.CSSProperties = {
33 36
   display: 'flex',
37
+  flexDirection: 'row',
38
+  height: '100%',
39
+  backgroundColor: '#f5f5f5',
40
+  overflow: 'hidden',
41
+};
42
+
43
+const outlinePanelStyle: React.CSSProperties = {
44
+  width: '260px',
45
+  minWidth: '200px',
46
+  maxWidth: '340px',
47
+  flexShrink: 0,
48
+  overflow: 'hidden',
49
+  borderRight: '1px solid #e8e8e8',
50
+  backgroundColor: '#fafafa',
51
+  display: 'flex',
34 52
   flexDirection: 'column',
53
+};
54
+
55
+const outlineHeaderStyle: React.CSSProperties = {
56
+  padding: '16px',
57
+  borderBottom: '1px solid #e8e8e8',
58
+  backgroundColor: '#ffffff',
59
+  fontWeight: 600,
60
+  fontSize: '14px',
61
+  color: '#262626',
62
+};
63
+
64
+const outlineContentStyle: React.CSSProperties = {
65
+  flex: 1,
66
+  overflow: 'auto',
67
+  padding: '12px',
68
+};
69
+
70
+const outlineItemStyle: React.CSSProperties = {
71
+  padding: '8px 12px',
72
+  cursor: 'pointer',
73
+  borderRadius: '4px',
74
+  fontSize: '13px',
75
+  color: '#595959',
76
+  marginBottom: '4px',
77
+  transition: 'all 0.2s',
78
+};
79
+
80
+const outlineItemActiveStyle: React.CSSProperties = {
81
+  ...outlineItemStyle,
82
+  backgroundColor: '#e6f7ff',
83
+  color: '#1890ff',
84
+  fontWeight: 500,
85
+};
86
+
87
+const editorContainerStyle: React.CSSProperties = {
88
+  display: 'flex',
89
+  flexDirection: 'column',
90
+  flex: 1,
35 91
   height: '100%',
36 92
   backgroundColor: '#ffffff',
37 93
   overflow: 'hidden',
@@ -85,6 +141,8 @@ const editorStyle: React.CSSProperties = {
85 141
   fontFamily: 'Monaco, Consolas, "Courier New", monospace',
86 142
   fontSize: '13px',
87 143
   lineHeight: '1.6',
144
+  border: 'none',
145
+  resize: 'none',
88 146
 };
89 147
 
90 148
 const loadingContainerStyle: React.CSSProperties = {
@@ -94,6 +152,15 @@ const loadingContainerStyle: React.CSSProperties = {
94 152
   height: '100%',
95 153
 };
96 154
 
155
+// ── Types ───────────────────────────────────────────────────────────────────
156
+
157
+interface OutlineItem {
158
+  id: string;
159
+  level: number;
160
+  text: string;
161
+  line: number;
162
+}
163
+
97 164
 // ── Component Props ─────────────────────────────────────────────────────────
98 165
 
99 166
 export interface EditorPanelProps {
@@ -128,11 +195,36 @@ export const EditorPanel: React.FC<EditorPanelProps> = ({
128 195
   onClose,
129 196
 }) => {
130 197
   const [loading, setLoading] = useState(false);
131
-  const [saving, setSaving] = useState(false);
132 198
   const [exporting, setExporting] = useState(false);
133 199
   
134 200
   const [documentTitle, setDocumentTitle] = useState(initialDocumentName || '');
135 201
   const [editContent, setEditContent] = useState('');
202
+  const [activeOutlineId, setActiveOutlineId] = useState<string>();
203
+
204
+  /**
205
+   * Parse outline from markdown content
206
+   */
207
+  const outline: OutlineItem[] = useMemo(() => {
208
+    const lines = editContent.split('\n');
209
+    const items: OutlineItem[] = [];
210
+    let counter = 0;
211
+
212
+    lines.forEach((line, index) => {
213
+      const match = line.match(/^(#{1,6})\s+(.+)$/);
214
+      if (match) {
215
+        const level = match[1].length;
216
+        const text = match[2].trim();
217
+        items.push({
218
+          id: `heading-${++counter}`,
219
+          level,
220
+          text,
221
+          line: index,
222
+        });
223
+      }
224
+    });
225
+
226
+    return items;
227
+  }, [editContent]);
136 228
 
137 229
   /**
138 230
    * Load document content from backend
@@ -168,43 +260,22 @@ export const EditorPanel: React.FC<EditorPanelProps> = ({
168 260
   }, [documentId, initialDocumentName, loadDocument]);
169 261
 
170 262
   /**
171
-   * Save edited content to backend
172
-   */
173
-  const handleSave = useCallback(async () => {
174
-    if (!documentId) return;
175
-    
176
-    try {
177
-      setSaving(true);
178
-      
179
-      await updateDocument(documentId, {
180
-        content: editContent,
181
-      });
182
-      
183
-      message.success('保存成功');
184
-    } catch (error) {
185
-      message.error('保存失败: ' + (error instanceof Error ? error.message : '未知错误'));
186
-    } finally {
187
-      setSaving(false);
188
-    }
189
-  }, [documentId, editContent]);
190
-
191
-  /**
192 263
    * Export document to Word format
193 264
    */
194 265
   const handleExport = useCallback(async () => {
195 266
     if (!documentId) return;
196
-    if (!documentId) return;
197 267
     
198 268
     try {
199 269
       setExporting(true);
200 270
       
201 271
       const result = await exportToWord({
202 272
         documentId,
203
-        styleId: 'default',
273
+        // styleId: null,  // 后端暂不支持样式ID(阶段 1 功能)
204 274
       });
205 275
       
206 276
       message.success(`导出成功: ${result.fileName}`);
207 277
       
278
+      // The downloadUrl is now properly formatted with base URL in the service
208 279
       if (result.downloadUrl) {
209 280
         window.open(result.downloadUrl, '_blank');
210 281
       }
@@ -215,6 +286,77 @@ export const EditorPanel: React.FC<EditorPanelProps> = ({
215 286
     }
216 287
   }, [documentId]);
217 288
 
289
+  /**
290
+   * Export to PDF (placeholder - not implemented yet)
291
+   */
292
+  const handleExportPDF = useCallback(async () => {
293
+    message.info('PDF 导出功能开发中,敬请期待');
294
+    // TODO: Implement PDF export
295
+  }, []);
296
+
297
+  /**
298
+   * Export to Markdown (download current content)
299
+   */
300
+  const handleExportMarkdown = useCallback(() => {
301
+    try {
302
+      const blob = new Blob([editContent], { type: 'text/markdown;charset=utf-8' });
303
+      const url = URL.createObjectURL(blob);
304
+      const link = document.createElement('a');
305
+      link.href = url;
306
+      link.download = `${documentTitle || '文档'}.md`;
307
+      document.body.appendChild(link);
308
+      link.click();
309
+      document.body.removeChild(link);
310
+      URL.revokeObjectURL(url);
311
+      message.success('Markdown 文件已下载');
312
+    } catch (error) {
313
+      message.error('Markdown 导出失败');
314
+    }
315
+  }, [editContent, documentTitle]);
316
+
317
+  /**
318
+   * Handle outline item click
319
+   */
320
+  const handleOutlineClick = useCallback((item: OutlineItem) => {
321
+    setActiveOutlineId(item.id);
322
+    
323
+    // Scroll to the line in textarea
324
+    const textarea = document.querySelector('textarea');
325
+    if (textarea) {
326
+      const lines = editContent.split('\n');
327
+      const beforeText = lines.slice(0, item.line).join('\n');
328
+      const position = beforeText.length + (item.line > 0 ? 1 : 0);
329
+      
330
+      textarea.focus();
331
+      textarea.setSelectionRange(position, position);
332
+      textarea.scrollTop = (item.line / lines.length) * textarea.scrollHeight;
333
+    }
334
+  }, [editContent]);
335
+
336
+  /**
337
+   * Export dropdown menu
338
+   */
339
+  const exportMenuItems: MenuProps['items'] = [
340
+    {
341
+      key: 'word',
342
+      icon: <FileWordOutlined />,
343
+      label: 'Word',
344
+      onClick: handleExport,
345
+    },
346
+    {
347
+      key: 'pdf',
348
+      icon: <FilePdfOutlined />,
349
+      label: 'PDF',
350
+      onClick: handleExportPDF,
351
+    },
352
+    {
353
+      key: 'markdown',
354
+      icon: <FileMarkdownOutlined />,
355
+      label: 'Markdown',
356
+      onClick: handleExportMarkdown,
357
+    },
358
+  ];
359
+
218 360
   // ── Render ────────────────────────────────────────────────────────────────
219 361
 
220 362
   // Loading state
@@ -234,53 +376,84 @@ export const EditorPanel: React.FC<EditorPanelProps> = ({
234 376
   // Document view
235 377
   return (
236 378
     <div style={containerStyle} data-testid="editor-panel">
237
-      {/* Toolbar */}
238
-      <div style={toolbarStyle}>
239
-        <div style={toolbarLeftStyle}>
240
-          <span style={titleStyle} title={documentTitle}>{documentTitle}</span>
241
-        </div>
242
-        
243
-        <div style={toolbarRightStyle}>
244
-          <Button
245
-            type="default"
246
-            size="small"
247
-            icon={<DownloadOutlined />}
248
-            onClick={handleExport}
249
-            loading={exporting}
250
-            title="导出为 Word 文档"
251
-          >
252
-            导出
253
-          </Button>
254
-          <Button
255
-            type="primary"
256
-            size="small"
257
-            icon={<SaveOutlined />}
258
-            onClick={handleSave}
259
-            loading={saving}
260
-            title="保存文档"
261
-          >
262
-            保存
263
-          </Button>
264
-          <Divider type="vertical" style={{ margin: '0 4px' }} />
265
-          <Button
266
-            type="text"
267
-            size="small"
268
-            icon={<CloseOutlined />}
269
-            onClick={onClose}
270
-            title="关闭编辑器"
271
-          />
379
+      {/* Left: Outline Panel */}
380
+      <div style={outlinePanelStyle}>
381
+        <div style={outlineHeaderStyle}>📑 文档大纲</div>
382
+        <div style={outlineContentStyle}>
383
+          {outline.length === 0 ? (
384
+            <div style={{ textAlign: 'center', padding: '20px', color: '#8c8c8c', fontSize: '13px' }}>
385
+              暂无大纲
386
+            </div>
387
+          ) : (
388
+            outline.map((item) => (
389
+              <div
390
+                key={item.id}
391
+                style={
392
+                  activeOutlineId === item.id
393
+                    ? outlineItemActiveStyle
394
+                    : outlineItemStyle
395
+                }
396
+                onClick={() => handleOutlineClick(item)}
397
+                onMouseEnter={(e) => {
398
+                  if (activeOutlineId !== item.id) {
399
+                    e.currentTarget.style.backgroundColor = '#f5f5f5';
400
+                  }
401
+                }}
402
+                onMouseLeave={(e) => {
403
+                  if (activeOutlineId !== item.id) {
404
+                    e.currentTarget.style.backgroundColor = 'transparent';
405
+                  }
406
+                }}
407
+              >
408
+                <div style={{ paddingLeft: `${(item.level - 1) * 12}px` }}>
409
+                  {item.text}
410
+                </div>
411
+              </div>
412
+            ))
413
+          )}
272 414
         </div>
273 415
       </div>
274 416
 
275
-      {/* Content area - 始终显示编辑器 */}
276
-      <div style={contentStyle}>
277
-        <TextArea
278
-          value={editContent}
279
-          onChange={(e) => setEditContent(e.target.value)}
280
-          style={editorStyle}
281
-          placeholder="在此输入 Markdown 内容..."
282
-          autoSize={{ minRows: 20 }}
283
-        />
417
+      {/* Right: Editor */}
418
+      <div style={editorContainerStyle}>
419
+        {/* Toolbar */}
420
+        <div style={toolbarStyle}>
421
+          <div style={toolbarLeftStyle}>
422
+            <span style={titleStyle} title={documentTitle}>{documentTitle}</span>
423
+          </div>
424
+          
425
+          <div style={toolbarRightStyle}>
426
+            <Dropdown menu={{ items: exportMenuItems }} placement="bottomRight">
427
+              <Button
428
+                type="default"
429
+                size="small"
430
+                icon={<DownloadOutlined />}
431
+                loading={exporting}
432
+              >
433
+                导出 <DownOutlined />
434
+              </Button>
435
+            </Dropdown>
436
+            <Divider type="vertical" style={{ margin: '0 4px' }} />
437
+            <Button
438
+              type="text"
439
+              size="small"
440
+              icon={<CloseOutlined />}
441
+              onClick={onClose}
442
+              title="关闭编辑器"
443
+            />
444
+          </div>
445
+        </div>
446
+
447
+        {/* Content area - 始终显示编辑器 */}
448
+        <div style={contentStyle}>
449
+          <TextArea
450
+            value={editContent}
451
+            onChange={(e) => setEditContent(e.target.value)}
452
+            style={editorStyle}
453
+            placeholder="在此输入 Markdown 内容..."
454
+            autoSize={{ minRows: 20 }}
455
+          />
456
+        </div>
284 457
       </div>
285 458
     </div>
286 459
   );

+ 2 - 2
src/components/MarkdownPreview/MarkdownPreview.tsx

@@ -354,12 +354,12 @@ export const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
354 354
       
355 355
       const result = await exportToWord({
356 356
         documentId,
357
-        styleId: 'default',
357
+        // styleId: null,  // 后端暂不支持样式ID(阶段 1 功能)
358 358
       });
359 359
       
360 360
       message.success(`导出成功: ${result.fileName}`);
361 361
       
362
-      // Open download URL in new tab
362
+      // The downloadUrl is now properly formatted with base URL in the service
363 363
       if (result.downloadUrl) {
364 364
         window.open(result.downloadUrl, '_blank');
365 365
       }

+ 53 - 4
src/services/exportService.ts

@@ -58,29 +58,78 @@ export const exportToWord = async (
58 58
   request: ExportDocRequest
59 59
 ): Promise<ExportDocResponse> => {
60 60
   try {
61
+    // Debug logging
62
+    console.log('[Export Service] Sending request:', {
63
+      url: '/api/v1/export/doc',
64
+      documentId: request.documentId,
65
+      styleId: request.styleId,
66
+    });
67
+    
61 68
     const response = await apiClient.post<ApiResponse<ExportDocResponse>>(
62 69
       '/api/v1/export/doc',
63 70
       request
64 71
     );
65
-    if (response.data.data.warning) {
72
+    
73
+    console.log('[Export Service] Response received:', {
74
+      status: response.status,
75
+      data: response.data,
76
+    });
77
+    
78
+    const data = response.data.data;
79
+    
80
+    if (data.warning) {
81
+      console.warn('[Export Service] Warning:', data.warning);
66 82
     }
67 83
     
68
-    return response.data.data;
84
+    // Ensure downloadUrl is properly formatted
85
+    if (data.downloadUrl && !data.downloadUrl.startsWith('http')) {
86
+      const baseURL = apiClient.defaults.baseURL || '';
87
+      data.downloadUrl = `${baseURL}${data.downloadUrl}`;
88
+    }
89
+    
90
+    console.log('[Export Service] Export successful:', {
91
+      fileName: data.fileName,
92
+      downloadUrl: data.downloadUrl,
93
+    });
94
+    
95
+    return data;
69 96
   } catch (error: any) {
97
+    // Enhanced error logging
98
+    console.error('[Export Service] Export failed:', {
99
+      status: error?.response?.status,
100
+      statusText: error?.response?.statusText,
101
+      data: error?.response?.data,
102
+      message: error?.message,
103
+      request: {
104
+        documentId: request.documentId,
105
+        styleId: request.styleId,
106
+      },
107
+    });
108
+    
70 109
     let friendlyMessage = '导出Word文档失败';
110
+    let detailMessage = '';
71 111
 
72 112
     if (error?.response?.status === 404) {
73 113
       friendlyMessage = '文档不存在,请确认文档ID正确';
114
+      detailMessage = `文档ID: ${request.documentId}`;
74 115
     } else if (error?.response?.status === 422) {
75 116
       friendlyMessage = '导出参数验证失败';
117
+      detailMessage = error?.response?.data?.detail || error?.response?.data?.message || '';
76 118
     } else if (error?.response?.status === 500) {
77
-      friendlyMessage = 'Markdown解析或文件写入失败';
119
+      friendlyMessage = '服务器内部错误';
120
+      // Try to extract detailed error from response
121
+      const backendError = error?.response?.data?.detail || error?.response?.data?.message || '';
122
+      detailMessage = backendError ? `后端错误: ${backendError}` : 'Markdown解析或文件写入失败';
78 123
     } else if (error?.code === 'ECONNREFUSED' || error?.code === 'NETWORK_ERROR') {
79 124
       friendlyMessage = '无法连接到导出服务器,请检查网络连接';
80 125
     }
81 126
 
82 127
     const message = getErrorMessage(error);
83
-    throw new Error(`${friendlyMessage}: ${message}`, { cause: error });
128
+    const fullMessage = detailMessage 
129
+      ? `${friendlyMessage}: ${detailMessage}. ${message}` 
130
+      : `${friendlyMessage}: ${message}`;
131
+      
132
+    throw new Error(fullMessage, { cause: error });
84 133
   }
85 134
 };
86 135
 

+ 7 - 1
src/types/export.ts

@@ -34,11 +34,17 @@ export type ExportRecordListItem = ExportRecord;
34 34
  * 
35 35
  * Note: The backend now requires documentId and reads content from the documents table.
36 36
  * File names are auto-generated by the backend from the document's first line + timestamp.
37
+ * 
38
+ * styleId is optional and currently NOT supported in Phase 1 backend implementation.
37 39
  */
38 40
 export interface ExportDocRequest {
39 41
   /** Document ID (required) - backend reads content from documents table */
40 42
   documentId: string;
41
-  /** Style ID (optional, uses default if not provided) */
43
+  /** 
44
+   * Style ID (optional, NOT supported in Phase 1)
45
+   * @deprecated Phase 1 backend does not support custom styles
46
+   * Leave undefined or null to use default backend behavior
47
+   */
42 48
   styleId?: string | null;
43 49
 }
44 50