状态:✅ 已完成并验收通过
实现日期:2026-07-10
版本:v1.0
POST /api/v1/documents/{documentId}/blocks
// 在某个 block 后插入段落
await fetch(`/api/v1/documents/${docId}/blocks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'paragraph',
content: '这是新段落内容',
after_block_id: 'block-p-100'
})
});
// 插入 H2 标题
await fetch(`/api/v1/documents/${docId}/blocks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'heading',
level: 2, // ⚠️ 必填:1-6
content: '第二章 项目背景',
word_style: 'Heading 2',
after_block_id: 'block-h1-0'
})
});
| 规则 | 说明 |
|---|---|
| ✅ 必填字段 | type、content 必须提供 |
| ⭐ level 参数 | heading 必填(1-6),其他类型可省略(默认 0) |
| ❌ 不要提供 | id、index、block_order 由后端自动生成 |
| 📍 插入位置 | after_block_id 指定插入位置,null = 末尾 |
POST /api/v1/documents/{documentId}/blocks
| 参数 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
documentId |
string | ✅ | 文档 ID | "doc-abc123456789" |
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
type |
string | ✅ | - | Block 类型:heading, paragraph, table, image, toc |
content |
string/dict/list | ✅ | - | Block 内容(格式因类型而异) |
level |
int | ⭐ | 0 |
heading 必填(1-6),其他类型固定为 0 |
word_style |
string | ❌ | "" |
Word 样式名(如 "Heading 1", "Normal") |
style |
dict | ❌ | {} |
自定义样式 JSON |
metadata |
dict | ❌ | {} |
元数据(如 parent_heading_id) |
after_block_id |
string/null | ❌ | null |
在哪个 block 后插入(null = 末尾) |
⚠️ 重要:
id、index、block_order由后端自动生成,前端不需要也不应该提供!
| Block 类型 | level 取值 | 是否必填 | 说明 |
|---|---|---|---|
heading |
1 - 6 |
✅ 必填 | 对应 H1 - H6 标题 |
paragraph |
0 |
❌ 可省略 | 固定值(默认 0) |
table |
0 |
❌ 可省略 | 固定值(默认 0) |
image |
0 |
❌ 可省略 | 固定值(默认 0) |
toc |
0 |
❌ 可省略 | 固定值(默认 0) |
// ✅ 正确:heading 类型必须指定 level
{
"type": "heading",
"level": 2,
"content": "第一节"
}
// ✅ 正确:paragraph 类型可以省略 level
{
"type": "paragraph",
"content": "段落内容"
}
// ❌ 错误:heading 类型未指定 level
{
"type": "heading",
"content": "标题"
}
{
"code": 0,
"message": "success",
"data": {
"blockId": "block-p-250",
"message": "Block created successfully"
}
}
{
"code": 400,
"message": "heading 的 level 必须是 1-6"
}
{
"code": 404,
"message": "Block not found: block-p-999"
}
{
"type": "paragraph",
"content": "这是一段普通文本"
}
{
"type": "heading",
"content": [
{"text": "第一章 ", "style": {}},
{"text": "重要通知", "style": {"bold": true, "color": "FF0000"}}
]
}
{
"type": "table",
"content": {
"rows": [
{
"cells": [
{"text": "姓名", "rowspan": 1, "colspan": 1, "col_index": 0, "style": {"bold": true}},
{"text": "年龄", "rowspan": 1, "colspan": 1, "col_index": 1, "style": {}}
]
}
]
}
}
{
"type": "image",
"content": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
}
style 字段)| 属性 | 类型 | 说明 | 示例 |
|---|---|---|---|
font_name |
string | 字体名称 | "微软雅黑" |
font_size |
number | 字号(磅) | 14.0 |
bold |
boolean | 是否加粗 | true |
italic |
boolean | 是否斜体 | true |
underline |
boolean | 是否下划线 | true |
strike |
boolean | 是否删除线 | true |
color |
string | 文字颜色(十六进制) | "FF0000" |
align |
string | 对齐方式 | "left", "center", "right", "justify" |
| 属性 | 类型 | 说明 | 示例 |
|---|---|---|---|
width |
number | 宽度值 | 10.0 |
height |
number | 高度值 | 7.0 |
unit |
string | 单位 | "cm", "inch" |
align |
string | 对齐方式 | "left", "center", "right" |
// 标题
word_style: 'Heading 1' // H1
word_style: 'Heading 2' // H2
word_style: 'Heading 3' // H3
// 段落
word_style: 'Normal' // 正文
word_style: 'Body Text' // 正文
// 表格
word_style: 'Table Grid' // 表格网格
POST /api/v1/documents/doc-abc123/blocks
{
"type": "paragraph",
"content": "这是新段落",
"after_block_id": "block-p-200"
}
POST /api/v1/documents/doc-abc123/blocks
{
"type": "heading",
"level": 2,
"content": "1.2 项目背景",
"word_style": "Heading 2",
"metadata": {
"parent_heading_id": "block-h1-0"
},
"after_block_id": "block-h2-0"
}
POST /api/v1/documents/doc-abc123/blocks
{
"type": "paragraph",
"content": [
{"text": "项目位于 ", "style": {}},
{"text": "陕西省延安市", "style": {"bold": true, "color": "FF0000"}},
{"text": ",总投资 ", "style": {}},
{"text": "5000 万元", "style": {"bold": true, "color": "0000FF"}}
],
"word_style": "Normal",
"after_block_id": "block-p-100"
}
POST /api/v1/documents/doc-abc123/blocks
{
"type": "paragraph",
"content": "文档结尾的段落",
"after_block_id": null
}
POST /api/v1/documents/doc-abc123/blocks
{
"type": "table",
"content": {
"rows": [
{
"cells": [
{"text": "姓名", "rowspan": 1, "colspan": 1, "col_index": 0, "style": {"bold": true}},
{"text": "年龄", "rowspan": 1, "colspan": 1, "col_index": 1, "style": {"bold": true}}
]
},
{
"cells": [
{"text": "张三", "rowspan": 1, "colspan": 1, "col_index": 0, "style": {}},
{"text": "30", "rowspan": 1, "colspan": 1, "col_index": 1, "style": {}}
]
}
]
},
"word_style": "Table Grid",
"metadata": {
"cols": 2,
"rows": 2
},
"after_block_id": "block-p-300"
}
id - Block ID生成规则:根据类型、级别和 index 自动生成
| 类型 | ID 格式 | 示例 |
|---|---|---|
| 标题 | block-h{level}-{index} |
block-h2-100 |
| 段落 | block-p-{index} |
block-p-200 |
| 表格 | block-table-{index} |
block-table-0 |
| 图片 | block-img-{index} |
block-img-50 |
| 目录 | block-toc-{index} |
block-toc-0 |
index - 同类型同级别序号说明:用于区分同类型同级别的 blocks,采用稀疏排序
计算方式:
index 中间值不同类型独立计数:
H1 标题:index = 0, 100, 200...(独立)
H2 标题:index = 0, 50, 100, 200...(独立)
段落: index = 0, 100, 200...(独立)
block_order - 文档全局位置说明:决定 blocks 在文档中的显示顺序(所有类型共享同一序列)
计算方式:
after_block_id 查询前一个 block 的 block_orderblock_order| 字段 | 作用域 | 用途 | 稀疏排序方式 |
|---|---|---|---|
index |
局部(同类型同级别) | 生成 Block ID,区分同类 blocks | 在前后同类 block 的 index 之间插入 |
block_order |
全局(所有 blocks) | 决定文档显示顺序 | 在前后任意 block 的 block_order 之间插入 |
传统排序的问题:
初始:1, 2, 3, 4, 5
在 2 后插入:需要更新 3, 4, 5 → 4, 5, 6 ❌ O(n) 操作
稀疏排序的优势:
初始:100, 200, 300, 400, 500
在 200 后插入:250 ✅ O(1) 操作
结果:100, 200, 250(新), 300, 400, 500
┌─────────────────────┬──────────┬────────────┐
│ Block │ index │ block_order│
├─────────────────────┼──────────┼────────────┤
│ H1: 第一章 │ 0 │ 100 │
│ H2: 1.1 概述 │ 0 │ 200 │
│ 段落1 │ 0 │ 300 │
│ H2: 1.2 背景 │ 100 │ 400 │
│ 段落2 │ 100 │ 500 │
│ H1: 第二章 │ 100 │ 600 │
└─────────────────────┴──────────┴────────────┘
前端请求:
{
"type": "heading",
"level": 2,
"content": "1.1.1 新增内容",
"word_style": "Heading 2",
"after_block_id": "block-p-0"
}
后端计算过程:
步骤 1:计算 index
# 查找前后两个 H2 标题
prev_h2_index = 0 # H2: 1.1 概述
next_h2_index = 100 # H2: 1.2 背景
# 计算中间值
new_index = (0 + 100) // 2 # = 50 ✅
步骤 2:计算 block_order
# 查找前后两个 block
after_order = 300 # 段落1
next_order = 400 # H2: 1.2 背景
# 计算中间值
new_block_order = (300 + 400) // 2 # = 350 ✅
步骤 3:生成 ID
new_id = f"block-h{level}-{index}" # = "block-h2-50"
插入后的文档结构:
┌─────────────────────┬──────────┬────────────┐
│ Block │ index │ block_order│
├─────────────────────┼──────────┼────────────┤
│ H1: 第一章 │ 0 │ 100 │
│ H2: 1.1 概述 │ 0 │ 200 │
│ 段落1 │ 0 │ 300 │
│ H2: 新增内容 🆕 │ 50 │ 350 │ ← 新插入
│ H2: 1.2 背景 │ 100 │ 400 │
│ 段落2 │ 100 │ 500 │
│ H1: 第二章 │ 100 │ 600 │
└─────────────────────┴──────────┴────────────┘
关键点:
index = 50:在两个 H2 的 index(0, 100)之间block_order = 350:在两个 block 的 order(300, 400)之间id = "block-h2-50":根据规则自动生成def calculate_index_for_insert(
db: ContentDB,
after_block_id: str,
new_type: str,
new_level: int = 0
) -> int:
"""计算插入 block 的 index(稀疏排序)"""
# 1. 获取 after_block 的 block_order
after_block = db.get_block_by_id(after_block_id)
after_order = after_block['block_order']
# 2. 查找下一个同类型同级别的 block
if new_type == 'heading':
cursor = db.conn.execute("""
SELECT index FROM document_blocks
WHERE type = ? AND level = ? AND block_order > ?
ORDER BY block_order LIMIT 1
""", (new_type, new_level, after_order))
else:
cursor = db.conn.execute("""
SELECT index FROM document_blocks
WHERE type = ? AND block_order > ?
ORDER BY block_order LIMIT 1
""", (new_type, after_order))
next_row = cursor.fetchone()
if next_row:
next_index = next_row['index']
# 查找前一个同类型同级别的 block
# ... 省略详细代码
new_index = (prev_index + next_index) // 2
else:
# 没有下一个同类 block,追加到最后
max_index = db.get_max_index(new_type, new_level)
new_index = max_index + 100
return new_index
def calculate_block_order_for_insert(
db: ContentDB,
after_block_id: str = None
) -> int:
"""计算插入 block 的 block_order(稀疏排序)"""
if after_block_id:
after_block = db.get_block_by_id(after_block_id)
after_order = after_block['block_order']
# 查询下一个 block
cursor = db.conn.execute("""
SELECT block_order FROM document_blocks
WHERE block_order > ?
ORDER BY block_order LIMIT 1
""", (after_order,))
next_row = cursor.fetchone()
if next_row:
next_order = next_row['block_order']
return (after_order + next_order) // 2
else:
return after_order + 100
else:
# 插入到文档末尾
max_order = db.get_max_block_order()
return max_order + 100
import { useState } from 'react';
interface BlockCreateRequest {
type: 'heading' | 'paragraph' | 'table' | 'image' | 'toc';
level?: number;
content: string | any[] | any;
word_style?: string;
style?: any;
metadata?: any;
after_block_id?: string | null;
}
export function useBlockInsert(documentId: string) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const insertBlock = async (block: BlockCreateRequest) => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/v1/documents/${documentId}/blocks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(block)
});
const data = await response.json();
if (data.code !== 0) {
throw new Error(data.message);
}
return data.data.blockId;
} catch (err: any) {
setError(err.message);
throw err;
} finally {
setLoading(false);
}
};
return { insertBlock, loading, error };
}
// 使用示例
function MyEditor() {
const { insertBlock, loading, error } = useBlockInsert('doc-abc123');
const handleInsertParagraph = async () => {
const blockId = await insertBlock({
type: 'paragraph',
content: '新段落',
after_block_id: 'block-p-100'
});
console.log('插入成功:', blockId);
};
const handleInsertHeading = async () => {
const blockId = await insertBlock({
type: 'heading',
level: 2, // ⚠️ 必填
content: '第二章',
word_style: 'Heading 2',
after_block_id: 'block-h1-0'
});
console.log('插入成功:', blockId);
};
return (
<div>
<button onClick={handleInsertParagraph} disabled={loading}>
插入段落
</button>
<button onClick={handleInsertHeading} disabled={loading}>
插入标题
</button>
{error && <div style={{color: 'red'}}>{error}</div>}
</div>
);
}
import { ref } from 'vue';
export function useBlockInsert(documentId: string) {
const loading = ref(false);
const error = ref<string | null>(null);
const insertBlock = async (block: any) => {
loading.value = true;
error.value = null;
try {
const response = await fetch(`/api/v1/documents/${documentId}/blocks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(block)
});
const data = await response.json();
if (data.code !== 0) {
throw new Error(data.message);
}
return data.data.blockId;
} catch (err: any) {
error.value = err.message;
throw err;
} finally {
loading.value = false;
}
};
return { insertBlock, loading, error };
}
const response = await fetch(...);
const data = await response.json();
console.log('API 响应:', data);
const requestBody = {
type: 'heading',
level: 2,
content: '标题'
};
console.log('请求体:', JSON.stringify(requestBody, null, 2));
| 错误信息 | 原因 | 解决方法 |
|---|---|---|
heading 的 level 必须是 1-6 |
heading 未指定 level 或超出范围 | 添加 level: 2(1-6) |
Block not found: xxx |
after_block_id 不存在 | 检查 block ID 是否正确 |
插入失败: ... |
其他错误 | 查看详细错误信息 |
test_insert_block.py运行测试:
python test_insert_block.py
测试数据:
{
"type": "heading",
"level": 2,
"content": "1.1.1 新增内容",
"after_block_id": "block-p-0"
}
计算结果:
50 ✅(在 0 和 100 之间)350 ✅(在 300 和 400 之间)block-h2-50 ✅测试数据:
{
"type": "paragraph",
"content": "新段落内容",
"after_block_id": "block-h2-50"
}
计算结果:
50 ✅(段落独立计数)375 ✅(在 350 和 400 之间)block-p-50 ✅测试数据:
{
"type": "paragraph",
"content": "文档末尾段落",
"after_block_id": null
}
计算结果:
200 ✅(最大值 + 100)700 ✅(最大值 + 100)block-p-200 ✅✅ 所有测试通过!
📈 统计信息:
- 总 blocks: 9
- 按类型:
- heading: 5
- paragraph: 4
app/services/content_db.py)calculate_index_for_insert() - 计算 indexcalculate_block_order_for_insert() - 计算 block_order_rebalance_indexes_between() - 局部重排 index_rebalance_block_orders_between() - 局部重排 block_ordergenerate_block_id() - 生成 Block IDapp/api/v1/blocks.py)POST /api/v1/documents/{documentId}/blocks
app/schemas/block.py)BlockCreate schema| 优势 | 说明 |
|---|---|
| 性能 | O(1) 插入,无需更新其他 blocks |
| 简单 | 前端只需关注内容和位置 |
| 可扩展 | 支持频繁插入和大文档 |
| 一致性 | 后端统一管理序号 |
INSERT_BLOCK_COMPLETE_GUIDE.md - 本文档test_insert_block.py - 测试脚本app/services/content_db.pyapp/api/v1/blocks.pyapp/schemas/block.pyafter_block_id = null 表示插入到文档末尾前端现在可以直接调用 API 进行 Block 插入操作!
最简单的调用:
await fetch(`/api/v1/documents/${docId}/blocks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'paragraph',
content: '新段落',
after_block_id: 'block-p-100'
})
});
实现者:AI Assistant (Kiro)
实现日期:2026-07-10
版本:v1.0
状态:✅ 已完成并验收通过