模板在线编辑与导出链路重构

This commit is contained in:
zwt13703
2026-07-05 23:35:00 +08:00
parent 1369d87afb
commit c84ec6aa71
15 changed files with 1434 additions and 166 deletions
+81 -13
View File
@@ -15,6 +15,7 @@ from models.document import Document
from models.generation_log import GenerationLog
from models.paragraph import Paragraph
from models.template import Template
from models.template_block import TemplateBlock
from services.document_export import export_document_bytes
from services.minio_client import (
download_object_bytes,
@@ -26,6 +27,46 @@ from services.minio_client import (
router = APIRouter()
def _block_text_content(block: TemplateBlock) -> str:
try:
payload = json.loads(block.content_json or "{}")
except Exception:
payload = {}
return payload.get("text") or block.default_value or ""
def _block_table_content(block: TemplateBlock) -> dict:
try:
payload = json.loads(block.content_json or "{}")
except Exception:
payload = {}
return payload.get("table") or {}
def _build_block_export_content(block: TemplateBlock) -> dict:
if block.block_type == "table":
table_data = _block_table_content(block)
matrix = table_data.get("data") or []
headers = matrix[0] if matrix else []
rows = matrix[1:] if len(matrix) > 1 else []
return {"content": [{"type": "table", "headers": headers, "rows": rows}]}
return {"content": [{"type": "text", "text": _block_text_content(block)}]}
def _resolve_block_write_modes(blocks: list[TemplateBlock]) -> list[str]:
modes: list[str] = []
anchor_counter: dict[str, int] = {}
for block in blocks:
if block.block_type == "heading":
modes.append("replace_heading_only")
continue
anchor = (block.anchor_ref or block.title or "").strip()
seen = anchor_counter.get(anchor, 0)
modes.append("replace_section" if seen == 0 else "append_after_heading")
anchor_counter[anchor] = seen + 1
return modes
@router.get("/{document_id}/docx")
async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)):
document = await db.get(Document, document_id)
@@ -39,22 +80,49 @@ async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)):
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())
block_result = await db.execute(
select(TemplateBlock)
.where(TemplateBlock.template_id == template.id)
.order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
)
blocks = block_result.scalars().all()
log_result = await db.execute(
select(GenerationLog).where(GenerationLog.document_id == document_id)
)
generation_logs = log_result.scalars().all()
log_map = {item.paragraph_id: json.loads(item.content) if item.content else {"content": []} for item in generation_logs}
logs = []
for log, paragraph in result.all():
logs.append(
{
"anchor_title": paragraph.anchor_title or paragraph.title,
"title": paragraph.title,
"write_mode": paragraph.write_mode,
"content": json.loads(log.content) if log.content else {"content": []},
}
if blocks:
write_modes = _resolve_block_write_modes(blocks)
for block, write_mode in zip(blocks, write_modes):
generated_content = log_map.get(block.source_paragraph_id) if block.source_paragraph_id else None
content = generated_content if (block.edit_mode == "ai" or block.block_type == "ai_slot") and generated_content else _build_block_export_content(block)
logs.append(
{
"anchor_title": block.anchor_ref or block.title,
"title": block.title,
"write_mode": write_mode,
"content": content,
}
)
else:
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())
)
for log, paragraph in result.all():
logs.append(
{
"anchor_title": paragraph.anchor_title or paragraph.title,
"title": paragraph.title,
"write_mode": paragraph.write_mode,
"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"
+310 -6
View File
@@ -1,4 +1,5 @@
import asyncio
import json
import os
import tempfile
import uuid
@@ -15,8 +16,10 @@ from models.document import Document
from models.generation_log import GenerationLog
from models.paragraph import Paragraph
from models.template import Template
from models.template_block import TemplateBlock
from schemas.schemas import Response, TemplateSave
from services.minio_client import minio_client
from services.document_export import export_document_bytes
from services.minio_client import download_object_bytes, minio_client, split_bucket_path, upload_bytes
from services.template_parser import parse_template
router = APIRouter()
@@ -64,6 +67,270 @@ def _serialize_template(template: Template) -> dict:
}
def _build_block_from_paragraph(paragraph: Paragraph) -> dict:
block_type = "heading" if paragraph.write_mode == "replace_heading_only" else ("ai_slot" if paragraph.edit_mode == "ai" else ("table" if paragraph.is_table else "text"))
content_json = json.dumps({
"text": paragraph.content or "",
"table": json.loads(paragraph.table_json or "{}") if paragraph.is_table else None,
}, ensure_ascii=False)
return {
"source_paragraph_id": paragraph.id,
"parent_block_id": None,
"sort_index": paragraph.sort_index,
"block_type": block_type,
"anchor_ref": paragraph.anchor_title or paragraph.title,
"title": paragraph.title,
"content_json": content_json,
"style_json": paragraph.style_json or "{}",
"edit_mode": paragraph.edit_mode,
"placeholder_key": "",
"variable_key": "",
"default_value": paragraph.content or "",
"model_id": paragraph.model_id,
"need_prompt": paragraph.need_prompt,
"prompt_text": paragraph.prompt_text,
"need_file": paragraph.need_file,
"file_note": paragraph.file_note,
"output_format": paragraph.output_format,
}
def _build_block_from_parsed_item(item, source_paragraph_id: int | None) -> dict:
content_json = json.dumps({
"text": item.content or "",
"table": json.loads(item.table_json or "{}") if item.is_table else None,
}, ensure_ascii=False)
return {
"source_paragraph_id": source_paragraph_id,
"parent_block_id": None,
"sort_index": item.sort_index,
"block_type": item.block_type,
"anchor_ref": item.anchor_title or item.title,
"title": item.title,
"content_json": content_json,
"style_json": item.style_json or "{}",
"edit_mode": item.edit_mode,
"placeholder_key": item.placeholder_key,
"variable_key": item.variable_key,
"default_value": item.default_value,
"model_id": None,
"need_prompt": True,
"prompt_text": "",
"need_file": False,
"file_note": "",
"output_format": item.output_format,
}
def _serialize_block(block: TemplateBlock) -> dict:
try:
content_json = json.loads(block.content_json or "{}")
except Exception:
content_json = {}
return {
"id": block.id,
"template_id": block.template_id,
"source_paragraph_id": block.source_paragraph_id,
"parent_block_id": block.parent_block_id,
"sort_index": block.sort_index,
"block_type": block.block_type,
"anchor_ref": block.anchor_ref,
"title": block.title,
"content_json": content_json,
"style_json": block.style_json,
"edit_mode": block.edit_mode,
"placeholder_key": block.placeholder_key,
"variable_key": block.variable_key,
"default_value": block.default_value,
"model_id": block.model_id,
"need_prompt": block.need_prompt,
"prompt_text": block.prompt_text,
"need_file": block.need_file,
"file_note": block.file_note,
"output_format": block.output_format,
}
async def _load_blocks(db: AsyncSession, template_id: int) -> list[TemplateBlock]:
result = await db.execute(
select(TemplateBlock)
.where(TemplateBlock.template_id == template_id)
.order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
)
return result.scalars().all()
async def _sync_blocks_from_paragraphs(db: AsyncSession, template_id: int, paragraphs: list[Paragraph]):
existing_blocks = await _load_blocks(db, template_id)
for block in existing_blocks:
await db.delete(block)
await db.flush()
block_rows: list[TemplateBlock] = []
for paragraph in paragraphs:
block = TemplateBlock(template_id=template_id, **_build_block_from_paragraph(paragraph))
db.add(block)
block_rows.append(block)
await db.flush()
return block_rows
async def _save_blocks(
db: AsyncSession,
template_id: int,
blocks_payload,
):
existing_blocks = await _load_blocks(db, template_id)
block_map = {item.id: item for item in existing_blocks}
incoming_ids = {config.id for config in blocks_payload if config.id}
for block in existing_blocks:
if block.id not in incoming_ids:
await db.delete(block)
for index, config in enumerate(blocks_payload, start=1):
block = block_map.get(config.id) if config.id else None
if block is None:
block = TemplateBlock(template_id=template_id)
db.add(block)
block.source_paragraph_id = config.source_paragraph_id
block.parent_block_id = config.parent_block_id
block.sort_index = index
block.block_type = config.block_type
block.anchor_ref = config.anchor_ref or config.title
block.title = config.title
block.content_json = json.dumps(config.content_json or {}, ensure_ascii=False)
block.style_json = config.style_json or "{}"
block.edit_mode = config.edit_mode
block.placeholder_key = config.placeholder_key
block.variable_key = config.variable_key
block.default_value = config.default_value
block.model_id = config.model_id
block.need_prompt = config.need_prompt
block.prompt_text = config.prompt_text
block.need_file = config.need_file
block.file_note = config.file_note
block.output_format = config.output_format
await db.flush()
def _block_text_content(block: TemplateBlock) -> str:
try:
payload = json.loads(block.content_json or "{}")
except Exception:
payload = {}
return payload.get("text") or block.default_value or ""
def _block_table_content(block: TemplateBlock) -> dict:
try:
payload = json.loads(block.content_json or "{}")
except Exception:
payload = {}
return payload.get("table") or {}
async def _sync_paragraphs_from_blocks(db: AsyncSession, template_id: int) -> list[Paragraph]:
paragraph_result = await db.execute(
select(Paragraph)
.where(Paragraph.template_id == template_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
existing_paragraphs = paragraph_result.scalars().all()
paragraph_map = {item.id: item for item in existing_paragraphs}
blocks = await _load_blocks(db, template_id)
write_modes = _resolve_block_write_modes(blocks)
kept_paragraph_ids: set[int] = set()
synced_rows: list[Paragraph] = []
for index, (block, write_mode) in enumerate(zip(blocks, write_modes), start=1):
paragraph = paragraph_map.get(block.source_paragraph_id) if block.source_paragraph_id else None
if paragraph is None:
paragraph = Paragraph(template_id=template_id)
db.add(paragraph)
await db.flush()
paragraph.sort_index = index
paragraph.anchor_title = block.anchor_ref or block.title
paragraph.title = block.title
paragraph.content = _block_text_content(block)
paragraph.style_json = block.style_json or "{}"
paragraph.is_table = block.block_type == "table"
paragraph.table_json = json.dumps(_block_table_content(block), ensure_ascii=False) if paragraph.is_table else "{}"
paragraph.edit_mode = "ai" if block.block_type == "ai_slot" or block.edit_mode == "ai" else "manual"
paragraph.write_mode = write_mode
paragraph.model_id = block.model_id
paragraph.need_prompt = block.need_prompt
paragraph.prompt_text = block.prompt_text
paragraph.need_file = block.need_file
paragraph.file_note = block.file_note
paragraph.output_format = block.output_format
block.source_paragraph_id = paragraph.id
kept_paragraph_ids.add(paragraph.id)
synced_rows.append(paragraph)
for paragraph in existing_paragraphs:
if paragraph.id in kept_paragraph_ids:
continue
await db.execute(delete(GenerationLog).where(GenerationLog.paragraph_id == paragraph.id))
await db.delete(paragraph)
await db.flush()
return synced_rows
def _build_export_content_from_block(block: TemplateBlock) -> dict:
if block.block_type == "table":
table_data = _block_table_content(block)
matrix = table_data.get("data") or []
headers = matrix[0] if matrix else []
rows = matrix[1:] if len(matrix) > 1 else []
return {"content": [{"type": "table", "headers": headers, "rows": rows}]}
return {"content": [{"type": "text", "text": _block_text_content(block)}]}
def _resolve_block_write_modes(blocks: list[TemplateBlock]) -> list[str]:
modes: list[str] = []
anchor_counter: dict[str, int] = {}
for block in blocks:
if block.block_type == "heading":
modes.append("replace_heading_only")
continue
anchor = (block.anchor_ref or block.title or "").strip()
seen = anchor_counter.get(anchor, 0)
modes.append("replace_section" if seen == 0 else "append_after_heading")
anchor_counter[anchor] = seen + 1
return modes
async def _write_template_snapshot_to_docx(db: AsyncSession, template: Template):
blocks = await _load_blocks(db, template.id)
if not blocks:
return
write_modes = _resolve_block_write_modes(blocks)
logs = []
for block, write_mode in zip(blocks, write_modes):
logs.append(
{
"anchor_title": block.anchor_ref or block.title,
"title": block.title,
"write_mode": write_mode,
"content": _build_export_content_from_block(block),
}
)
template_bucket, template_object = split_bucket_path(template.file_path)
template_bytes = await asyncio.to_thread(download_object_bytes, template_bucket, template_object)
exported_bytes = await asyncio.to_thread(export_document_bytes, template_bytes, logs)
await asyncio.to_thread(
upload_bytes,
template_bucket,
template_object,
exported_bytes,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
@router.get("")
async def list_templates(
page: int = Query(1, ge=1),
@@ -100,9 +367,16 @@ async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
.where(Paragraph.template_id == template_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
paragraphs = [_serialize_paragraph(item) for item in result.scalars().all()]
paragraph_rows = result.scalars().all()
paragraphs = [_serialize_paragraph(item) for item in paragraph_rows]
block_rows = await _load_blocks(db, template_id)
if not block_rows and paragraph_rows:
block_rows = await _sync_blocks_from_paragraphs(db, template_id, paragraph_rows)
await db.commit()
blocks = [_serialize_block(item) for item in block_rows]
payload = _serialize_template(template)
payload["paragraphs"] = paragraphs
payload["blocks"] = blocks
return Response(data=payload)
@@ -161,19 +435,35 @@ async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depen
style_json=item.style_json,
is_table=item.is_table,
table_json=item.table_json,
edit_mode="manual",
edit_mode=item.edit_mode,
write_mode=item.write_mode,
need_prompt=item.edit_mode == "ai",
output_format=item.output_format,
)
db.add(paragraph)
paragraph_rows.append(paragraph)
await db.flush()
existing_blocks = await _load_blocks(db, template.id)
for block in existing_blocks:
await db.delete(block)
await db.flush()
block_rows: list[TemplateBlock] = []
for item, paragraph in zip(parsed_items, paragraph_rows):
block = TemplateBlock(template_id=template.id, **_build_block_from_parsed_item(item, paragraph.id))
db.add(block)
block_rows.append(block)
await db.flush()
await db.commit()
await db.refresh(template)
for paragraph in paragraph_rows:
await db.refresh(paragraph)
for block in block_rows:
await db.refresh(block)
payload = _serialize_template(template)
payload["paragraphs"] = [_serialize_paragraph(item) for item in paragraph_rows]
payload["blocks"] = [_serialize_block(item) for item in block_rows]
return Response(data=payload)
@@ -224,10 +514,24 @@ async def save_template_paragraphs(
paragraph.file_note = config.file_note
paragraph.output_format = config.output_format
await db.flush()
refreshed_result = await db.execute(
select(Paragraph)
.where(Paragraph.template_id == template_id)
.order_by(Paragraph.sort_index.asc(), Paragraph.id.asc())
)
refreshed_paragraphs = refreshed_result.scalars().all()
if body.save_mode == "manual" and body.blocks:
await _save_blocks(db, template_id, body.blocks)
refreshed_paragraphs = await _sync_paragraphs_from_blocks(db, template_id)
else:
await _sync_blocks_from_paragraphs(db, template_id, refreshed_paragraphs)
refreshed_paragraphs = await _sync_paragraphs_from_blocks(db, template_id)
template.paragraph_count = len(refreshed_paragraphs)
await _write_template_snapshot_to_docx(db, template)
await db.commit()
template.paragraph_count = len(body.paragraphs)
await db.commit()
return Response(data={"template_id": template_id, "saved": len(body.paragraphs)})
blocks = [_serialize_block(item) for item in await _load_blocks(db, template_id)]
return Response(data={"template_id": template_id, "saved": len(refreshed_paragraphs), "blocks": blocks})
@router.delete("/{template_id}")