From b313083766eedc372f691340a34b67ff32ef2656 Mon Sep 17 00:00:00 2001 From: zwt13703 Date: Thu, 2 Jul 2026 15:13:57 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E8=BF=90=E8=A1=8C=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=E5=B9=B6=E5=AE=9E=E7=8E=B0=E5=9F=BA=E7=A1=80=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- README.md | 183 +++++++++++++++++++++------ backend/routers/export.py | 72 ++++++++++- backend/services/document_export.py | 141 +++++++++++++++++++++ backend/services/minio_client.py | 35 ++++- docs/tasks/task_detail_2026_07_02.md | 12 ++ docs/需求与设计/03-任务拆解清单.md | 26 ++-- 7 files changed, 414 insertions(+), 57 deletions(-) create mode 100644 backend/services/document_export.py diff --git a/.gitignore b/.gitignore index d7817cb..b213593 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ __pycache__/ *.py[cod] *$py.class - +.idea # C extensions *.so diff --git a/README.md b/README.md index 1b9dd5c..f42de6a 100644 --- a/README.md +++ b/README.md @@ -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.0(asyncmy 驱动) -- **对象存储**: 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-compose(MySQL + MinIO) -- LibreOffice(可选,用于 PDF 导出) +- Docker + Docker Compose +- LibreOffice(可选,用于未来的 PDF 导出) diff --git a/backend/routers/export.py b/backend/routers/export.py index 5c88beb..c564741 100644 --- a/backend/routers/export.py +++ b/backend/routers/export.py @@ -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, ) diff --git a/backend/services/document_export.py b/backend/services/document_export.py new file mode 100644 index 0000000..c6329f7 --- /dev/null +++ b/backend/services/document_export.py @@ -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() diff --git a/backend/services/minio_client.py b/backend/services/minio_client.py index cf28b90..8eb4a62 100644 --- a/backend/services/minio_client.py +++ b/backend/services/minio_client.py @@ -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, + ) diff --git a/docs/tasks/task_detail_2026_07_02.md b/docs/tasks/task_detail_2026_07_02.md index 9ec768c..42c8040 100644 --- a/docs/tasks/task_detail_2026_07_02.md +++ b/docs/tasks/task_detail_2026_07_02.md @@ -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 文件。 diff --git a/docs/需求与设计/03-任务拆解清单.md b/docs/需求与设计/03-任务拆解清单.md index a55f305..2673167 100644 --- a/docs/需求与设计/03-任务拆解清单.md +++ b/docs/需求与设计/03-任务拆解清单.md @@ -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 周)