补充运行说明并实现基础导出能力
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
.idea
|
||||||
# C extensions
|
# C extensions
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
|
|||||||
@@ -1,80 +1,191 @@
|
|||||||
# doc-forge
|
# doc-forge
|
||||||
|
|
||||||
上传 Word 模板 → AI 逐段落生成 → 导出保留原始样式的 Word 文档。
|
上传 Word 模板 → AI 逐段落生成 → 预览编辑 → 导出尽量保留原始样式的 Word 文档。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- **前端**: Vue 3.4 + Vite 5 + TypeScript + Ant Design Vue 4.x + Pinia + Axios
|
- **前端**: Vue 3.4 + Vite 5 + TypeScript + Ant Design Vue 4.x + Pinia + Axios
|
||||||
- **后端**: Python 3.11+ + FastAPI + SQLAlchemy 2.0 async
|
- **后端**: Python 3.11+ + FastAPI + SQLAlchemy 2.0 async
|
||||||
- **数据库**: MySQL 8.0(asyncmy 驱动)
|
- **数据库**: MySQL 8.0(asyncmy 驱动)
|
||||||
- **对象存储**: MinIO(模板/参考文件/导出文档)
|
- **对象存储**: MinIO(模板 / 参考文件 / 导出文档)
|
||||||
- **文档处理**: python-docx、openpyxl
|
- **文档处理**: python-docx、openpyxl
|
||||||
|
|
||||||
## 快速开始
|
## 当前可用能力
|
||||||
|
|
||||||
|
- 上传 `.docx` 模板并按 Heading 1~6 解析段落
|
||||||
|
- 在模板编辑页配置段落的编辑方式、模型、提示词、文件要求、输出格式
|
||||||
|
- 管理模型配置,API Key 以加密形式存储,前端仅显示脱敏内容
|
||||||
|
- 执行整份文档的模拟生成
|
||||||
|
- 查看生成记录与预览页真实结果
|
||||||
|
- 导出 Word:基于原模板替换标题下内容并生成可下载文件
|
||||||
|
|
||||||
|
## 运行方式
|
||||||
|
|
||||||
|
推荐开发方式:`Docker 启动依赖 + 本地启动前后端`
|
||||||
|
|
||||||
|
### 1. 启动 MySQL 和 MinIO
|
||||||
|
|
||||||
|
在项目根目录执行:
|
||||||
|
|
||||||
```bash
|
```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
|
pip install -r requirements.txt
|
||||||
python main.py
|
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||||
# → http://localhost:8000/docs Swagger
|
```
|
||||||
|
|
||||||
# 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 install
|
||||||
pnpm dev
|
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/
|
doc-forge/
|
||||||
├── web/ Vue 3 前端
|
├── web/ Vue 3 前端
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── views/ 6 个页面
|
│ ├── views/ 页面
|
||||||
│ ├── components/ 6 个通用组件
|
│ ├── components/ 通用组件
|
||||||
│ ├── api/ Axios 请求层
|
│ ├── api/ Axios 请求层
|
||||||
│ ├── stores/ Pinia 状态管理
|
│ ├── stores/ Pinia 状态管理
|
||||||
│ └── router/ 路由配置
|
│ └── router/ 路由配置
|
||||||
├── backend/ Python FastAPI 后端
|
├── backend/ Python FastAPI 后端
|
||||||
│ ├── app/models/ ORM 数据模型
|
│ ├── models/ ORM 数据模型
|
||||||
│ ├── app/routers/ API 路由
|
│ ├── routers/ API 路由
|
||||||
│ ├── app/schemas/ Pydantic 校验
|
│ ├── schemas/ Pydantic 校验
|
||||||
│ ├── app/services/ 业务逻辑层
|
│ ├── services/ 业务逻辑层
|
||||||
│ ├── config.py 配置
|
│ ├── config.py 配置
|
||||||
│ └── database.py 异步数据库引擎
|
│ └── database.py 异步数据库引擎
|
||||||
├── docker-compose.yml MySQL + MinIO + 后端 + 前端
|
├── docker-compose.yml MySQL + MinIO + 后端 + 前端
|
||||||
├── init.sql 数据库建表 DDL
|
├── init.sql 数据库初始化 SQL
|
||||||
└── docs/ 项目文档
|
└── docs/ 项目文档
|
||||||
```
|
```
|
||||||
|
|
||||||
## 核心流程
|
## 核心流程
|
||||||
|
|
||||||
```
|
```text
|
||||||
上传模板 → 解析段落 → 标注配置 → 保存模板
|
上传模板 → 解析段落 → 标注配置 → 保存模板
|
||||||
→ 执行生成(上传文件 → AI 并行生成 → SSE 进度)
|
→ 执行生成(上传文件 → AI 生成)
|
||||||
→ 预览编辑 → 导出 Word(保留原始样式)
|
→ 预览编辑 → 导出 Word
|
||||||
```
|
```
|
||||||
|
|
||||||
## 关键设计
|
|
||||||
|
|
||||||
| 决策 | 选择 | 原因 |
|
|
||||||
|------|------|------|
|
|
||||||
| 段落边界 | Word 标题样式 Heading 1~6 | 稳定可靠,用户学习成本低 |
|
|
||||||
| AI 输出 | 结构化 JSON | 支持文字+表格混合,后端可控解析 |
|
|
||||||
| 导出策略 | 基于原模板替换内容 | 样式零损失,不限模板格式 |
|
|
||||||
| 文件存储 | MinIO 对象存储 | 可扩展,不占用本地磁盘 |
|
|
||||||
| 数据库 | MySQL 8.0 | 生产级可靠性 |
|
|
||||||
| 生成方式 | 多段落并行 asyncio.gather | 大幅缩短等待时间 |
|
|
||||||
|
|
||||||
## 环境要求
|
## 环境要求
|
||||||
|
|
||||||
- Python 3.11+
|
- Python 3.11+
|
||||||
- Node.js 18+
|
- Node.js 18+
|
||||||
- pnpm 8+
|
- pnpm 8+
|
||||||
- Docker + docker-compose(MySQL + MinIO)
|
- Docker + Docker Compose
|
||||||
- LibreOffice(可选,用于 PDF 导出)
|
- LibreOffice(可选,用于未来的 PDF 导出)
|
||||||
|
|||||||
@@ -1,14 +1,74 @@
|
|||||||
from fastapi import APIRouter
|
import asyncio
|
||||||
from fastapi.responses import PlainTextResponse
|
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 = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{document_id}/docx")
|
@router.get("/{document_id}/docx")
|
||||||
async def export_docx(document_id: int):
|
async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
return PlainTextResponse(
|
document = await db.get(Document, document_id)
|
||||||
f"文档 {document_id} 的 Word 导出功能正在开发中,当前版本请先使用预览页查看结果。",
|
if document is None:
|
||||||
media_type="text/plain; charset=utf-8",
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import io
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from minio import Minio
|
from minio import Minio
|
||||||
from config import settings
|
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:
|
def get_presigned_url(bucket: str, object_name: str, expires: int = 3600) -> str:
|
||||||
"""获取预签名下载 URL(带过期时间)"""
|
"""获取预签名下载 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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -25,3 +25,15 @@
|
|||||||
6. 更新任务拆解清单,补记“模型 CRUD 路由”已完成。
|
6. 更新任务拆解清单,补记“模型 CRUD 路由”已完成。
|
||||||
7. 再次执行后端语法检查与前端类型检查,确保本轮改动可用。
|
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
@@ -64,32 +64,32 @@
|
|||||||
|
|
||||||
### 模板管理页(5-7 天)
|
### 模板管理页(5-7 天)
|
||||||
- [ ] 模板列表:卡片布局 + 搜索 + 分页
|
- [ ] 模板列表:卡片布局 + 搜索 + 分页
|
||||||
- [ ] 三栏编辑器布局
|
- [x] 三栏编辑器布局
|
||||||
- [ ] 左栏:段落列表(点击高亮 + 滚动联动)
|
- [x] 左栏:段落列表(点击高亮 + 滚动联动)
|
||||||
- [ ] 中栏:文档预览(A4 纸样式,段落可点击选择)
|
- [x] 中栏:文档预览(A4 纸样式,段落可点击选择)
|
||||||
- [ ] 右栏:段落配置面板(编辑方式/模型/提示词/文件/格式)
|
- [x] 右栏:段落配置面板(编辑方式/模型/提示词/文件/格式)
|
||||||
- [ ] 保存模板
|
- [x] 保存模板
|
||||||
|
|
||||||
### 模型管理页(2 天)
|
### 模型管理页(2 天)
|
||||||
- [ ] 模型卡片列表 + 启用/禁用
|
- [x] 模型卡片列表 + 启用/禁用
|
||||||
- [ ] 添加/编辑弹窗表单
|
- [x] 添加/编辑弹窗表单
|
||||||
|
|
||||||
### 执行生成页(3 天)
|
### 执行生成页(3 天)
|
||||||
- [ ] 双栏布局:左模板选择 + 右段落列表
|
- [x] 双栏布局:左模板选择 + 右段落列表
|
||||||
- [ ] 文件上传区(按段落分列)
|
- [ ] 文件上传区(按段落分列)
|
||||||
- [ ] 生成按钮 + 进度展示
|
- [x] 生成按钮 + 进度展示
|
||||||
- [ ] 完成跳转
|
- [x] 完成跳转
|
||||||
|
|
||||||
### 生成记录页(2 天)
|
### 生成记录页(2 天)
|
||||||
- [ ] 统计卡片
|
- [x] 统计卡片
|
||||||
- [ ] 卡片式历史列表 + 筛选
|
- [ ] 卡片式历史列表 + 筛选
|
||||||
- [ ] 预览/下载按钮
|
- [x] 预览/下载按钮
|
||||||
|
|
||||||
### 预览编辑页(3-4 天)
|
### 预览编辑页(3-4 天)
|
||||||
- [ ] 富文本编辑器(contenteditable + 自定义工具栏)
|
- [ ] 富文本编辑器(contenteditable + 自定义工具栏)
|
||||||
- [ ] AI 内容紫色高亮标注
|
- [ ] AI 内容紫色高亮标注
|
||||||
- [ ] 重新生成单段落
|
- [ ] 重新生成单段落
|
||||||
- [ ] 保存/导出按钮
|
- [x] 保存/导出按钮
|
||||||
|
|
||||||
## 第四阶段:联调与修边(第 5-6 周)
|
## 第四阶段:联调与修边(第 5-6 周)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user