补充运行说明并实现基础导出能力

This commit is contained in:
zwt13703
2026-07-02 15:13:57 +08:00
parent a2bad58591
commit b313083766
7 changed files with 414 additions and 57 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
__pycache__/
*.py[cod]
*$py.class
.idea
# C extensions
*.so
+147 -36
View File
@@ -1,80 +1,191 @@
# doc-forge
上传 Word 模板 → AI 逐段落生成 → 导出保留原始样式的 Word 文档。
上传 Word 模板 → AI 逐段落生成 → 预览编辑 → 导出尽量保留原始样式的 Word 文档。
## 技术栈
- **前端**: Vue 3.4 + Vite 5 + TypeScript + Ant Design Vue 4.x + Pinia + Axios
- **后端**: Python 3.11+ + FastAPI + SQLAlchemy 2.0 async
- **数据库**: MySQL 8.0asyncmy 驱动)
- **对象存储**: MinIO(模板/参考文件/导出文档)
- **对象存储**: MinIO(模板 / 参考文件 / 导出文档)
- **文档处理**: python-docx、openpyxl
## 快速开始
## 当前可用能力
- 上传 `.docx` 模板并按 Heading 1~6 解析段落
- 在模板编辑页配置段落的编辑方式、模型、提示词、文件要求、输出格式
- 管理模型配置,API Key 以加密形式存储,前端仅显示脱敏内容
- 执行整份文档的模拟生成
- 查看生成记录与预览页真实结果
- 导出 Word:基于原模板替换标题下内容并生成可下载文件
## 运行方式
推荐开发方式:`Docker 启动依赖 + 本地启动前后端`
### 1. 启动 MySQL 和 MinIO
在项目根目录执行:
```bash
# 1. 启动基础设施(MySQL + MinIO
docker-compose up -d mysql minio
docker compose up -d mysql minio
```
# 2. 启动后端
cd backend
启动后可访问:
- MySQL: `localhost:3306`
- MinIO API: `http://localhost:9000`
- MinIO Console: `http://localhost:9001`
默认账号:
- MinIO 用户名: `docforge`
- MinIO 密码: `docforge123`
### 2. 启动后端
```bash
cd /Users/zhouwentao/Workspaces/Yangliu/doc-forge/backend
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python main.py
# → http://localhost:8000/docs Swagger
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
# 3. 启动前端
cd web
后端地址:
- API 根地址: `http://localhost:8000`
- 健康检查: `http://localhost:8000/health`
后端默认读取 [backend/.env](/Users/zhouwentao/Workspaces/Yangliu/doc-forge/backend/.env)
```env
DEBUG=True
DB_HOST=localhost
DB_PORT=3306
DB_USER=docforge
DB_PASSWORD=docforge123
DB_NAME=doc_forge
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=docforge
MINIO_SECRET_KEY=docforge123
MINIO_USE_SSL=False
ENCRYPTION_KEY=change-this-to-a-32-byte-key-in-production!!
```
### 3. 启动前端
```bash
cd /Users/zhouwentao/Workspaces/Yangliu/doc-forge/web
pnpm install
pnpm dev
# → http://localhost:5173
```
前端地址:
- `http://localhost:5173`
### 4. 初步使用流程
1. 打开“模板管理”,上传一个 `.docx` 模板。
2. 进入模板编辑页,为段落配置 AI / 手动、提示词、模型等。
3. 打开“模型管理”,添加至少一个模型配置。
4. 打开“执行生成”,选择模板并发起生成。
5. 到“生成记录”查看历史,点击“预览”查看实际生成内容。
6. 在预览页点击“导出 Word”下载导出文件。
## Docker 全套启动
如果想直接用 Docker 跑全套,可以在项目根目录准备 `.env`
```env
ENCRYPTION_KEY=change-this-to-a-32-byte-key-in-production!!
```
然后执行:
```bash
docker compose up -d
```
暴露端口:
- 前端: `5173`
- 后端: `8000`
- MinIO: `9000`
- MinIO Console: `9001`
- MySQL: `3306`
## 常见问题
### `.idea` 不小心提交了怎么办?
不影响项目运行,但建议尽快移除并加入忽略:
```bash
echo ".idea/" >> .gitignore
git rm -r --cached .idea
git add .gitignore
git commit -m "移除 IDE 配置文件"
```
### 为什么我本地 `python main.py` 报缺少模块?
说明当前 Python 环境还没安装依赖,先执行:
```bash
pip install -r requirements.txt
```
推荐用虚拟环境:
```bash
python3 -m venv .venv
source .venv/bin/activate
```
### 为什么导出 PDF 还不可用?
当前阶段已经支持基础 Word 导出,PDF 导出还未接入 LibreOffice 转换流程。
## 目录结构
```
```text
doc-forge/
├── web/ Vue 3 前端
│ └── src/
│ ├── views/ 6 个页面
│ ├── components/ 6 个通用组件
│ ├── views/ 页面
│ ├── components/ 通用组件
│ ├── api/ Axios 请求层
│ ├── stores/ Pinia 状态管理
│ └── router/ 路由配置
├── backend/ Python FastAPI 后端
│ ├── app/models/ ORM 数据模型
│ ├── app/routers/ API 路由
│ ├── app/schemas/ Pydantic 校验
│ ├── app/services/ 业务逻辑层
│ ├── models/ ORM 数据模型
│ ├── routers/ API 路由
│ ├── schemas/ Pydantic 校验
│ ├── services/ 业务逻辑层
│ ├── config.py 配置
│ └── database.py 异步数据库引擎
├── docker-compose.yml MySQL + MinIO + 后端 + 前端
├── init.sql 数据库建表 DDL
├── init.sql 数据库初始化 SQL
└── docs/ 项目文档
```
## 核心流程
```
```text
上传模板 → 解析段落 → 标注配置 → 保存模板
→ 执行生成(上传文件 → AI 并行生成 → SSE 进度
→ 预览编辑 → 导出 Word(保留原始样式)
→ 执行生成(上传文件 → AI 生成
→ 预览编辑 → 导出 Word
```
## 关键设计
| 决策 | 选择 | 原因 |
|------|------|------|
| 段落边界 | Word 标题样式 Heading 1~6 | 稳定可靠,用户学习成本低 |
| AI 输出 | 结构化 JSON | 支持文字+表格混合,后端可控解析 |
| 导出策略 | 基于原模板替换内容 | 样式零损失,不限模板格式 |
| 文件存储 | MinIO 对象存储 | 可扩展,不占用本地磁盘 |
| 数据库 | MySQL 8.0 | 生产级可靠性 |
| 生成方式 | 多段落并行 asyncio.gather | 大幅缩短等待时间 |
## 环境要求
- Python 3.11+
- Node.js 18+
- pnpm 8+
- Docker + docker-composeMySQL + MinIO
- LibreOffice(可选,用于 PDF 导出)
- Docker + Docker Compose
- LibreOffice(可选,用于未来的 PDF 导出)
+66 -6
View File
@@ -1,14 +1,74 @@
from fastapi import APIRouter
from fastapi.responses import PlainTextResponse
import asyncio
import json
import os
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import PlainTextResponse, RedirectResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from database import get_db
from models.document import Document
from models.generation_log import GenerationLog
from models.paragraph import Paragraph
from models.template import Template
from services.document_export import export_document_bytes
from services.minio_client import (
download_object_bytes,
get_presigned_url,
split_bucket_path,
upload_bytes,
)
router = APIRouter()
@router.get("/{document_id}/docx")
async def export_docx(document_id: int):
return PlainTextResponse(
f"文档 {document_id} 的 Word 导出功能正在开发中,当前版本请先使用预览页查看结果。",
media_type="text/plain; charset=utf-8",
async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)):
document = await db.get(Document, document_id)
if document is None:
raise HTTPException(status_code=404, detail="生成记录不存在")
template = await db.get(Template, document.template_id)
if template is None:
raise HTTPException(status_code=404, detail="模板不存在")
template_bucket, template_object = split_bucket_path(template.file_path)
template_bytes = await asyncio.to_thread(download_object_bytes, template_bucket, template_object)
result = await db.execute(
select(GenerationLog, Paragraph)
.join(Paragraph, Paragraph.id == GenerationLog.paragraph_id)
.where(GenerationLog.document_id == document_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
logs = []
for log, paragraph in result.all():
logs.append(
{
"title": paragraph.title,
"content": json.loads(log.content) if log.content else {"content": []},
}
)
exported_bytes = await asyncio.to_thread(export_document_bytes, template_bytes, logs)
object_name = f"{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}.docx"
await asyncio.to_thread(
upload_bytes,
settings.MINIO_BUCKET_OUTPUTS,
object_name,
exported_bytes,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
document.file_path = f"{settings.MINIO_BUCKET_OUTPUTS}/{object_name}"
await db.commit()
return RedirectResponse(
url=get_presigned_url(settings.MINIO_BUCKET_OUTPUTS, object_name),
status_code=307,
)
+141
View File
@@ -0,0 +1,141 @@
from io import BytesIO
from docx import Document
from docx.document import Document as DocumentObject
from docx.oxml import OxmlElement
from docx.oxml.table import CT_Tbl
from docx.oxml.text.paragraph import CT_P
from docx.table import Table, _Cell
from docx.text.paragraph import Paragraph
def _iter_block_items(parent: DocumentObject | _Cell):
parent_elm = parent.element.body if isinstance(parent, DocumentObject) else parent._tc
for child in parent_elm.iterchildren():
if isinstance(child, CT_P):
yield Paragraph(child, parent)
elif isinstance(child, CT_Tbl):
yield Table(child, parent)
def _is_heading(paragraph: Paragraph) -> bool:
style_name = paragraph.style.name if paragraph.style is not None else ""
normalized = style_name.lower().replace(" ", "")
return normalized.startswith("heading")
def _delete_block(block):
element = block._element
parent = element.getparent()
if parent is not None:
parent.remove(element)
def _clear_paragraph(paragraph: Paragraph):
element = paragraph._element
for child in list(element):
if child.tag.endswith("}r"):
element.remove(child)
def _append_paragraph_after(paragraph: Paragraph, text: str, style_name: str | None = None) -> Paragraph:
new_p = OxmlElement("w:p")
paragraph._element.addnext(new_p)
new_para = Paragraph(new_p, paragraph._parent)
if style_name:
try:
new_para.style = style_name
except Exception:
pass
if text:
new_para.add_run(text)
return new_para
def _append_table_after(paragraph: Paragraph, rows: list[list[str]], headers: list[str] | None = None):
container = paragraph._parent
table = container.add_table(rows=1, cols=max(len(headers or []), len(rows[0]) if rows else 1))
if headers:
header_cells = table.rows[0].cells
for index, value in enumerate(headers):
header_cells[index].text = value
else:
if rows:
first = rows.pop(0)
for index, value in enumerate(first):
table.rows[0].cells[index].text = value
for row in rows:
new_row = table.add_row().cells
for index, value in enumerate(row):
new_row[index].text = value
tbl = table._tbl
tbl.getparent().remove(tbl)
paragraph._element.addnext(tbl)
return Table(tbl, container)
def _append_empty_paragraph_after_table(table: Table, style_name: str | None = None) -> Paragraph:
new_p = OxmlElement("w:p")
table._tbl.addnext(new_p)
new_para = Paragraph(new_p, table._parent)
if style_name:
try:
new_para.style = style_name
except Exception:
pass
return new_para
def _find_heading_paragraph(document: DocumentObject, heading_text: str) -> Paragraph | None:
for block in _iter_block_items(document):
if isinstance(block, Paragraph) and _is_heading(block) and block.text.strip() == heading_text.strip():
return block
return None
def _replace_section_content(document: DocumentObject, heading_title: str, content: dict):
heading = _find_heading_paragraph(document, heading_title)
if heading is None:
return
first_body_style = None
current = heading._element.getnext()
blocks_to_remove = []
while current is not None:
if isinstance(current, CT_P):
current_paragraph = Paragraph(current, heading._parent)
if _is_heading(current_paragraph):
break
if first_body_style is None and current_paragraph.style is not None:
first_body_style = current_paragraph.style.name
blocks_to_remove.append(current_paragraph)
elif isinstance(current, CT_Tbl):
blocks_to_remove.append(Table(current, heading._parent))
current = current.getnext()
for block in blocks_to_remove:
_delete_block(block)
insert_after = heading
content_blocks = content.get("content", [])
for block in content_blocks:
block_type = block.get("type")
if block_type == "table":
rows = [list(row) for row in block.get("rows", [])]
headers = block.get("headers") or []
table = _append_table_after(insert_after, rows, headers)
insert_after = _append_empty_paragraph_after_table(table, first_body_style)
else:
text = block.get("text", "")
insert_after = _append_paragraph_after(insert_after, text, first_body_style)
def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
document = Document(BytesIO(template_bytes))
for item in logs:
_replace_section_content(document, item["title"], item["content"])
output = BytesIO()
document.save(output)
return output.getvalue()
+34 -1
View File
@@ -1,3 +1,6 @@
import io
from datetime import timedelta
from minio import Minio
from config import settings
@@ -34,4 +37,34 @@ def get_file_url(bucket: str, object_name: str) -> str:
def get_presigned_url(bucket: str, object_name: str, expires: int = 3600) -> str:
"""获取预签名下载 URL(带过期时间)"""
return minio_client.presigned_get_object(bucket, object_name, expires=expires)
return minio_client.presigned_get_object(bucket, object_name, expires=timedelta(seconds=expires))
def split_bucket_path(file_path: str) -> tuple[str, str]:
if "/" not in file_path:
raise ValueError("非法的 MinIO 文件路径")
return file_path.split("/", 1)
def download_object_bytes(bucket: str, object_name: str) -> bytes:
response = minio_client.get_object(bucket, object_name)
try:
return response.read()
finally:
response.close()
response.release_conn()
def upload_bytes(
bucket: str,
object_name: str,
content: bytes,
content_type: str = "application/octet-stream",
):
minio_client.put_object(
bucket,
object_name,
io.BytesIO(content),
len(content),
content_type=content_type,
)
+12
View File
@@ -25,3 +25,15 @@
6. 更新任务拆解清单,补记“模型 CRUD 路由”已完成。
7. 再次执行后端语法检查与前端类型检查,确保本轮改动可用。
- **执行结果**: 当前系统已可初步走通“上传模板 → 配置段落 → 管理模型 → 触发生成 → 查看记录/预览”的联调链路,导出仍为占位提示实现。
## 会话 ID: local-20260702150745
- [2026-07-02 15:07:45]
- **执行原因**: 用户反馈项目已运行成功,要求将运行方式补充进 README,并继续完成后续任务。
- **执行过程**:
1. 重写 README,补充本地开发启动方式、Docker 启动方式、初步使用流程、常见问题与 `.idea` 误提交处理方案。
2. 扩展 MinIO 工具方法,补充对象路径拆分、字节下载、字节上传与预签名 URL 生成能力。
3. 新增 Word 导出服务,基于原始模板按标题定位段落并替换生成结果,支持基础文本与表格内容导出。
4. 更新导出路由,使“导出 Word”能够生成文件、上传到 MinIO 输出桶并返回可下载链接。
5. 再次执行后端语法检查与前端类型检查。
6. 同步更新任务拆解清单,标记当前前端页面中已实际可用的子项。
- **执行结果**: README 已补全运行说明,系统新增基础 Word 导出能力,当前可以从预览页直接导出可下载的 Word 文件。
+13 -13
View File
@@ -64,32 +64,32 @@
### 模板管理页(5-7 天)
- [ ] 模板列表:卡片布局 + 搜索 + 分页
- [ ] 三栏编辑器布局
- [ ] 左栏:段落列表(点击高亮 + 滚动联动)
- [ ] 中栏:文档预览(A4 纸样式,段落可点击选择)
- [ ] 右栏:段落配置面板(编辑方式/模型/提示词/文件/格式)
- [ ] 保存模板
- [x] 三栏编辑器布局
- [x] 左栏:段落列表(点击高亮 + 滚动联动)
- [x] 中栏:文档预览(A4 纸样式,段落可点击选择)
- [x] 右栏:段落配置面板(编辑方式/模型/提示词/文件/格式)
- [x] 保存模板
### 模型管理页(2 天)
- [ ] 模型卡片列表 + 启用/禁用
- [ ] 添加/编辑弹窗表单
- [x] 模型卡片列表 + 启用/禁用
- [x] 添加/编辑弹窗表单
### 执行生成页(3 天)
- [ ] 双栏布局:左模板选择 + 右段落列表
- [x] 双栏布局:左模板选择 + 右段落列表
- [ ] 文件上传区(按段落分列)
- [ ] 生成按钮 + 进度展示
- [ ] 完成跳转
- [x] 生成按钮 + 进度展示
- [x] 完成跳转
### 生成记录页(2 天)
- [ ] 统计卡片
- [x] 统计卡片
- [ ] 卡片式历史列表 + 筛选
- [ ] 预览/下载按钮
- [x] 预览/下载按钮
### 预览编辑页(3-4 天)
- [ ] 富文本编辑器(contenteditable + 自定义工具栏)
- [ ] AI 内容紫色高亮标注
- [ ] 重新生成单段落
- [ ] 保存/导出按钮
- [x] 保存/导出按钮
## 第四阶段:联调与修边(第 5-6 周)