| 模块 | 功能 | 优先级 |
|---|---|---|
| 文档管理 | 创建、加载、保存文档 | P0 |
| 基础编辑 | 标题、段落、文本格式化 | P0 |
| 表格编辑 | 创建、编辑、调整表格 | P0 |
| 图片管理 | 插入、调整、对齐图片 | P0 |
| 导出功能 | 导出Word文档 | P0 |
| 富文本编辑 | 单元格和段落内富文本 | P1 |
| 块操作 | 拖拽排序、复制粘贴 | P1 |
| 协同编辑 | 多人实时编辑 | P2 |
| 版本管理 | 历史版本、回滚 | P2 |
触发场景: 用户在Chat中点击"编辑"按钮
功能流程:
POST /api/v1/documentsUI交互:
[Chat消息]
这是AI生成的文档 [下载] [编辑]
↓点击
[加载中...解析文档]
↓
[打开编辑器窗口]
API调用:
const loadDocument = async (documentId: string) => {
const res = await apiClient.get(`/api/v1/documents/${documentId}/blocks`);
const blocks = res.data.data.blocks.sort((a, b) => a.block_order - b.block_order);
return blocks;
};
加载状态显示:
保存策略:
保存接口:
const saveDocument = async (documentId: string, blocks: DocumentBlock[]) => {
await apiClient.put(`/api/v1/documents/${documentId}/blocks`, { blocks });
};
保存状态提示:
[保存中...] → [已保存于 14:32:15] → [保存失败,点击重试]
支持级别: H1 ~ H6
创建标题:
编辑标题:
UI示例:
# 第一章 概述 ← H1
↑ 点击编辑
输入状态:
┌────────────────────┐
│# |第一章 概述 │ ← 光标闪烁
└────────────────────┘
[B] [I] [U] [颜色] ← 浮动工具栏
创建段落:
段落操作:
空段落处理:
支持格式:
| 格式 | 快捷键 | 效果 |
|---|---|---|
| 加粗 | Ctrl+B | 粗体 |
| 斜体 | Ctrl+I | 斜体 |
| 下划线 | Ctrl+U | 下划线 |
| 文字颜色 | - | 红色 |
| 字号 | - | 字号14pt |
| 字体 | - | 微软雅黑 |
格式化工具栏:
文本选中时显示浮动工具栏:
┌────────────────────────────────┐
│ [B] [I] [U] [颜色▼] [字号▼] [字体▼] │
└────────────────────────────────┘
创建方式:
对话框UI:
┌─────────────────┐
│ 插入表格 │
│ │
│ 行数: [3] │
│ 列数: [4] │
│ │
│ [取消] [确定] │
└─────────────────┘
默认值:
渲染方式: div + CSS Grid
<div class="table-block" style="display: grid; grid-template-columns: 1fr 1fr 1fr;">
<div class="table-cell">单元格1</div>
<div class="table-cell">单元格2</div>
<div class="table-cell">单元格3</div>
...
</div>
进入编辑:
编辑状态UI:
┌──────────────────┐
│ 单元格内容| │ ← 光标闪烁
│ [B] [I] [U] [色] │ ← 工具栏
└──────────────────┘
退出编辑:
操作流程:
合并示例:
原始:
┌───┬───┬───┐
│ A │ B │ C │
├───┼───┼───┤
│ D │ E │ F │
└───┴───┴───┘
选中A、B、D、E后合并:
┌───────┬───┐
│ A │ C │ ← A单元格 rowspan=2, colspan=2
│ │ │
├───────┼───┤
│ G │ F │
└───────┴───┘
数据结构变化:
// 合并前
cells: [
{ text: 'A', rowspan: 1, colspan: 1 },
{ text: 'B', rowspan: 1, colspan: 1 },
// ...
]
// 合并后
cells: [
{ text: 'A', rowspan: 2, colspan: 2 },
{ text: 'C', rowspan: 1, colspan: 1 },
// B、D、E被移除
]
右键菜单:
单元格右键 →
- 在上方插入行
- 在下方插入行
- 在左侧插入列
- 在右侧插入列
- 删除当前行
- 删除当前列
- 合并单元格
- 拆分单元格
插入行逻辑:
const insertRow = (table: TableBlock, afterRow: number) => {
const newRow: TableRow = {
cells: Array(table.metadata.cols).fill(null).map(() => ({
text: '',
rowspan: 1,
colspan: 1,
style: {}
}))
};
const rows = [...table.content.rows];
rows.splice(afterRow + 1, 0, newRow);
return {
...table,
content: { rows },
metadata: { ...table.metadata, rows: rows.length }
};
};
交互方式:
col_widths数组UI反馈:
正常状态:
│ 列1 │ 列2 │
悬停状态:
│ 列1 ║ 列2 │ ← 加粗竖线,光标变为↔
拖拽状态:
│ 列1 │ 列2 │ ← 实时显示新宽度
实现:
const handleColumnResize = (colIndex: number, newWidth: number) => {
const col_widths = [...table.metadata.col_widths];
col_widths[colIndex] = newWidth;
updateBlock(table.id, {
metadata: { ...table.metadata, col_widths }
});
};
交互:
row_heights数组注意: 行高为可选功能,不设置时自动适应内容高度
插入方式:
支持格式: JPG、PNG、GIF、WebP
文件大小限制: 单张图片≤5MB
转换流程:
const handleImageUpload = async (file: File) => {
// 1. 验证
if (file.size > 5 * 1024 * 1024) {
showError('图片大小不能超过5MB');
return;
}
// 2. 读取并转Base64
const reader = new FileReader();
reader.onload = (e) => {
const base64 = e.target.result as string;
// 3. 创建image block
const imageBlock: ImageBlock = {
id: generateBlockId('img'),
block_order: computeInsertOrder(),
type: 'image',
level: 0,
index: 0,
content: base64, // data:image/png;base64,...
word_style: 'Normal',
style: {
width: 10,
height: 7,
unit: 'cm',
align: 'center'
},
metadata: {
alt: file.name,
para_style: 'Normal',
parent_heading_id: getCurrentHeadingId()
}
};
addBlock(imageBlock);
};
reader.readAsDataURL(file);
};
交互方式:
UI显示:
未选中:
┌─────────────┐
│ [图片] │
└─────────────┘
选中状态:
■─────────────■ ← 四角调整点
│ │
│ [图片] │ ← 中心图片
│ │
■─────────────■
尺寸计算:
const handleResize = (corner: 'nw' | 'ne' | 'sw' | 'se', deltaX: number, deltaY: number) => {
const { width, height } = block.style;
const aspectRatio = width / height;
let newWidth = width;
let newHeight = height;
switch (corner) {
case 'se': // 右下角:等比缩放
newWidth = width + deltaX * 0.01; // 转换px到cm
newHeight = newWidth / aspectRatio;
break;
// ...其他角落
}
updateBlock(block.id, {
style: { ...block.style, width: newWidth, height: newHeight }
});
};
对齐选项:
实现:
<div className={`image-block align-${block.style.align}`}>
<img src={block.content} alt={block.metadata.alt} />
</div>
/* CSS */
.image-block.align-left { text-align: left; }
.image-block.align-center { text-align: center; }
.image-block.align-right { text-align: right; }
操作:
操作:
触发:
导出流程:
用户点击"导出"
↓
弹出样式选择对话框(可选,默认使用default样式)
↓
调用 POST /api/v1/export/doc { documentId, styleId }
↓
后端从document_blocks读取 → 渲染Word → 保存到tmp/
↓
返回 { downloadUrl, fileName, recordId }
↓
前端触发浏览器下载
样式选择对话框(阶段1):
┌────────────────────────┐
│ 导出设置 │
│ │
│ 样式: [系统默认 ▼] │
│ - 系统默认 │
│ - 我的样式1 │
│ - 公司模板 │
│ │
│ [取消] [导出] │
└────────────────────────┘
查看历史:
历史记录列表:
┌───────────────────────────┐
│ 下载历史 │
├───────────────────────────┤
│ 📄 报告_1680000000.doc │
│ 204KB · 2小时前 │
│ [下载] [删除] │
├───────────────────────────┤
│ 📄 报告_1679950000.doc │
│ 198KB · 昨天 14:32 │
│ [下载] [删除] │
└───────────────────────────┘
操作:
选中方式:
选中状态UI:
┌─────────────────────────┐
│ [⋮] [↑] [↓] [🗑] │ ← 块工具栏
├─────────────────────────┤
│ # 标题内容 │ ← 选中的块(带边框)
└─────────────────────────┘
实现库: @dnd-kit/core
交互:
UI反馈:
拖拽前:
[块A]
[块B]
[块C]
拖拽中:
[块B] ← 拖拽中(半透明)
───────── ← 插入位置指示
[块A]
[块C]
拖拽后:
[块A]
[块B] ← 已移动
[块C]
复制:
粘贴:
实现:
const handleCopy = (block: DocumentBlock) => {
const clipboardData = {
type: 'ax-block',
block: { ...block, id: undefined, block_order: undefined } // 移除原ID
};
navigator.clipboard.writeText(JSON.stringify(clipboardData));
};
const handlePaste = async () => {
const text = await navigator.clipboard.readText();
try {
const data = JSON.parse(text);
if (data.type === 'ax-block') {
const newBlock = {
...data.block,
id: generateBlockId(data.block.type),
block_order: computeInsertOrder()
};
addBlock(newBlock);
}
} catch (e) {
// 普通文本粘贴,创建段落块
addBlock({
type: 'paragraph',
content: text,
// ...
});
}
};
操作:
批量删除:
| 快捷键 | 功能 | 作用范围 |
|---|---|---|
| 文档操作 | ||
| Ctrl+S | 保存文档 | 全局 |
| Ctrl+Z | 撤销 | 全局 |
| Ctrl+Y / Ctrl+Shift+Z | 重做 | 全局 |
| Ctrl+P | 导出Word | 全局 |
| 文本格式 | ||
| Ctrl+B | 加粗 | 文本选中时 |
| Ctrl+I | 斜体 | 文本选中时 |
| Ctrl+U | 下划线 | 文本选中时 |
| 块操作 | ||
| Enter | 新建段落块 | 块末尾时 |
| Shift+Enter | 块内换行 | 块编辑时 |
| Ctrl+C | 复制块 | 块选中时 |
| Ctrl+V | 粘贴块 | 任意时 |
| Delete | 删除块 | 块选中时 |
| Ctrl+Up | 上移块 | 块选中时 |
| Ctrl+Down | 下移块 | 块选中时 |
| 标题 | ||
| Ctrl+1 ~ Ctrl+6 | 转为H1~H6 | 块选中时 |
| Ctrl+0 | 转为普通段落 | 块选中时 |
| 表格 | ||
| Tab | 跳转下一单元格 | 表格编辑时 |
| Shift+Tab | 跳转上一单元格 | 表格编辑时 |
| Ctrl+Shift+T | 插入表格 | 任意时 |
| 图片 | ||
| Ctrl+Shift+I | 插入图片 | 任意时 |
优先级规则:
实现:
const handleKeyDown = (e: KeyboardEvent) => {
// 阻止浏览器默认行为
if ((e.ctrlKey || e.metaKey) && ['s', 'p', 'z', 'y'].includes(e.key.toLowerCase())) {
e.preventDefault();
}
// 分发快捷键
if (e.ctrlKey && e.key === 's') {
saveDocument();
} else if (e.ctrlKey && e.key === 'z') {
undo();
}
// ...
};
数据结构:
interface HistoryState {
blocks: DocumentBlock[];
timestamp: number;
}
interface HistoryStack {
past: HistoryState[]; // 历史状态
present: HistoryState; // 当前状态
future: HistoryState[]; // 重做栈
}
const editorStore = create<EditorStore>((set, get) => ({
history: {
past: [],
present: { blocks: [], timestamp: Date.now() },
future: []
},
pushHistory: () => {
const { blocks, history } = get();
set({
history: {
past: [...history.past, history.present],
present: { blocks: [...blocks], timestamp: Date.now() },
future: [] // 清空重做栈
}
});
},
undo: () => {
const { history } = get();
if (history.past.length === 0) return;
const previous = history.past[history.past.length - 1];
set({
blocks: previous.blocks,
history: {
past: history.past.slice(0, -1),
present: previous,
future: [history.present, ...history.future]
}
});
},
redo: () => {
const { history } = get();
if (history.future.length === 0) return;
const next = history.future[0];
set({
blocks: next.blocks,
history: {
past: [...history.past, history.present],
present: next,
future: history.future.slice(1)
}
});
}
}));
触发: Ctrl+F 打开搜索框
搜索框UI:
┌─────────────────────────────┐
│ 查找: [关键词___] [↑] [↓] │
│ [x] 区分大小写 [x] 全词匹配│
│ 共找到 3 处,当前第 1 处 │
└─────────────────────────────┘
搜索范围:
高亮显示:
UI扩展:
┌─────────────────────────────┐
│ 查找: [旧文本___] │
│ 替换为: [新文本___] │
│ [替换] [全部替换] │
└─────────────────────────────┘
替换逻辑:
const replaceAll = (searchText: string, replaceText: string) => {
const newBlocks = blocks.map(block => {
if (block.type === 'heading' || block.type === 'paragraph') {
return {
...block,
content: replaceInContent(block.content, searchText, replaceText)
};
} else if (block.type === 'table') {
return {
...block,
content: {
rows: block.content.rows.map(row => ({
cells: row.cells.map(cell => ({
...cell,
text: replaceInContent(cell.text, searchText, replaceText)
}))
}))
}
};
}
return block;
});
set({ blocks: newBlocks });
pushHistory();
};
示例:
<div
role="article"
aria-label={`${block.type} block, ${getBlockSummary(block)}`}
tabIndex={0}
>
<BlockRenderer block={block} />
</div>
文档版本: v1.0
创建日期: 2026-07-03
下一步: 参考《新编辑器开发手册.md》开始编码实现