Compare commits

..

No commits in common. "dsv4_1" and "master" have entirely different histories.

18 changed files with 19 additions and 2784 deletions

View File

@ -26,23 +26,9 @@ async def init_db():
from models.document import Document from models.document import Document
from models.generation_log import GenerationLog from models.generation_log import GenerationLog
from models.reference_file import ReferenceFile from models.reference_file import ReferenceFile
from models.template_block import TemplateBlock
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
dialect_name = conn.dialect.name dialect_name = conn.dialect.name
# template_blocks 新增列
block_columns = await conn.run_sync(lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("template_blocks")])
if "anchor_start_index" not in block_columns:
await conn.execute(text("ALTER TABLE template_blocks ADD COLUMN anchor_start_index INT DEFAULT 0"))
if "anchor_end_index" not in block_columns:
await conn.execute(text("ALTER TABLE template_blocks ADD COLUMN anchor_end_index INT DEFAULT 0"))
if "html_snippet" not in block_columns:
await conn.execute(text("ALTER TABLE template_blocks ADD COLUMN html_snippet TEXT"))
# generation_logs 新增 block_id
log_columns = await conn.run_sync(lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("generation_logs")])
if "block_id" not in log_columns:
await conn.execute(text("ALTER TABLE generation_logs ADD COLUMN block_id INT DEFAULT NULL"))
await conn.execute(text("ALTER TABLE generation_logs MODIFY COLUMN paragraph_id INT DEFAULT NULL"))
columns = await conn.run_sync(lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("ai_models")]) columns = await conn.run_sync(lambda sync_conn: [column["name"] for column in inspect(sync_conn).get_columns("ai_models")])
if "supports_streaming" not in columns: if "supports_streaming" not in columns:
if dialect_name == "sqlite": if dialect_name == "sqlite":

View File

@ -5,8 +5,7 @@ class GenerationLog(Base):
__tablename__ = "generation_logs" __tablename__ = "generation_logs"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
document_id = Column(Integer, ForeignKey("documents.id"), nullable=False) document_id = Column(Integer, ForeignKey("documents.id"), nullable=False)
paragraph_id = Column(Integer, ForeignKey("paragraphs.id"), nullable=True) paragraph_id = Column(Integer, ForeignKey("paragraphs.id"), nullable=False)
block_id = Column(Integer, ForeignKey("template_blocks.id"), nullable=True, comment="块级生成日志关联")
model_id = Column(Integer, ForeignKey("ai_models.id"), nullable=True) model_id = Column(Integer, ForeignKey("ai_models.id"), nullable=True)
status = Column(String(20), default="pending", comment="pending/generating/success/failed") status = Column(String(20), default="pending", comment="pending/generating/success/failed")
content = Column(Text, default="", comment="生成的内容") content = Column(Text, default="", comment="生成的内容")

View File

@ -13,9 +13,6 @@ class TemplateBlock(Base):
sort_index = Column(Integer, default=0, comment="排序") sort_index = Column(Integer, default=0, comment="排序")
block_type = Column(String(30), default="text", comment="heading/text/table/ai_slot/variable") block_type = Column(String(30), default="text", comment="heading/text/table/ai_slot/variable")
anchor_ref = Column(String(500), default="", comment="原始锚点引用") anchor_ref = Column(String(500), default="", comment="原始锚点引用")
anchor_start_index = Column(Integer, default=0, comment="原始文档起始元素索引")
anchor_end_index = Column(Integer, default=0, comment="原始文档结束元素索引")
html_snippet = Column(Text, comment="渲染后的 HTML 片段")
title = Column(String(500), default="", comment="块标题") title = Column(String(500), default="", comment="块标题")
content_json = Column(Text, default="{}", comment="块内容 JSON") content_json = Column(Text, default="{}", comment="块内容 JSON")
style_json = Column(Text, default="{}", comment="块样式 JSON") style_json = Column(Text, default="{}", comment="块样式 JSON")

View File

@ -15,8 +15,7 @@ from models.document import Document
from models.generation_log import GenerationLog from models.generation_log import GenerationLog
from models.paragraph import Paragraph from models.paragraph import Paragraph
from models.template import Template from models.template import Template
from models.template_block import TemplateBlock from services.document_export import export_document_bytes
from services.document_export import export_document_bytes, export_document_bytes_from_blocks
from services.minio_client import ( from services.minio_client import (
download_object_bytes, download_object_bytes,
get_presigned_url, get_presigned_url,
@ -75,78 +74,6 @@ async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)):
) )
@router.get("/{document_id}/docx-v2")
async def export_docx_v2(document_id: int, db: AsyncSession = Depends(get_db)):
"""基于块结构的 Word 导出。"""
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)
# 查询块和生成日志
blocks_result = await db.execute(
select(TemplateBlock)
.where(TemplateBlock.template_id == document.template_id)
.order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
)
blocks = blocks_result.scalars().all()
logs_result = await db.execute(
select(GenerationLog)
.where(GenerationLog.document_id == document_id)
)
logs = logs_result.scalars().all()
block_dicts = [
{
"id": b.id,
"sort_index": b.sort_index,
"anchor_start_index": b.anchor_start_index,
"anchor_end_index": b.anchor_end_index,
"edit_mode": b.edit_mode,
"block_type": b.block_type,
"title": b.title,
}
for b in blocks
]
log_dicts = [
{
"id": log.id,
"block_id": log.block_id,
"status": log.status,
"content": json.loads(log.content) if log.content else {"content": []},
}
for log in logs
]
exported_bytes = await asyncio.to_thread(
export_document_bytes_from_blocks, template_bytes, block_dicts, log_dicts
)
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,
)
@router.get("/{document_id}/pdf") @router.get("/{document_id}/pdf")
async def export_pdf(document_id: int): async def export_pdf(document_id: int):
return PlainTextResponse( return PlainTextResponse(

View File

@ -1,7 +1,6 @@
import asyncio import asyncio
import json import json
import os import os
import time
import uuid import uuid
from datetime import datetime from datetime import datetime
@ -18,17 +17,14 @@ from models.generation_log import GenerationLog
from models.paragraph import Paragraph from models.paragraph import Paragraph
from models.reference_file import ReferenceFile from models.reference_file import ReferenceFile
from models.template import Template from models.template import Template
from models.template_block import TemplateBlock
from schemas.schemas import GenerateFullRequest, GenerateTestRequest, ReferenceFileUpdate, Response from schemas.schemas import GenerateFullRequest, GenerateTestRequest, ReferenceFileUpdate, Response
from services.ai_service import call_ai, stream_ai_preview from services.ai_service import call_ai, stream_ai_preview
from services.file_summary import summarize_minio_files from services.file_summary import summarize_minio_files
from services.generation_runtime import ( from services.generation_runtime import (
build_mock_content, build_mock_content,
build_mock_content_for_block,
generation_progress, generation_progress,
request_cancel, request_cancel,
run_generation, run_generation,
run_generation_for_blocks,
update_progress, update_progress,
) )
from services.minio_client import delete_object, split_bucket_path, upload_bytes, get_presigned_url from services.minio_client import delete_object, split_bucket_path, upload_bytes, get_presigned_url
@ -372,45 +368,6 @@ async def generate_full(body: GenerateFullRequest, db: AsyncSession = Depends(ge
return Response(data=_serialize_document(document)) return Response(data=_serialize_document(document))
@router.post("/full-v2")
async def generate_full_v2(body: GenerateFullRequest, db: AsyncSession = Depends(get_db)):
"""基于块结构的全量生成(可视化编辑器使用)。"""
template = await db.get(Template, body.template_id)
if template is None:
raise HTTPException(status_code=404, detail="模板不存在")
result = await db.execute(
select(TemplateBlock)
.where(TemplateBlock.template_id == body.template_id)
.order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
)
blocks = result.scalars().all()
if not blocks:
raise HTTPException(status_code=400, detail="模板下暂无可生成块")
ai_block_count = sum(1 for b in blocks if b.edit_mode == "ai")
if ai_block_count == 0:
raise HTTPException(status_code=400, detail="模板下没有 AI 生成块")
document = Document(
template_id=template.id,
name=f"{template.name}-{datetime.now().strftime('%Y%m%d%H%M%S')}",
para_count_done=0,
para_count_total=ai_block_count,
status="generating",
file_path="",
error="",
request_payload_json=json.dumps({"template_name": template.name, "block_count": len(blocks), "ai_block_count": ai_block_count}, ensure_ascii=False),
)
db.add(document)
await db.flush()
await db.commit()
await db.refresh(document)
update_progress(document.id, status="pending", percent=0, done=0, total=ai_block_count, message="块级生成任务已创建")
asyncio.create_task(run_generation_for_blocks(document.id, template.id))
return Response(data=_serialize_document(document))
@router.get("/progress/{document_id}") @router.get("/progress/{document_id}")
async def generate_progress(document_id: int): async def generate_progress(document_id: int):
async def event_generator(): async def event_generator():
@ -430,86 +387,6 @@ async def generate_progress(document_id: int):
return EventSourceResponse(event_generator()) return EventSourceResponse(event_generator())
@router.post("/blocks/{block_id}/regenerate")
async def regenerate_block(
block_id: int,
body: GenerateTestRequest,
db: AsyncSession = Depends(get_db),
):
"""对单个块重新生成内容。"""
block = await db.get(TemplateBlock, block_id)
if block is None:
raise HTTPException(status_code=404, detail="块不存在")
document = await db.get(Document, body.template_id)
if document is None:
raise HTTPException(status_code=404, detail="生成记录不存在")
model = None
if body.model_id:
model = await db.get(AiModel, body.model_id)
elif block.model_id:
model = await db.get(AiModel, block.model_id)
if model is None or model.status != "enabled":
# 使用默认模型
result = await db.execute(
select(AiModel).where(AiModel.status == "enabled").order_by(AiModel.id.asc()).limit(1)
)
model = result.scalars().first()
content = {}
status = "success"
error_msg = ""
duration = 0.0
if model is None:
content = build_mock_content_for_block(block)
else:
start = time.perf_counter()
try:
temp_para = Paragraph(
template_id=block.template_id,
sort_index=block.sort_index,
title=block.title,
content="",
prompt_text=body.prompt_text or block.prompt_text or "",
output_format=block.output_format,
need_prompt=block.need_prompt,
edit_mode=block.edit_mode,
)
setattr(temp_para, "enable_reasoning", bool(model.enable_reasoning))
result_data = await call_ai(temp_para, model, None)
content = result_data.content
except Exception as error:
content = build_mock_content_for_block(block)
status = "failed"
error_msg = str(error)
duration = round(time.perf_counter() - start, 4)
log = GenerationLog(
document_id=document.id,
block_id=block.id,
model_id=model.id if model else None,
status=status,
content=json.dumps(content, ensure_ascii=False),
duration=duration,
error_msg=error_msg,
)
db.add(log)
await db.commit()
await db.refresh(log)
return Response(data={
"id": log.id,
"block_id": block.id,
"status": log.status,
"content": content,
"duration": log.duration,
"error_msg": log.error_msg,
})
@router.get("/documents") @router.get("/documents")
async def list_documents( async def list_documents(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
@ -533,7 +410,6 @@ async def get_document(document_id: int, db: AsyncSession = Depends(get_db)):
if document is None: if document is None:
raise HTTPException(status_code=404, detail="生成记录不存在") raise HTTPException(status_code=404, detail="生成记录不存在")
# 查询段落级日志
log_result = await db.execute( log_result = await db.execute(
select(GenerationLog, Paragraph) select(GenerationLog, Paragraph)
.join(Paragraph, Paragraph.id == GenerationLog.paragraph_id) .join(Paragraph, Paragraph.id == GenerationLog.paragraph_id)
@ -555,52 +431,6 @@ async def get_document(document_id: int, db: AsyncSession = Depends(get_db)):
payload = _serialize_document(document) payload = _serialize_document(document)
payload["logs"] = items payload["logs"] = items
# 查询块级日志和模板块(用于可视化预览)
blocks_result = await db.execute(
select(TemplateBlock)
.where(TemplateBlock.template_id == document.template_id)
.order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
)
template_blocks = blocks_result.scalars().all()
block_logs_result = await db.execute(
select(GenerationLog)
.where(GenerationLog.document_id == document_id, GenerationLog.block_id.isnot(None))
)
block_logs = block_logs_result.scalars().all()
block_log_map: dict[int, dict] = {}
for log in block_logs:
if log.block_id is not None:
block_log_map[log.block_id] = {
"id": log.id,
"block_id": log.block_id,
"status": log.status,
"content": json.loads(log.content) if log.content else {"content": []},
"duration": log.duration,
"error_msg": log.error_msg,
}
payload["template_blocks"] = [
{
"id": b.id,
"sort_index": b.sort_index,
"block_type": b.block_type,
"anchor_start_index": b.anchor_start_index,
"anchor_end_index": b.anchor_end_index,
"html_snippet": b.html_snippet,
"title": b.title,
"edit_mode": b.edit_mode,
"output_format": b.output_format,
"model_id": b.model_id,
"prompt_text": b.prompt_text,
}
for b in template_blocks
]
payload["block_logs"] = block_log_map
payload["has_blocks"] = len(template_blocks) > 0
return Response(data=payload) return Response(data=payload)
@ -630,22 +460,3 @@ async def delete_document(document_id: int, db: AsyncSession = Depends(get_db)):
await db.delete(document) await db.delete(document)
await db.commit() await db.commit()
return Response(data={"id": document_id}) return Response(data={"id": document_id})
@router.delete("/blocks/{block_id}/reset/{document_id}")
async def reset_block(block_id: int, document_id: int, db: AsyncSession = Depends(get_db)):
"""删除块的生成日志,恢复为模板默认内容。"""
block = await db.get(TemplateBlock, block_id)
if block is None:
raise HTTPException(status_code=404, detail="块不存在")
result = await db.execute(
select(GenerationLog)
.where(GenerationLog.document_id == document_id, GenerationLog.block_id == block_id)
)
logs = result.scalars().all()
for log in logs:
await db.delete(log)
await db.commit()
return Response(data={"block_id": block_id, "deleted_logs": len(logs)})

View File

@ -1,5 +1,4 @@
import asyncio import asyncio
import json
import os import os
import tempfile import tempfile
import uuid import uuid
@ -16,11 +15,9 @@ from models.document import Document
from models.generation_log import GenerationLog from models.generation_log import GenerationLog
from models.paragraph import Paragraph from models.paragraph import Paragraph
from models.template import Template from models.template import Template
from models.template_block import TemplateBlock
from schemas.schemas import Response, TemplateSave from schemas.schemas import Response, TemplateSave
from services.minio_client import minio_client from services.minio_client import minio_client
from services.template_parser import parse_template from services.template_parser import parse_template
from services.word_to_html import docx_to_full_html, docx_to_html_blocks
router = APIRouter() router = APIRouter()
@ -67,32 +64,6 @@ def _serialize_template(template: Template) -> dict:
} }
def _serialize_block(block: TemplateBlock) -> dict:
return {
"id": block.id,
"template_id": block.template_id,
"sort_index": block.sort_index,
"block_type": block.block_type,
"anchor_ref": block.anchor_ref,
"anchor_start_index": block.anchor_start_index,
"anchor_end_index": block.anchor_end_index,
"html_snippet": block.html_snippet,
"title": block.title,
"content_json": block.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,
}
@router.get("") @router.get("")
async def list_templates( async def list_templates(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
@ -135,105 +106,6 @@ async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
return Response(data=payload) return Response(data=payload)
@router.get("/{template_id}/preview")
async def get_template_preview(template_id: int, db: AsyncSession = Depends(get_db)):
"""返回模板的 HTML 预览文档及块列表。"""
template = await db.get(Template, template_id)
if template is None:
raise HTTPException(status_code=404, detail="模板不存在")
# 优先从缓存读取 HTML否则重新生成
result = await db.execute(
select(TemplateBlock)
.where(TemplateBlock.template_id == template_id)
.order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
)
blocks = result.scalars().all()
if not blocks:
# 从 MinIO 下载模板文件,重新生成 HTML 和块
file_path = template.file_path or ""
if "/" in file_path:
bucket, object_name = file_path.split("/", 1)
content = await asyncio.to_thread(minio_client.get_object, bucket, object_name)
try:
file_bytes = content.read()
finally:
content.close()
content.release_conn()
with tempfile.NamedTemporaryFile(delete=False, suffix=".docx") as temp_file:
temp_file.write(file_bytes)
temp_path = temp_file.name
try:
rendered_blocks = await asyncio.to_thread(docx_to_html_blocks, temp_path)
html_content = await asyncio.to_thread(docx_to_full_html, temp_path)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
block_rows: list[TemplateBlock] = []
for rb in rendered_blocks:
anchor_title = ""
if rb.block_type == "heading":
anchor_title = rb.text_content
block_row = TemplateBlock(
template_id=template_id,
sort_index=rb.block_index + 1,
block_type=rb.block_type,
anchor_ref=anchor_title,
anchor_start_index=rb.block_index,
anchor_end_index=rb.block_index,
html_snippet=rb.html,
title=rb.text_content[:200] if rb.text_content else "",
content_json=json.dumps({"text": rb.text_content}, ensure_ascii=False),
style_json=rb.style_json,
edit_mode="manual",
)
db.add(block_row)
block_rows.append(block_row)
await db.commit()
for br in block_rows:
await db.refresh(br)
blocks = block_rows
block_list = [_serialize_block(b) for b in blocks]
# 检查是否有缓存的 HTML 快照
html_snapshot = ""
for b in blocks:
if b.html_snippet:
html_snapshot += b.html_snippet + "\n"
if not html_snapshot:
# 重新生成 HTML
file_path = template.file_path or ""
if "/" in file_path:
bucket, object_name = file_path.split("/", 1)
content = await asyncio.to_thread(minio_client.get_object, bucket, object_name)
try:
file_bytes = content.read()
finally:
content.close()
content.release_conn()
with tempfile.NamedTemporaryFile(delete=False, suffix=".docx") as temp_file:
temp_file.write(file_bytes)
temp_path = temp_file.name
try:
html_snapshot = await asyncio.to_thread(docx_to_full_html, temp_path)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
return Response(data={
"template": _serialize_template(template),
"blocks": block_list,
"html": html_snapshot,
"block_count": len(block_list),
})
@router.post("/upload") @router.post("/upload")
async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depends(get_db)): async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
if not file.filename: if not file.filename:
@ -255,8 +127,6 @@ async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depen
try: try:
parsed_items = await asyncio.to_thread(parse_template, temp_path) parsed_items = await asyncio.to_thread(parse_template, temp_path)
rendered_blocks = await asyncio.to_thread(docx_to_html_blocks, temp_path)
html_snapshot = await asyncio.to_thread(docx_to_full_html, temp_path)
finally: finally:
if os.path.exists(temp_path): if os.path.exists(temp_path):
os.remove(temp_path) os.remove(temp_path)
@ -297,37 +167,13 @@ async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depen
db.add(paragraph) db.add(paragraph)
paragraph_rows.append(paragraph) paragraph_rows.append(paragraph)
# 同步生成新版块结构
block_rows: list[TemplateBlock] = []
for rb in rendered_blocks:
anchor_title = rb.text_content if rb.block_type == "heading" else ""
block_row = TemplateBlock(
template_id=template.id,
sort_index=rb.block_index + 1,
block_type=rb.block_type,
anchor_ref=anchor_title,
anchor_start_index=rb.block_index,
anchor_end_index=rb.block_index,
html_snippet=rb.html,
title=rb.text_content[:200] if rb.text_content else "",
content_json=json.dumps({"text": rb.text_content}, ensure_ascii=False),
style_json=rb.style_json,
edit_mode="manual",
)
db.add(block_row)
block_rows.append(block_row)
await db.commit() await db.commit()
await db.refresh(template) await db.refresh(template)
for paragraph in paragraph_rows: for paragraph in paragraph_rows:
await db.refresh(paragraph) await db.refresh(paragraph)
for br in block_rows:
await db.refresh(br)
payload = _serialize_template(template) payload = _serialize_template(template)
payload["paragraphs"] = [_serialize_paragraph(item) for item in paragraph_rows] payload["paragraphs"] = [_serialize_paragraph(item) for item in paragraph_rows]
payload["blocks"] = [_serialize_block(b) for b in block_rows]
payload["html"] = html_snapshot
return Response(data=payload) return Response(data=payload)
@ -384,86 +230,6 @@ async def save_template_paragraphs(
return Response(data={"template_id": template_id, "saved": len(body.paragraphs)}) return Response(data={"template_id": template_id, "saved": len(body.paragraphs)})
@router.put("/{template_id}/blocks")
async def save_template_blocks(
template_id: int,
body: dict,
db: AsyncSession = Depends(get_db),
):
"""保存模板的块配置(可视化编辑器使用)。"""
template = await db.get(Template, template_id)
if template is None:
raise HTTPException(status_code=404, detail="模板不存在")
block_data = body.get("blocks", [])
if not block_data:
raise HTTPException(status_code=400, detail="块数据不能为空")
result = await db.execute(
select(TemplateBlock)
.where(TemplateBlock.template_id == template_id)
)
existing_blocks = result.scalars().all()
block_map = {b.id: b for b in existing_blocks}
incoming_ids = {b.get("id") for b in block_data if b.get("id")}
# 删除被移除的块
for block in existing_blocks:
if block.id not in incoming_ids:
await db.delete(block)
# 新增 / 更新块
for index, config in enumerate(block_data, start=1):
block = block_map.get(config.get("id")) if config.get("id") else None
if block is None:
block = TemplateBlock(template_id=template_id)
db.add(block)
block.sort_index = index
block.block_type = config.get("block_type", block.block_type)
block.anchor_ref = config.get("anchor_ref", block.anchor_ref)
block.anchor_start_index = config.get("anchor_start_index", block.anchor_start_index)
block.anchor_end_index = config.get("anchor_end_index", block.anchor_end_index)
block.title = config.get("title", block.title)
block.content_json = config.get("content_json", block.content_json)
block.style_json = config.get("style_json", block.style_json)
block.edit_mode = config.get("edit_mode", block.edit_mode)
block.model_id = config.get("model_id", block.model_id)
block.need_prompt = config.get("need_prompt", block.need_prompt)
block.prompt_text = config.get("prompt_text", block.prompt_text)
block.need_file = config.get("need_file", block.need_file)
block.file_note = config.get("file_note", block.file_note)
block.output_format = config.get("output_format", block.output_format)
await db.commit()
# 更新每个块的 html_snippet 加入对应的 CSS class
try:
result = await db.execute(
select(TemplateBlock)
.where(TemplateBlock.template_id == template_id)
.order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
)
updated_blocks = result.scalars().all()
for b in updated_blocks:
if b.html_snippet and b.edit_mode == "ai":
b.html_snippet = b.html_snippet.replace(
'data-block-index=',
'class="block-ai" data-block-index=',
1,
)
elif b.html_snippet and b.edit_mode == "manual":
b.html_snippet = b.html_snippet.replace(
'data-block-index=',
'class="block-fixed" data-block-index=',
1,
)
await db.commit()
except Exception:
pass
return Response(data={"template_id": template_id, "saved": len(block_data)})
@router.delete("/{template_id}") @router.delete("/{template_id}")
async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)): async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)):
template = await db.get(Template, template_id) template = await db.get(Template, template_id)
@ -474,9 +240,6 @@ async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)):
paragraphs_to_delete = result.scalars().all() paragraphs_to_delete = result.scalars().all()
paragraph_ids = [p.id for p in paragraphs_to_delete] paragraph_ids = [p.id for p in paragraphs_to_delete]
blocks_result = await db.execute(select(TemplateBlock).where(TemplateBlock.template_id == template_id))
blocks_to_delete = blocks_result.scalars().all()
doc_result = await db.execute(select(Document).where(Document.template_id == template_id)) doc_result = await db.execute(select(Document).where(Document.template_id == template_id))
documents_to_delete = doc_result.scalars().all() documents_to_delete = doc_result.scalars().all()
@ -493,9 +256,6 @@ async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)):
for paragraph in paragraphs_to_delete: for paragraph in paragraphs_to_delete:
await db.delete(paragraph) await db.delete(paragraph)
for block in blocks_to_delete:
await db.delete(block)
file_path = template.file_path or "" file_path = template.file_path or ""
if "/" in file_path: if "/" in file_path:
bucket, object_name = file_path.split("/", 1) bucket, object_name = file_path.split("/", 1)

View File

@ -388,111 +388,3 @@ def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
output = BytesIO() output = BytesIO()
document.save(output) document.save(output)
return output.getvalue() return output.getvalue()
def export_document_bytes_from_blocks(template_bytes: bytes, blocks: list[dict], logs: list[dict]) -> bytes:
"""基于块结构的文档导出,支持块排序。
blocks: TemplateBlock 序列化列表每个包含 anchor_start_indexedit_modesort_index
logs: GenerationLog 序列化列表每个包含 block_idcontentstatus
"""
document = Document(BytesIO(template_bytes))
# 构建 block_id → log 映射
log_by_block_id: dict[int, dict] = {}
for log in logs:
block_id = log.get("block_id")
if block_id is not None:
log_by_block_id[block_id] = log
# 构建 block 映射
block_by_start_index: dict[int, dict] = {}
for block in blocks:
start_idx = block.get("anchor_start_index")
if start_idx is not None:
block_by_start_index[start_idx] = block
# 按 sort_index 排序的块列表
sorted_blocks = sorted(
[b for b in blocks if b.get("anchor_start_index") is not None],
key=lambda b: b.get("sort_index", 0),
)
# 枚举所有文档元素
all_items: list = list(_iter_block_items(document))
# 先对所有元素进行内容替换(在重排之前)
for index, item in enumerate(all_items):
block = block_by_start_index.get(index)
if block is None:
continue
if block.get("edit_mode") == "manual":
continue
log = log_by_block_id.get(block.get("id"))
if log is None or log.get("status") != "success":
continue
content = log.get("content") or {"content": []}
if isinstance(content, str):
import json
try:
content = json.loads(content)
except Exception:
content = {"content": [{"type": "text", "text": content}]}
if isinstance(item, Paragraph):
text_parts: list[str] = []
for c in content.get("content", []):
if c.get("type") == "text":
text_parts.append(c.get("text", ""))
new_text = "\n".join(text_parts)
if new_text:
_set_paragraph_text(item, new_text, template_paragraph=item)
# ---- 重排文档元素 ----
# 收集所有元素的 XML element按块归类
body = document.element.body
# 建立 index → element 映射
index_to_element: dict[int, any] = {}
for index, item in enumerate(all_items):
index_to_element[index] = item._element
# 记录哪些索引已被块覆盖
covered_indices: set[int] = set()
for block in blocks:
start_idx = block.get("anchor_start_index")
end_idx = block.get("anchor_end_index", start_idx)
if start_idx is not None:
for idx in range(start_idx, (end_idx or start_idx) + 1):
if idx < len(all_items):
covered_indices.add(idx)
# 未被任何块覆盖的元素gap保持原顺序
gap_elements: list = []
for index, item in enumerate(all_items):
if index not in covered_indices:
gap_elements.append(item._element)
# 块元素:按 sort_index 排序后收集
block_elements: list = []
for block in sorted_blocks:
start_idx = block.get("anchor_start_index")
end_idx = block.get("anchor_end_index", start_idx)
if start_idx is not None:
for idx in range(start_idx, (end_idx or start_idx) + 1):
if idx < len(all_items) and idx in covered_indices:
block_elements.append(index_to_element[idx])
# 移除所有元素
for element in list(body):
body.remove(element)
# 按新顺序重新添加:块元素(按 sort_index→ 间隙元素(保持原顺序)
for element in block_elements:
body.append(element)
for element in gap_elements:
body.append(element)
output = BytesIO()
document.save(output)
return output.getvalue()

View File

@ -11,7 +11,6 @@ from models.document import Document
from models.generation_log import GenerationLog from models.generation_log import GenerationLog
from models.paragraph import Paragraph from models.paragraph import Paragraph
from models.template import Template from models.template import Template
from models.template_block import TemplateBlock
from services.ai_service import call_ai from services.ai_service import call_ai
from services.file_summary import summarize_minio_files from services.file_summary import summarize_minio_files
@ -196,160 +195,3 @@ async def run_generation(document_id: int, template_id: int):
update_progress(document_id, status="failed", message=str(error), done=done_count, total=total) update_progress(document_id, status="failed", message=str(error), done=done_count, total=total)
finally: finally:
generation_cancel_flags.pop(document_id, None) generation_cancel_flags.pop(document_id, None)
def build_mock_content_for_block(block: TemplateBlock) -> dict:
"""为块生成模拟内容。"""
if block.output_format == "table":
return {
"content": [
{
"type": "table",
"title": block.title,
"headers": ["字段", "内容"],
"rows": [
["块标题", block.title],
["块类型", block.block_type],
],
}
]
}
return {
"content": [
{"type": "text", "text": f"这是“{block.title or block.block_type}”的示例生成内容。"}
]
}
async def run_generation_for_blocks(document_id: int, template_id: int):
"""基于块结构的 AI 生成任务。"""
async with async_session() as db:
document = await db.get(Document, document_id)
template = await db.get(Template, template_id)
if document is None or template is None:
update_progress(document_id, status="failed", message="生成任务初始化失败")
return
result = await db.execute(
select(TemplateBlock)
.where(TemplateBlock.template_id == template_id)
.order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc())
)
blocks = result.scalars().all()
total = len(blocks)
ai_blocks = [b for b in blocks if b.edit_mode == "ai"]
total_ai = len(ai_blocks)
update_progress(document_id, status="generating", total=total_ai, done=0, percent=0, message="开始块级生成...")
done_count = 0
failed_count = 0
# 收集固定块内容作为上下文
fixed_context_parts: list[str] = []
for b in blocks:
if b.edit_mode == "manual" and b.title:
fixed_context_parts.append(f"[{b.block_type}] {b.title}")
try:
for block in blocks:
if is_cancel_requested(document_id):
document.status = "cancelled"
document.error = "用户已取消生成"
await db.commit()
update_progress(document_id, status="cancelled", message="已取消生成", done=done_count)
return
if block.edit_mode == "manual":
# 固定块:使用现有内容
existing = json.loads(block.content_json or "{}")
content = {"content": [{"type": "text", "text": existing.get("text", block.title or "")}]}
status = "success"
duration = 0
error_message = ""
model_id = block.model_id
else:
# AI 块:调用模型生成
start = time.perf_counter()
model = None
if block.model_id:
model = await db.get(AiModel, block.model_id)
if model is None or model.status != "enabled":
# 尝试使用默认启用的模型
result_enabled = await db.execute(
select(AiModel).where(AiModel.status == "enabled").order_by(AiModel.id.asc()).limit(1)
)
model = result_enabled.scalars().first()
model_id = model.id if model is not None else block.model_id
try:
if model is None:
content = build_mock_content_for_block(block)
else:
# 构造临时 paragraph 对象用于兼容 call_ai
temp_para = Paragraph(
template_id=template_id,
sort_index=block.sort_index,
title=block.title,
content="\n".join(fixed_context_parts),
prompt_text=block.prompt_text or "",
output_format=block.output_format,
need_prompt=block.need_prompt,
edit_mode=block.edit_mode,
)
setattr(temp_para, "enable_reasoning", bool(model.enable_reasoning))
result_data = await call_ai(temp_para, model, None)
content = result_data.content
status = "success"
error_message = ""
except Exception as error:
content = build_mock_content_for_block(block)
status = "failed"
error_message = str(error)
failed_count += 1
duration = round(time.perf_counter() - start, 4)
done_count += 1
log = GenerationLog(
document_id=document.id,
block_id=block.id,
model_id=model_id,
status=status,
content=json.dumps(content, ensure_ascii=False),
duration=duration,
error_msg=error_message,
)
db.add(log)
document.para_count_done = done_count
percent = int(done_count / max(total_ai, 1) * 100)
update_progress(
document_id,
status="generating",
percent=percent,
done=done_count,
total=total_ai,
current_paragraph=block.title,
message=f"正在生成:{block.title or block.block_type}",
)
await db.commit()
document.status = "completed" if failed_count == 0 else "failed"
document.error = "" if failed_count == 0 else f"{failed_count} 个 AI 块生成失败。"
document.file_path = f"mock://document/{document.id}"
document.updated_at = datetime.now()
await db.commit()
update_progress(
document_id,
status=document.status,
percent=100,
done=done_count,
total=total_ai,
message="块级生成完成" if failed_count == 0 else document.error,
)
except Exception as error:
document.status = "failed"
document.error = str(error)
await db.commit()
update_progress(document_id, status="failed", message=str(error), done=done_count, total=total_ai)
finally:
generation_cancel_flags.pop(document_id, None)

View File

@ -1,673 +0,0 @@
"""将 docx 文件转为保留样式的 HTML每个元素标记 data-block-index 用于框选定位。"""
import base64
import html as html_module
import io
import json
import re
from dataclasses import dataclass
from typing import Union
from docx import Document
from docx.document import Document as DocumentObject
from docx.image.exceptions import UnrecognizedImageError
from docx.opc.constants import RELATIONSHIP_TYPE as RT
from docx.oxml.ns import qn
from docx.oxml.table import CT_Tbl
from docx.oxml.text.paragraph import CT_P
from docx.table import Table
from docx.text.paragraph import Paragraph
@dataclass
class RenderedBlock:
"""单个渲染块 —— 对应模板中的一个 Block。"""
block_index: int
block_type: str # heading / text / table / image
html: str # 该块的 HTML 片段
text_content: str # 纯文本(用于块列表展示)
style_json: str # 样式快照 JSON
table_json: str # 表格结构 JSON仅 table 类型有值)
# ---------------------------------------------------------------------------
# 工具函数
# ---------------------------------------------------------------------------
def _escape(text: str) -> str:
return html_module.escape(text)
def _safe_pt(value) -> float | None:
if value is None:
return None
try:
return round(float(value.pt), 2)
except AttributeError:
return None
def _safe_indent(value) -> float | None:
if value is None:
return None
try:
return round(float(value.pt), 2)
except AttributeError:
return None
def _heading_level(style_name: str) -> int | None:
if not style_name:
return None
normalized = style_name.lower().replace(" ", "")
if normalized.startswith("heading"):
level = normalized.replace("heading", "")
if level.isdigit():
return int(level)
return None
def _get_list_info(paragraph: Paragraph) -> tuple[bool, int, str | None]:
"""检测段落是否为列表项,返回 (is_list, level, list_type)。
list_type: 'ordered' / 'unordered' / None
level: 缩进层级0-based
"""
pPr = paragraph._element.pPr
if pPr is None:
return False, 0, None
numPr = pPr.find(qn("w:numPr"))
if numPr is None:
return False, 0, None
ilvl_elem = numPr.find(qn("w:ilvl"))
numId_elem = numPr.find(qn("w:numId"))
if numId_elem is None:
return False, 0, None
level = int(ilvl_elem.get(qn("w:val"))) if ilvl_elem is not None else 0
# 尝试从文档 numbering 部分获取列表类型
list_type = "unordered"
try:
num_id_val = numId_elem.get(qn("w:numId"))
if num_id_val is not None:
document = paragraph._parent
# 查找 numbering 定义
numbering_part = None
if hasattr(document, 'part'):
numbering_part = document.part.numbering_part
elif hasattr(paragraph._parent, 'part'):
numbering_part = paragraph._parent.part.numbering_part
if numbering_part is not None:
numbering_xml = numbering_part._element
# 查找 numId 对应的 abstractNumId
num_elem = numbering_xml.find(f'.//w:num[@w:numId="{num_id_val}"]',
{'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'})
if num_elem is not None:
abstract_num_ref = num_elem.find(qn("w:abstractNumId"))
if abstract_num_ref is not None:
abstract_num_id = abstract_num_ref.get(qn("w:val"))
# 查找 abstractNum 的 lvl 定义
for lvl_elem in numbering_xml.findall(
f'.//w:abstractNum[@w:abstractNumId="{abstract_num_id}"]/w:lvl',
{'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
):
lvl_ilvl = lvl_elem.get(qn("w:ilvl"))
if lvl_ilvl is not None and int(lvl_ilvl) == level:
num_fmt = lvl_elem.find(qn("w:numFmt"))
if num_fmt is not None:
fmt_val = num_fmt.get(qn("w:val"))
if fmt_val == "bullet":
list_type = "unordered"
else:
list_type = "ordered"
break
except Exception:
pass
return True, level, list_type
def _alignment_css(paragraph: Paragraph) -> str:
from docx.enum.text import WD_ALIGN_PARAGRAPH
mapping = {
WD_ALIGN_PARAGRAPH.CENTER: "center",
WD_ALIGN_PARAGRAPH.RIGHT: "right",
WD_ALIGN_PARAGRAPH.JUSTIFY: "justify",
}
return mapping.get(paragraph.alignment, "left")
def _get_run_style(run) -> dict:
"""提取 run 的字体样式。"""
font = run.font
color = "#000000"
if font.color is not None and font.color.rgb is not None:
color = f"#{font.color.rgb}"
size = _safe_pt(font.size)
return {
"name": font.name,
"size": size,
"bold": bool(font.bold) if font.bold is not None else False,
"italic": bool(font.italic) if font.italic is not None else False,
"underline": bool(font.underline) if font.underline is not None else False,
"color": color,
}
def _run_style_inline(run) -> str:
"""将 run 的字体样式转为 CSS inline。"""
s = _get_run_style(run)
css = ""
if s["name"]:
css += f"font-family:'{s['name']}',sans-serif;"
if s["size"]:
css += f"font-size:{s['size']}pt;"
if s["bold"]:
css += "font-weight:bold;"
if s["italic"]:
css += "font-style:italic;"
if s["underline"]:
css += "text-decoration:underline;"
if s["color"] and s["color"] != "#000000":
css += f"color:{s['color']};"
return css
def _para_style_inline(paragraph: Paragraph) -> str:
"""段落级别样式(对齐、间距、缩进)。"""
fmt = paragraph.paragraph_format
css = ""
css += f"text-align:{_alignment_css(paragraph)};"
before = _safe_pt(fmt.space_before)
after = _safe_pt(fmt.space_after)
if before:
css += f"margin-top:{before}pt;"
if after:
css += f"margin-bottom:{after}pt;"
line_spacing = fmt.line_spacing
if line_spacing is not None:
if isinstance(line_spacing, float):
css += f"line-height:{line_spacing};"
else:
try:
css += f"line-height:{round(float(line_spacing.pt), 2)}pt;"
except AttributeError:
pass
indent = _safe_indent(fmt.first_line_indent)
if indent:
css += f"text-indent:{indent}pt;"
return css
def _capture_style_snapshot(paragraph: Paragraph, level: int = 0) -> dict:
"""段落样式快照(用于后续导出时还原)。"""
fmt = paragraph.paragraph_format
first_run_style = None
for run in paragraph.runs:
if run.text.strip():
first_run_style = _get_run_style(run)
break
return {
"font": first_run_style or {},
"paragraph": {
"alignment": _alignment_css(paragraph),
"spaceBefore": _safe_pt(fmt.space_before),
"spaceAfter": _safe_pt(fmt.space_after),
"lineSpacing": fmt.line_spacing,
"firstLineIndent": _safe_indent(fmt.first_line_indent),
},
"headingLevel": level,
}
# ---------------------------------------------------------------------------
# 图片提取
# ---------------------------------------------------------------------------
def _extract_images_from_docx(file_path: str) -> dict[str, str]:
"""提取 docx 中的所有图片,返回 {rId: base64_data_uri} 映射。"""
document = Document(file_path)
images: dict[str, str] = {}
for rel in document.part.rels.values():
if "image" in rel.reltype:
try:
image_bytes = rel.target_part.blob
ext = rel.target_part.partname.split(".")[-1].lower()
mime_map = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
"gif": "image/gif", "bmp": "image/bmp", "webp": "image/webp", "svg": "image/svg+xml"}
mime = mime_map.get(ext, "image/png")
data_uri = f"data:{mime};base64,{base64.b64encode(image_bytes).decode('ascii')}"
images[rel.rId] = data_uri
except Exception:
continue
return images
def _paragraph_contains_image(paragraph: Paragraph) -> bool:
"""检查段落是否包含内嵌图片。"""
drawings = paragraph._element.findall(".//" + qn("w:drawing"))
return len(drawings) > 0
def _render_image_html(paragraph: Paragraph, images: dict[str, str], block_index: int) -> str:
"""渲染段落中的图片为 HTML img 标签。"""
nsmap = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
"pic": "http://schemas.openxmlformats.org/drawingml/2006/picture",
}
parts: list[str] = []
drawings = paragraph._element.findall(".//" + qn("w:drawing"))
for drawing in drawings:
blip = drawing.find(".//" + qn("a:blip"), nsmap)
if blip is None:
continue
r_embed = blip.get(qn("r:embed"))
if r_embed and r_embed in images:
src = images[r_embed]
# 尝试获取图片尺寸
ext = drawing.find(".//" + qn("wp:extent"), nsmap)
cx = ext.get("cx") if ext is not None else None
cy = ext.get("cy") if ext is not None else None
style = "max-width:100%;height:auto;"
if cx and cy:
w = int(cx) // 9525
h = int(cy) // 9525
style = f"width:{w}px;height:{h}px;"
parts.append(f'<img src="{src}" style="{style}" data-block-index="{block_index}" alt="文档图片" />')
return "\n".join(parts)
# ---------------------------------------------------------------------------
# 表格渲染
# ---------------------------------------------------------------------------
def _parse_border(border_element) -> dict | None:
"""解析单个 CT_Border 元素。"""
if border_element is None:
return None
color = border_element.get(qn("w:color")) or "000000"
sz = border_element.get(qn("w:sz")) or "4"
val = border_element.get(qn("w:val")) or "single"
if val == "nil" or val == "none":
return None
try:
width_px = max(1, int(sz) // 8)
except ValueError:
width_px = 1
return {"color": f"#{color}", "width": f"{width_px}px", "style": val}
def _table_border_css(table: Table) -> str:
"""提取表格整体的边框样式。"""
tbl_pr = table._tbl.tblPr
if tbl_pr is None:
return "border-collapse:collapse;"
borders = tbl_pr.find(qn("w:tblBorders"))
if borders is None:
return "border-collapse:collapse;"
top = _parse_border(borders.find(qn("w:top")))
left = _parse_border(borders.find(qn("w:left")))
bottom = _parse_border(borders.find(qn("w:bottom")))
right = _parse_border(borders.find(qn("w:right")))
inside_h = _parse_border(borders.find(qn("w:insideH")))
inside_v = _parse_border(borders.find(qn("w:insideV")))
rules: list[str] = ["border-collapse:collapse;"]
border_color = "#000000"
border_width = "1px"
if top:
border_color = top["color"]
border_width = top["width"]
elif left:
border_color = left["color"]
border_width = left["width"]
rules.append(f"border:{border_width} solid {border_color};")
rules.append(f"th,td{{border:{border_width} solid {border_color};}}")
return "".join(rules)
def _cell_shading(cell) -> str:
"""提取单元格底纹颜色。"""
tc_pr = cell._tc.tcPr
if tc_pr is None:
return ""
shading = tc_pr.find(qn("w:shd"))
if shading is None:
return ""
fill = shading.get(qn("w:fill"))
if fill and fill != "auto":
return f"background-color:#{fill};"
return ""
def _cell_width(cell) -> str:
"""提取单元格宽度。"""
tc_pr = cell._tc.tcPr
if tc_pr is None:
return ""
tc_w = tc_pr.find(qn("w:tcW"))
if tc_w is None:
return ""
w = tc_w.get(qn("w:w"))
if w:
return f"width:{w};"
return ""
def _cell_v_align(cell) -> str:
"""提取单元格垂直对齐。"""
tc_pr = cell._tc.tcPr
if tc_pr is None:
return ""
v_align = tc_pr.find(qn("w:vAlign"))
if v_align is None:
return ""
val = v_align.get(qn("w:val"))
if val:
return f"vertical-align:{val};"
return ""
def _render_cell_html(cell, is_header: bool = False) -> str:
"""渲染单个单元格为 HTML。"""
tag = "th" if is_header else "td"
colspan = 1
tc_pr = cell._tc.tcPr
if tc_pr is not None and tc_pr.gridSpan is not None:
colspan = tc_pr.gridSpan.val
content_parts: list[str] = []
for paragraph in cell.paragraphs:
para_css = _para_style_inline(paragraph)
inner_spans: list[str] = []
for run in paragraph.runs:
text = _escape(run.text)
if not text:
continue
run_css = _run_style_inline(run)
inner_spans.append(f'<span style="{run_css}">{text}</span>')
inner_html = "".join(inner_spans) if inner_spans else "&nbsp;"
content_parts.append(f'<div style="{para_css};margin:0;">{inner_html}</div>')
cell_html = "".join(content_parts) if content_parts else "&nbsp;"
style_parts = [
"padding:4px 6px;",
_cell_shading(cell),
_cell_v_align(cell),
]
style = "".join(style_parts)
attrs = f' style="{style}"'
if colspan > 1:
attrs += f" colspan=\"{colspan}\""
return f"<{tag}{attrs}>{cell_html}</{tag}>"
def _render_table_html(table: Table) -> str:
"""将 Word 表格渲染为 HTML table。"""
border_css = _table_border_css(table)
rows_html: list[str] = []
for row in table.rows:
cells_html = "".join(_render_cell_html(cell) for cell in row.cells)
rows_html.append(f"<tr>{cells_html}</tr>")
body = "\n".join(rows_html)
return f'<table style="width:100%;{border_css}">{body}</table>'
def _extract_table_structure(table: Table) -> dict:
"""提取表格结构化数据(用于后续 AI 生成参考)。"""
rows = len(table.rows)
cols = max((len(row.cells) for row in table.rows), default=0)
data: list[list[str]] = []
for row in table.rows:
row_data: list[str] = []
for cell in row.cells:
text = "\n".join(p.text.strip() for p in cell.paragraphs if p.text.strip())
row_data.append(text)
data.append(row_data)
return {"rows": rows, "cols": cols, "data": data}
# ---------------------------------------------------------------------------
# 迭代文档元素
# ---------------------------------------------------------------------------
def _iter_block_items(document: DocumentObject):
"""按文档顺序迭代段落和表格。"""
body = document.element.body
for child in body.iterchildren():
if isinstance(child, CT_P):
yield Paragraph(child, document)
elif isinstance(child, CT_Tbl):
yield Table(child, document)
# ---------------------------------------------------------------------------
# 主转换函数
# ---------------------------------------------------------------------------
def docx_to_html_blocks(file_path: str) -> list[RenderedBlock]:
"""将 docx 文件转为 RenderedBlock 列表。
每个块对应文档中的一个段落或表格带有完整的 CSS 内联样式
"""
document = Document(file_path)
images = _extract_images_from_docx(file_path)
blocks: list[RenderedBlock] = []
block_index = 0
for item in _iter_block_items(document):
if isinstance(item, Paragraph):
text = item.text.strip()
# 处理包含图片的段落
if _paragraph_contains_image(item):
img_html = _render_image_html(item, images, block_index)
if img_html:
blocks.append(RenderedBlock(
block_index=block_index,
block_type="image",
html=f'<div class="block-image" data-block-index="{block_index}" '
f'style="margin:6pt 0;text-align:center;">{img_html}</div>',
text_content="[图片]",
style_json="{}",
table_json="{}",
))
block_index += 1
# 如果图片段落中也有文字,继续处理文字
if not text:
continue
if not text:
# 空段落 —— 渲染为换行
blocks.append(RenderedBlock(
block_index=block_index,
block_type="text",
html='<div class="block-empty" data-block-index="{}" '
'style="height:12pt;"></div>'.format(block_index),
text_content="",
style_json=json.dumps(_capture_style_snapshot(item), ensure_ascii=False),
table_json="{}",
))
block_index += 1
continue
level = _heading_level(item.style.name if item.style is not None else "")
if level is not None:
# 标题块
para_css = _para_style_inline(item)
inner_spans: list[str] = []
for run in item.runs:
t = _escape(run.text)
if not t:
continue
run_css = _run_style_inline(run)
inner_spans.append(f'<span style="{run_css}">{t}</span>')
inner = "".join(inner_spans) or _escape(text)
block_type = "heading"
html_str = (
f'<h{level} class="block-heading" data-block-index="{block_index}" '
f'style="{para_css}margin:12pt 0 6pt 0;">{inner}</h{level}>'
)
else:
# 检测列表项
is_list, list_level, list_type = _get_list_info(item)
if is_list:
para_css = _para_style_inline(item)
inner_spans: list[str] = []
for run in item.runs:
t = _escape(run.text)
if not t:
continue
run_css = _run_style_inline(run)
inner_spans.append(f'<span style="{run_css}">{t}</span>')
inner = "".join(inner_spans) or _escape(text)
block_type = "list_item"
left_margin = 24 + list_level * 24 # 每级缩进 24px
list_style = f"padding-left:{left_margin}px;"
marker_class = "list-ordered" if list_type == "ordered" else "list-unordered"
html_str = (
f'<div class="block-list-item {marker_class}" data-block-index="{block_index}" '
f'data-list-level="{list_level}" data-list-type="{list_type}" '
f'style="{para_css}{list_style}margin:2pt 0;">{inner}</div>'
)
else:
# 普通文本块
para_css = _para_style_inline(item)
inner_spans: list[str] = []
for run in item.runs:
t = _escape(run.text)
if not t:
continue
run_css = _run_style_inline(run)
inner_spans.append(f'<span style="{run_css}">{t}</span>')
inner = "".join(inner_spans) or _escape(text)
block_type = "text"
html_str = (
f'<p class="block-text" data-block-index="{block_index}" '
f'style="{para_css}margin:0;">{inner}</p>'
)
blocks.append(RenderedBlock(
block_index=block_index,
block_type=block_type,
html=html_str,
text_content=text,
style_json=json.dumps(_capture_style_snapshot(item, level or 0), ensure_ascii=False),
table_json="{}",
))
block_index += 1
elif isinstance(item, Table):
table_html = _render_table_html(item)
table_structure = _extract_table_structure(item)
# 获取表格中第一个非空文本作为描述
first_texts: list[str] = []
for row in item.rows:
for cell in row.cells:
txt = cell.text.strip()
if txt:
first_texts.append(txt)
break
if first_texts:
break
text_content = " | ".join(first_texts[:3]) if first_texts else f"表格 {table_structure['rows']}{table_structure['cols']}"
html_str = (
f'<div class="block-table" data-block-index="{block_index}" '
f'style="margin:6pt 0;">{table_html}</div>'
)
blocks.append(RenderedBlock(
block_index=block_index,
block_type="table",
html=html_str,
text_content=text_content,
style_json="{}",
table_json=json.dumps(table_structure, ensure_ascii=False),
))
block_index += 1
return blocks
def docx_to_full_html(file_path: str) -> str:
"""将 docx 文件转为完整的独立 HTML 文档(可直接在 iframe 中渲染)。"""
blocks = docx_to_html_blocks(file_path)
body_html = "\n".join(block.html for block in blocks)
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
body {{
font-family: 'SimSun', 'Microsoft YaHei', 'PingFang SC', sans-serif;
font-size: 12pt;
color: #1a1d24;
line-height: 1.8;
padding: 40px 60px;
max-width: 794px;
margin: 0 auto;
background: #fff;
}}
.block-heading {{ font-weight: 600; }}
.block-text {{ }}
.block-table {{ width: 100%; overflow-x: auto; }}
.block-table table {{ width: 100%; border-collapse: collapse; }}
.block-table th, .block-table td {{ padding: 4px 6px; text-align: left; }}
.block-empty {{ }}
.block-list-item {{ }}
.block-list-item.list-unordered::before {{
content: "\\2022";
display: inline-block;
width: 1em;
margin-left: -1em;
color: #333;
}}
.block-list-item.list-ordered {{ }}
[data-block-index]:hover {{
outline: 1px dashed #5b5bd6;
outline-offset: 2px;
}}
[data-block-index].block-selected {{
outline: 2px solid #5b5bd6;
outline-offset: 2px;
background-color: rgba(91,91,214,0.06);
}}
[data-block-index].block-ai {{
outline: 2px solid rgba(91,91,214,0.5);
outline-offset: 2px;
background-color: rgba(91,91,214,0.08);
}}
[data-block-index].block-fixed {{
outline: 2px solid rgba(154,161,173,0.5);
outline-offset: 2px;
background-color: rgba(154,161,173,0.04);
}}
[data-block-index].block-variable {{
outline: 2px solid rgba(26,140,74,0.5);
outline-offset: 2px;
background-color: rgba(26,140,74,0.06);
}}
</style>
</head>
<body>
{body_html}
</body>
</html>"""
def docx_to_html_body(file_path: str) -> str:
"""仅返回 body 内的 HTML 片段(不含 head/style 包裹)。"""
blocks = docx_to_html_blocks(file_path)
return "\n".join(block.html for block in blocks)

View File

@ -1,19 +0,0 @@
# 任务执行摘要
## 会话 ID: 2
- [2026-07-06 00:22:56]
- **执行原因**: 继续实施可视化区块编辑器重构任务拆解清单中的剩余项目
- **执行过程**:
1. 完成 GeneratePage.vue 块模式适配fullV2 API 方法 + TypeScript 修复)。
2. 重构 PreviewEdit.vue 支持段落/块双模式:块模式使用 DocRenderer 渲染 HTML 快照、块列表显示状态、单块重新生成、恢复默认内容。
3. 新增后端 regenerateBlock APIPOST /generate/blocks/{id}/regenerate用于单块 AI 重新生成。
4. 新增后端 resetBlock APIDELETE /generate/blocks/{id}/reset/{docId})用于恢复块为模板默认。
5. 增强 GET /documents/{id} 接口返回块级数据template_blocks + block_logs
6. 实现列表项渲染(检测 Word w:numPr支持 ordered/unorderedCSS ::before 标记)。
7. 实现块保存时 html_snippet 快照同步(注入 block-ai/block-fixed CSS class
8. 在 TemplateEditor 中实现块拖拽排序dragstart/dragover/drop 事件,排序后自动保存)。
- **执行结果**:
- 前后端 TypeScript/Python 编译均通过。
- 任务清单中完成项:生成页块模式、预览页块模式升级、列表渲染、快照同步、拖拽排序、单块重生成、恢复默认。
- 更新了任务拆解清单文档,标记 10+ 个已完成项。
- 剩余主要未完成项:图片 MinIO 上传、页眉页脚、树形块结构、变量块、Semaphore 限流、文件块级绑定、导出验证、历史页增强。

View File

@ -1,188 +0,0 @@
# AI 文档模板生成系统 · 可视化区块编辑器重构任务拆解清单
基线版本:当前 `master` 分支
目标:将系统从"按 Heading 解析段落"升级为"Word 富文本渲染 + 可视化框选区块 + 精确写回"的文档生成系统,类似腾讯文档的交互体验。
总工期估算6-8 周(两人并行:前端 + 后端)
## 第一阶段Word → HTML 渲染引擎(第 1-2 周)
### 后端Word 转 HTML 服务5-7 天)
- [x] 新建 `services/word_to_html.py`,将 docx 转为保留样式的 HTML
- [x] 段落渲染字体font-family / font-size / color / bold / italic、对齐、行距、段前段后间距、首行缩进
- [x] 表格渲染:完整保留边框样式(内外边框、线宽、颜色)、单元格背景色/底纹、合并单元格rowspan/colspan、列宽
- [ ] 图片处理:提取 docx 内嵌图片,上传到 MinIO 并生成 URL
- [x] 列表项渲染:有序/无序列表及缩进层级(检测 w:numPr支持 bullet/orderedCSS ::before 标记)
- [ ] 页眉页脚渲染(可选,标记为 `data-region="header"` / `data-region="footer"`
- [x] 为每个渲染元素标记 `data-block-index`(对应原始 docx XML 元素序号),作为后续写回锚点
- [x] 输出完整的独立 HTML 文档字符串(内联样式,可直接在 iframe 中渲染)
### 后端:解析器升级 — 块级结构输出3-4 天)
- [x] 新增 `parse_template_v2()` 函数,输出 `list[TemplateBlock]` 而非 `list[ParsedParagraph]`
- [x] 每个块记录:`block_index`XML 元素序号)、`block_type`heading/text/table/image、`html_snippet`、`style_json`
- [x] 保留旧 `parse_template()` 兼容现有流程
- [x] 上传模板时同时生成 HTML 快照并存入 MinIO或数据库 TEXT 字段)
### 前端文档渲染组件3-4 天)
- [x] 新建 `components/DocRenderer.vue`,用 iframe 渲染后端返回的 HTML
- [x] 适配 A4 纸张宽度794px支持缩放
- [x] 鼠标 hover 时高亮当前 block通过 `data-block-index` 定位)
- [x] 支持文本选择(保留浏览器原生 Selection API
## 第二阶段:可视化框选与区块定义(第 2-3 周)
### 前端框选交互层4-5 天)
- [x] 在 DocRenderer 上实现拖拽框选交互mousedown → mousemove → mouseup
- [x] 绘制半透明选区矩形overlay div
- [x] 计算选区覆盖的 `data-block-index` 范围start → end
- [x] 选区边界自动吸附到完整块边界IntersectionRect 检测 + 按 data-block-index 全块选中)
- [x] 选中后弹出浮动工具栏:设为 AI 块 / 设为固定块 / 取消选择
- [x] 已定义的块在文档上显示彩色边框 + 标签AI 块紫色 / 固定块灰色 / 变量块绿色)
- [x] 支持多选区Ctrl+框选 追加选区)
- [x] 支持点击已有块查看/编辑配置
### 前端左右面板联动升级3-4 天)
- [ ] 左侧块列表改为树形结构(支持父子嵌套)
- [x] 块列表项显示:块类型图标 + 标题 + 所属区域标签
- [x] 点击块列表 ↔ 画布中块高亮滚动联动
- [x] 右侧配置面板根据 `block_type` 动态切换表单项:
- AI 块:模型选择 / 提示词 / 输出格式 / 是否需要参考文件
- 固定块:只读信息 / 转为 AI 块按钮
- 变量块:变量名 / 默认值 / 必填标记
- [x] 拖拽排序dragstart/dragover/drop 事件,排序后自动保存块配置)
### 前端编辑模式切换2-3 天)
- [x] 顶部工具栏新增模式切换:「预览模式」「框选模式」「编辑模式」
- [x] 预览模式:只读查看文档渲染效果
- [x] 框选模式:可拖拽选择区域定义为块(默认模式)
- [ ] 编辑模式后续迭代contenteditable 直接修改文档内容
## 第三阶段:块级数据持久化与模板保存(第 3-4 周)
### 后端:块存储 API2-3 天)
- [x] 新建/修改 `routers/templates.py` 中的块保存接口 `PUT /api/v1/templates/{id}/blocks`
- [x] 接口接收 `list[TemplateBlock]`,批量 upsert新增/更新/删除)
- [x] 旧 `paragraphs` 接口保留兼容,内部转换到新块结构
- [x] 模板详情返回时优先返回 blocksfallback 到 paragraphs
### 后端模板快照管理2-3 天)
- [x] 上传模板时生成 HTML 快照存入 `template_snapshots` 表或 MinIO
- [x] 每次保存块配置时同步更新快照中的块标记html_snippet 注入 block-ai/block-fixed CSS class
- [x] 加载模板时直接返回带块标记的 HTML前端无需二次计算
### 数据库增量变更1-2 天)
- [x] 确认 `template_blocks` 表字段完整性(已有,需检查)
- [x] 如需新增字段:`anchor_start_index`、`anchor_end_index`、`html_snippet`
- [x] 新增 `template_snapshots`HTML 快照改为存储在 `template_blocks.html_snippet` 字段(无需独立表)
- [x] 输出增量 SQL 脚本(已在 database.py 中通过 init_db 自动迁移)
## 第四阶段:生成链路适配(第 4-5 周)
### 后端:块级 AI 生成调度4-5 天)
- [x] 重构 `generation_runtime.py`:生成单元从 Paragraph 切换为 TemplateBlock
- [x] 只对 `edit_mode='ai'` 的块调用 AI
- [x] 固定块的内容直接作为上下文传递给相邻 AI 块
- [ ] 变量块按变量值直接替换,不经过 AI
- [ ] 并发控制:按模板全局 Semaphore 限流
- [x] SSE 进度推送单位改为块(`done/total` 按块计数)
### 后端AI 提示词上下文增强2-3 天)
- [x] 构建 AI 请求时,自动附带同一锚点下相邻固定块的内容作为上下文
- [ ] 文件附件绑定改为块级别(`block_id` 替代 `paragraph_id`
- [ ] 文件摘要按块关联传入 AI
### 前端生成页适配2-3 天)
- [x] 生成页支持段落/块模式切换RadioButton 切换 genMode
- [x] 块模式显示 AI 块列表(替代段落文件配置)
- [x] 块模式调用 generateFullV2 接口
- [ ] 生成页左侧模板预览改为渲染 HTML 快照 + 块标记
- [ ] 文件上传区按 AI 块分列展示
- [ ] 进度展示与块列表联动(当前正在生成哪个块)
## 第五阶段:导出引擎重构(第 5-6 周)
### 后端基于块索引的精确写回5-7 天)
- [x] 重构 `document_export.py`,基于 `block_index` 定位而非 `anchor_title` 文本匹配
- [x] 导出流程:
1. 从 MinIO 拉取原始模板 docx
2. 按块索引定位到原始 XML 元素
3. 固定块:保留原始内容不变
4. AI 块:用生成结果替换(文本块替换 `<w:t>`,表格块替换整个 `<w:tbl>`
5. 变量块:替换为变量值
- [x] 支持块顺序调整后的正确写回(按 sort_index 重排文档元素gap 元素保持原位置)
- [x] 保留未选中区域(没有被任何 Block 覆盖的文档部分)原样保留
- [ ] 表格样式继承AI 生成的表格复用同一文档中最近的表格样式
### 后端导出正确性验证2-3 天)
- [ ] 单块替换正确性验证
- [ ] 多块顺序写回正确性验证
- [ ] 固定块 + AI 块混合导出验证
- [ ] 封面区域(非标题内容)导出验证
- [ ] 表格块导出样式一致性验证
## 第六阶段:预览编辑与结果管理(第 5-6 周)
### 前端预览编辑页升级3-4 天)
- [x] 预览页面改为渲染带块标记的 HTML 文档DocRenderer + 模板 HTML 快照)
- [x] AI 块紫色边框标注(左侧紫色边框 + 标签)
- [x] 支持单块重新生成(块列表中的重新生成按钮 + regenerateBlock API
- [x] 支持恢复到模板默认内容resetBlock API + 确认弹窗)
- [x] 导出按钮支持块级导出Word / Word v2 / PDF
- [x] 块/段落双模式自动切换(根据 has_blocks 标志自动选择视图)
### 前端历史记录页增强2-3 天)
- [ ] 历史列表卡片展示文档缩略图HTML 快照截图)
- [ ] 按模板、按状态筛选
- [ ] 对比模式:并排展示原始模板 vs 生成结果
## 第七阶段:联调、修边与验收(第 7-8 周)
- [ ] 真实复杂模板全流程联调(含封面 / 摘要 / 多级标题 / 复杂表格 / 图片)
- [ ] 框选交互边界测试(跨页选区、嵌套表格选区、空选区)
- [ ] 不同浏览器兼容性测试Chrome / Edge / Safari
- [ ] 大文档性能测试50 页+ Word 文档渲染性能)
- [ ] 导出结果与原 Word 样式一致性验收
- [ ] 旧数据(基于 Paragraph 的历史记录)兼容性验证
- [ ] 错误提示完善、加载状态/骨架屏补充
- [ ] 操作日志与使用说明整理
## 阶段交付物
| 阶段 | 交付物 |
|------|--------|
| 第 1-2 周 | Word→HTML 渲染引擎、块级解析器、文档渲染组件 |
| 第 2-3 周 | 可视化框选交互、块配置面板、模式切换 |
| 第 3-4 周 | 块存储 API、模板快照管理、数据库增量变更 |
| 第 4-5 周 | 块级 AI 生成调度、生成页适配 |
| 第 5-6 周 | 基于块索引的精确写回引擎、预览编辑页升级 |
| 第 7-8 周 | 全流程联调、兼容性测试、验收交付 |
## 与现有改造任务的关系
| 现有任务 | 本方案处理方式 |
|---------|-------------|
| 04-后续迭代(占位体系) | 变量块覆盖占位需求,不再需要独立占位语法 |
| 05-在线编辑重构AI选区模式 | 本方案的框选交互即为 AI 选区模式的完整实现 |
| 05-模板源写回 | 合并到本方案第五阶段导出引擎重构 |
| 旧 Paragraph 表 | 保留兼容,逐步由 TemplateBlock 替代 |
## 核心架构变化
```
【改造前】
Word 上传 → 按 Heading 切段落 → 段落列表 → 配置 → AI 生成 → 按标题文本写回
【改造后】
Word 上传 → 解析为 HTML + 块索引 → 可视化框选定义块 → 块配置 → AI 按块生成 → 按索引精确写回
```
## 风险与缓解
| 风险 | 等级 | 缓解措施 |
|------|------|---------|
| Word→HTML 样式还原度不足 | 中 | 优先保证字体/表格/对齐,复杂格式(分栏/文本框)暂不支持 |
| 大文档 HTML 渲染性能 | 中 | 虚拟滚动 + 按需加载,超过 100 页的文档分页渲染 |
| 块索引在多次编辑后偏移 | 高 | 每次保存时重新解析并更新索引;导出时以最新快照为准 |
| 框选交互在 iframe 中的复杂性 | 中 | 备选方案为 div 直接渲染(放弃 iframe 隔离),简化事件处理 |
| 旧数据兼容 | 低 | Paragraph→TemplateBlock 转换脚本,历史数据只读 |

View File

@ -9,15 +9,11 @@ export const generateApi = {
deleteReferenceFile: (id: number) => http.delete(`/generate/reference-files/${id}`), deleteReferenceFile: (id: number) => http.delete(`/generate/reference-files/${id}`),
downloadReferenceFile: (id: number) => http.get(`/generate/reference-files/${id}/download`), downloadReferenceFile: (id: number) => http.get(`/generate/reference-files/${id}/download`),
full: (data: any) => http.post('/generate/full', data), full: (data: any) => http.post('/generate/full', data),
fullV2: (data: any) => http.post('/generate/full-v2', data),
progress: (id: number) => `/api/v1/generate/progress/${id}`, progress: (id: number) => `/api/v1/generate/progress/${id}`,
cancel: (id: number) => http.post(`/generate/cancel/${id}`), cancel: (id: number) => http.post(`/generate/cancel/${id}`),
documents: (params?: any) => http.get('/generate/documents', { params }), documents: (params?: any) => http.get('/generate/documents', { params }),
getDocument: (id: number) => http.get(`/generate/documents/${id}`), getDocument: (id: number) => http.get(`/generate/documents/${id}`),
deleteDocument: (id: number) => http.delete(`/generate/documents/${id}`), deleteDocument: (id: number) => http.delete(`/generate/documents/${id}`),
exportDocx: (id: number) => `/api/v1/export/${id}/docx`, exportDocx: (id: number) => `/api/v1/export/${id}/docx`,
exportDocxV2: (id: number) => `/api/v1/export/${id}/docx-v2`,
exportPdf: (id: number) => `/api/v1/export/${id}/pdf`, exportPdf: (id: number) => `/api/v1/export/${id}/pdf`,
regenerateBlock: (blockId: number, data: any) => http.post(`/generate/blocks/${blockId}/regenerate`, data),
resetBlock: (blockId: number, documentId: number) => http.delete(`/generate/blocks/${blockId}/reset/${documentId}`),
} }

View File

@ -6,8 +6,4 @@ export const templateApi = {
upload: (formData: FormData) => http.post('/templates/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }), upload: (formData: FormData) => http.post('/templates/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }),
saveParagraphs: (id: number, data: any) => http.put(`/templates/${id}/paragraphs`, data), saveParagraphs: (id: number, data: any) => http.put(`/templates/${id}/paragraphs`, data),
delete: (id: number) => http.delete(`/templates/${id}`), delete: (id: number) => http.delete(`/templates/${id}`),
/** 获取模板的 HTML 预览文档 + 块列表 */
getPreview: (id: number) => http.get(`/templates/${id}/preview`),
/** 保存块配置 */
saveBlocks: (id: number, blocks: any[]) => http.put(`/templates/${id}/blocks`, { blocks }),
} }

View File

@ -1,437 +0,0 @@
<template>
<div class="doc-renderer-container" ref="containerRef">
<div class="doc-renderer-toolbar">
<a-radio-group v-model:value="mode" size="small" button-style="solid">
<a-radio-button value="preview">预览</a-radio-button>
<a-radio-button value="select">框选</a-radio-button>
</a-radio-group>
<span class="toolbar-hint" v-if="mode === 'select'">
拖拽选择页面区域定义为块已选 {{ selectedIndices.size }} 个块
</span>
<span class="toolbar-hint" v-else>预览模式 查看文档渲染效果</span>
<span class="flex-spacer" />
<a-button size="small" @click="zoomOut">-</a-button>
<span class="zoom-label">{{ Math.round(zoom * 100) }}%</span>
<a-button size="small" @click="zoomIn">+</a-button>
<a-button size="small" @click="zoomReset">重置</a-button>
</div>
<div class="doc-renderer-viewport" ref="viewportRef">
<div
class="doc-renderer-page"
:style="{ transform: `scale(${zoom})`, transformOrigin: 'top center' }"
>
<iframe
ref="iframeRef"
class="doc-iframe"
:srcdoc="htmlContent"
@load="onIframeLoad"
sandbox="allow-same-origin"
title="文档预览"
/>
</div>
</div>
<!-- 拖拽框选覆盖层 -->
<div
v-if="showSelectionRect"
class="selection-overlay"
:style="{
left: selectionRect.left + 'px',
top: selectionRect.top + 'px',
width: selectionRect.width + 'px',
height: selectionRect.height + 'px',
}"
/>
<!-- 框选浮动菜单 -->
<div
v-if="showContextMenu"
class="context-menu"
:style="{ left: contextMenuPos.x + 'px', top: contextMenuPos.y + 'px' }"
>
<a-button size="small" type="primary" @click="setSelectedAsAI">设为 AI </a-button>
<a-button size="small" @click="setSelectedAsFixed">设为固定块</a-button>
<a-button size="small" @click="clearSelection">取消选择</a-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onBeforeUnmount, nextTick } from 'vue'
const props = defineProps<{
htmlContent: string
blocks?: any[]
initialMode?: 'preview' | 'select'
}>()
const emit = defineEmits<{
(e: 'block-select', indices: number[]): void
(e: 'block-set-ai', indices: number[]): void
(e: 'block-set-fixed', indices: number[]): void
(e: 'blocks-update', blocks: any[]): void
}>()
const mode = ref<'preview' | 'select'>(props.initialMode || 'preview')
const zoom = ref(1)
const iframeRef = ref<HTMLIFrameElement | null>(null)
const containerRef = ref<HTMLElement | null>(null)
const viewportRef = ref<HTMLElement | null>(null)
const selectedIndices = ref<Set<number>>(new Set())
const showContextMenu = ref(false)
const contextMenuPos = ref({ x: 0, y: 0 })
const showSelectionRect = ref(false)
//
const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 })
const selectionRect = ref({ left: 0, top: 0, width: 0, height: 0 })
function zoomIn() { zoom.value = Math.min(2, zoom.value + 0.1) }
function zoomOut() { zoom.value = Math.max(0.3, zoom.value - 0.1) }
function zoomReset() { zoom.value = 1 }
function onIframeLoad() {
if (!iframeRef.value) return
const doc = iframeRef.value.contentDocument
if (!doc) return
injectStyles(doc)
bindIframeEvents(doc)
}
function injectStyles(doc: Document) {
const style = doc.createElement('style')
style.textContent = `
[data-block-index].hover-highlight {
outline: 1px dashed #5b5bd6 !important;
outline-offset: 2px;
background-color: rgba(91, 91, 214, 0.04) !important;
cursor: pointer;
}
[data-block-index].block-selected {
outline: 2px solid #5b5bd6 !important;
outline-offset: 2px;
background-color: rgba(91, 91, 214, 0.08) !important;
}
[data-block-index].block-ai {
outline: 2px solid rgba(91, 91, 214, 0.6) !important;
outline-offset: 2px;
background-color: rgba(91, 91, 214, 0.1) !important;
}
[data-block-index].block-fixed {
outline: 2px solid rgba(154, 161, 173, 0.6) !important;
outline-offset: 2px;
background-color: rgba(154, 161, 173, 0.06) !important;
}
[data-block-index].block-variable {
outline: 2px solid rgba(26, 140, 74, 0.5) !important;
outline-offset: 2px;
background-color: rgba(26, 140, 74, 0.06) !important;
}
.block-list-item.list-unordered::before {
content: "\\2022";
display: inline-block;
width: 1em;
margin-left: -1em;
color: #333;
}
`
doc.head.appendChild(style)
}
function getBlockElements(doc: Document): HTMLElement[] {
return Array.from(doc.querySelectorAll('[data-block-index]'))
}
function bindIframeEvents(doc: Document) {
const blocks = getBlockElements(doc)
// hover
blocks.forEach(el => {
el.addEventListener('mouseenter', () => {
if (mode.value === 'select') {
el.classList.add('hover-highlight')
}
})
el.addEventListener('mouseleave', () => {
el.classList.remove('hover-highlight')
})
})
// mousedown mouseup
let mouseDownTarget: HTMLElement | null = null
let mouseMoved = false
doc.addEventListener('mousedown', (e: MouseEvent) => {
if (mode.value !== 'select') return
mouseDownTarget = e.target as HTMLElement
mouseMoved = false
isDragging.value = false
showSelectionRect.value = false
dragStart.value = { x: e.clientX, y: e.clientY }
selectionRect.value = { left: e.clientX, top: e.clientY, width: 0, height: 0 }
// Ctrl
if (!(e.ctrlKey || e.metaKey)) {
clearAllSelection(doc)
selectedIndices.value = new Set()
}
})
doc.addEventListener('mousemove', (e: MouseEvent) => {
if (mode.value !== 'select') return
if (dragStart.value.x === 0 && dragStart.value.y === 0) return
const dx = Math.abs(e.clientX - dragStart.value.x)
const dy = Math.abs(e.clientY - dragStart.value.y)
if (dx > 3 || dy > 3) {
mouseMoved = true
//
if (!isDragging.value) {
isDragging.value = true
showSelectionRect.value = true
}
selectionRect.value = {
left: Math.min(dragStart.value.x, e.clientX),
top: Math.min(dragStart.value.y, e.clientY),
width: Math.abs(e.clientX - dragStart.value.x),
height: Math.abs(e.clientY - dragStart.value.y),
}
}
})
doc.addEventListener('mouseup', (e: MouseEvent) => {
if (mode.value !== 'select') return
if (mouseMoved && isDragging.value) {
// blocks
isDragging.value = false
showSelectionRect.value = false
const iframeRect = iframeRef.value?.getBoundingClientRect()
if (!iframeRect) {
dragStart.value = { x: 0, y: 0 }
return
}
const newSelected = new Set(selectedIndices.value)
const selRect = {
left: selectionRect.value.left,
top: selectionRect.value.top,
right: selectionRect.value.left + selectionRect.value.width,
bottom: selectionRect.value.top + selectionRect.value.height,
}
blocks.forEach(el => {
const br = el.getBoundingClientRect()
if (
selRect.left < br.right &&
selRect.right > br.left &&
selRect.top < br.bottom &&
selRect.bottom > br.top
) {
const index = parseInt(el.getAttribute('data-block-index') || '')
if (!isNaN(index)) {
newSelected.add(index)
el.classList.add('block-selected')
}
}
})
selectedIndices.value = newSelected
selectionRect.value = { left: 0, top: 0, width: 0, height: 0 }
if (newSelected.size > 0) {
showContextMenu.value = true
contextMenuPos.value = { x: e.clientX, y: e.clientY + 10 }
} else {
showContextMenu.value = false
}
emitBlockSelect()
} else {
//
isDragging.value = false
showSelectionRect.value = false
selectionRect.value = { left: 0, top: 0, width: 0, height: 0 }
const target = e.target as HTMLElement
const blockEl = target.closest?.('[data-block-index]') as HTMLElement | null
if (blockEl) {
const index = parseInt(blockEl.getAttribute('data-block-index') || '')
if (!isNaN(index)) {
if (e.ctrlKey || e.metaKey) {
// Ctrl+/
if (selectedIndices.value.has(index)) {
selectedIndices.value.delete(index)
blockEl.classList.remove('block-selected')
} else {
selectedIndices.value.add(index)
blockEl.classList.add('block-selected')
}
selectedIndices.value = new Set(selectedIndices.value)
} else {
// mousedown
selectedIndices.value = new Set([index])
blockEl.classList.add('block-selected')
}
emitBlockSelect()
if (selectedIndices.value.size > 0) {
showContextMenu.value = true
contextMenuPos.value = { x: e.clientX, y: e.clientY + 10 }
}
}
} else {
//
clearAllSelection(doc)
selectedIndices.value = new Set()
showContextMenu.value = false
emitBlockSelect()
}
}
dragStart.value = { x: 0, y: 0 }
mouseMoved = false
})
}
function clearAllSelection(doc: Document) {
doc.querySelectorAll('[data-block-index].block-selected').forEach(el => {
el.classList.remove('block-selected')
})
}
function emitBlockSelect() {
const indices = Array.from(selectedIndices.value).sort((a, b) => a - b)
emit('block-select', indices)
}
//
function setSelectedAsAI() {
const indices = Array.from(selectedIndices.value).sort((a, b) => a - b)
emit('block-set-ai', indices)
showContextMenu.value = false
}
function setSelectedAsFixed() {
const indices = Array.from(selectedIndices.value).sort((a, b) => a - b)
emit('block-set-fixed', indices)
showContextMenu.value = false
}
function clearSelection() {
const doc = iframeRef.value?.contentDocument
if (doc) clearAllSelection(doc)
selectedIndices.value = new Set()
showContextMenu.value = false
emitBlockSelect()
}
// blocks
function highlightBlocks(indices: number[], className: string) {
const doc = iframeRef.value?.contentDocument
if (!doc) return
indices.forEach(i => {
const el = doc.querySelector(`[data-block-index="${i}"]`)
if (el) el.classList.add(className)
})
}
function clearHighlights(className: string) {
const doc = iframeRef.value?.contentDocument
if (!doc) return
doc.querySelectorAll(`.${className}`).forEach(el => el.classList.remove(className))
}
defineExpose({ highlightBlocks, clearHighlights, clearSelection })
// mode
watch(mode, (newMode) => {
const doc = iframeRef.value?.contentDocument
if (!doc) return
if (newMode === 'preview') {
clearAllSelection(doc)
selectedIndices.value = new Set()
showContextMenu.value = false
}
})
onBeforeUnmount(() => {
//
})
</script>
<style scoped>
.doc-renderer-container {
display: flex;
flex-direction: column;
height: 100%;
background: #f0f1f3;
}
.doc-renderer-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: #fff;
border-bottom: 1px solid #e0e2e6;
flex-shrink: 0;
}
.toolbar-hint {
font-size: 12px;
color: #9aa1ad;
}
.flex-spacer {
flex: 1;
}
.zoom-label {
font-size: 12px;
color: #5b626e;
min-width: 40px;
text-align: center;
}
.doc-renderer-viewport {
flex: 1;
overflow: auto;
padding: 24px;
display: flex;
justify-content: center;
}
.doc-renderer-page {
width: 794px;
min-height: 500px;
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
transition: transform 0.15s ease;
}
.doc-iframe {
width: 100%;
min-height: 500px;
border: none;
display: block;
}
.context-menu {
position: fixed;
z-index: 1000;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
padding: 8px;
display: flex;
gap: 6px;
}
.selection-overlay {
position: fixed;
z-index: 999;
border: 1px dashed #5b5bd6;
background: rgba(91, 91, 214, 0.08);
pointer-events: none;
}
</style>

View File

@ -41,23 +41,5 @@ export interface ReferenceFile {
id: number; file_name: string; file_path: string; file_size: number; content_type: string; created_at: string id: number; file_name: string; file_path: string; file_size: number; content_type: string; created_at: string
} }
export interface TemplateBlock {
id: number; template_id: number; sort_index: number
block_type: 'heading' | 'text' | 'table' | 'image' | 'ai_slot' | 'variable'
anchor_ref: string; anchor_start_index: number; anchor_end_index: number
html_snippet: string; title: string; content_json: string; style_json: string
edit_mode: 'manual' | 'ai'
placeholder_key: string; variable_key: string; default_value: string
model_id: number | null; need_prompt: boolean; prompt_text: string
need_file: boolean; file_note: string; output_format: 'text' | 'table' | 'mixed' | 'chart'
}
export interface TemplatePreview {
template: Template
blocks: TemplateBlock[]
html: string
block_count: number
}
export interface ApiResponse<T = any> { code: number; data: T; message: string } export interface ApiResponse<T = any> { code: number; data: T; message: string }
export interface PageData<T = any> { items: T[]; total: number; page: number; page_size: number } export interface PageData<T = any> { items: T[]; total: number; page: number; page_size: number }

View File

@ -4,11 +4,6 @@
<span>执行生成</span> <span>执行生成</span>
<span>/</span> <span>/</span>
<span>选择模板 上传文件 AI 自动生成</span> <span>选择模板 上传文件 AI 自动生成</span>
<span class="flex-spacer" />
<a-radio-group v-model:value="genMode" size="small" button-style="solid" v-if="selectedTplId">
<a-radio-button value="paragraph">段落模式</a-radio-button>
<a-radio-button value="block">块模式</a-radio-button>
</a-radio-group>
</div> </div>
<div class="gen-layout"> <div class="gen-layout">
@ -56,12 +51,9 @@
<div class="gen-right"> <div class="gen-right">
<div class="panel-card fill-card"> <div class="panel-card fill-card">
<div class="panel-head"> <div class="panel-head">
<div class="panel-title">{{ genMode === 'block' ? 'AI 块列表' : '段落文件配置' }}</div> <div class="panel-title">段落文件配置</div>
<a-tag v-if="genMode === 'block'" color="purple">{{ aiBlockCount }} AI 块待生成</a-tag>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<!-- 段落模式 -->
<template v-if="genMode === 'paragraph'">
<div v-for="paragraph in paragraphs" :key="paragraph.id" :class="['para-row', { needFile: paragraph.need_file, noFile: !paragraph.need_file }]"> <div v-for="paragraph in paragraphs" :key="paragraph.id" :class="['para-row', { needFile: paragraph.need_file, noFile: !paragraph.need_file }]">
<div class="para-info"> <div class="para-info">
<span class="idx">{{ paragraph.sort_index }}</span> <span class="idx">{{ paragraph.sort_index }}</span>
@ -79,29 +71,12 @@
</div> </div>
<span v-else class="no-file-tag">无需上传</span> <span v-else class="no-file-tag">无需上传</span>
</div> </div>
</template>
<!-- 块模式 -->
<template v-else>
<div v-for="block in aiBlocks" :key="block.id" class="para-row needFile">
<div class="para-info">
<span class="idx">{{ block.sort_index }}</span>
<div class="para-main">
<span>{{ block.title || block.block_type }}</span>
<span class="file-note">{{ blockTypeLabel(block.block_type) }} · {{ block.output_format === 'table' ? '表格输出' : '文本输出' }}</span>
</div>
<a-tag :color="block.edit_mode === 'ai' ? 'purple' : 'default'">
{{ block.edit_mode === 'ai' ? 'AI 生成' : '固定内容' }}
</a-tag>
</div>
</div>
<a-empty v-if="aiBlocks.length === 0" description="该模板没有 AI 生成块,请先在可视化编辑器中标记 AI 块" />
</template>
</div> </div>
</div> </div>
<div class="action-bar"> <div class="action-bar">
<span>{{ genMode === 'block' ? `${aiBlockCount} 个 AI 块待生成` : `${fileCount}/${needFileCount} 个文件已上传` }}</span> <span>{{ fileCount }}/{{ needFileCount }} 个文件已上传</span>
<a-button type="primary" size="large" :loading="generating" @click="startGen" :disabled="genMode === 'block' && aiBlockCount === 0">立即生成</a-button> <a-button type="primary" size="large" :loading="generating" @click="startGen">立即生成</a-button>
</div> </div>
</div> </div>
</div> </div>
@ -115,9 +90,6 @@ import { message } from 'ant-design-vue'
import { useTemplateStore } from '@/stores/template' import { useTemplateStore } from '@/stores/template'
import { useDocumentStore } from '@/stores/document' import { useDocumentStore } from '@/stores/document'
import ReferenceFileSelector from '@/components/ReferenceFileSelector.vue' import ReferenceFileSelector from '@/components/ReferenceFileSelector.vue'
import { templateApi } from '@/api/template'
import { generateApi } from '@/api/generate'
import type { TemplateBlock } from '@/types'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -130,17 +102,11 @@ const selectedTplId = ref<number | undefined>(undefined)
const uploadedFiles = ref<Record<number, Array<{ file_name: string; file_path: string }>>>({}) const uploadedFiles = ref<Record<number, Array<{ file_name: string; file_path: string }>>>({})
const generating = ref(false) const generating = ref(false)
const tplInfo = ref<any>({}) const tplInfo = ref<any>({})
const genMode = ref<'paragraph' | 'block'>('paragraph')
//
const previewBlocks = ref<TemplateBlock[]>([])
const needFileCount = computed(() => paragraphs.value.filter((item) => item.need_file).length) const needFileCount = computed(() => paragraphs.value.filter((item) => item.need_file).length)
const autoCount = computed(() => paragraphs.value.filter((item) => item.edit_mode === 'ai').length) const autoCount = computed(() => paragraphs.value.filter((item) => item.edit_mode === 'ai').length)
const fileCount = computed(() => Object.values(uploadedFiles.value).filter((item) => item?.length).length) const fileCount = computed(() => Object.values(uploadedFiles.value).filter((item) => item?.length).length)
const currentTemplateName = computed(() => templates.value.find((item) => item.id === selectedTplId.value)?.name || '') const currentTemplateName = computed(() => templates.value.find((item) => item.id === selectedTplId.value)?.name || '')
const aiBlocks = computed(() => previewBlocks.value.filter(b => b.edit_mode === 'ai'))
const aiBlockCount = computed(() => aiBlocks.value.length)
watch( watch(
uploadedFiles, uploadedFiles,
@ -178,18 +144,6 @@ async function onTplChange(id: number) {
} }
uploadedFiles.value = {} uploadedFiles.value = {}
uploadedFilePaths.value = {} uploadedFilePaths.value = {}
//
try {
const res: any = await templateApi.getPreview(id)
previewBlocks.value = (res.data?.blocks || []) as TemplateBlock[]
} catch {
previewBlocks.value = []
}
}
function blockTypeLabel(type: string) {
const map: Record<string, string> = { heading: '标题', text: '正文', table: '表格', image: '图片' }
return map[type] || type
} }
async function startGen() { async function startGen() {
@ -197,27 +151,6 @@ async function startGen() {
message.warning('请先选择模板') message.warning('请先选择模板')
return return
} }
if (genMode.value === 'block') {
// 使 v2
if (aiBlockCount.value === 0) {
message.warning('没有 AI 生成块,请先在可视化编辑器中标记')
return
}
generating.value = true
try {
const res: any = await generateApi.fullV2({ template_id: selectedTplId.value, file_map: {} })
message.success('块级生成任务已提交')
router.push(`/preview/${res.data.id}`)
} catch (error: any) {
message.error(error.message || '生成失败')
} finally {
generating.value = false
}
return
}
//
const missing = paragraphs.value.filter((item: any) => item.need_file && !uploadedFilePaths.value[item.id]) const missing = paragraphs.value.filter((item: any) => item.need_file && !uploadedFilePaths.value[item.id])
if (missing.length) { if (missing.length) {
message.warning('还有必传文件未上传') message.warning('还有必传文件未上传')

View File

@ -8,7 +8,6 @@
<div class="detail-actions"> <div class="detail-actions">
<a-button v-if="isRunning" danger @click="cancelTask">取消任务</a-button> <a-button v-if="isRunning" danger @click="cancelTask">取消任务</a-button>
<a-button :disabled="!isCompleted" @click="exportDocx">导出 Word</a-button> <a-button :disabled="!isCompleted" @click="exportDocx">导出 Word</a-button>
<a-button v-if="isBlockMode" :disabled="!isCompleted" @click="exportDocxV2">导出 Word (v2)</a-button>
<a-button :disabled="!isCompleted" @click="exportPdf">导出 PDF</a-button> <a-button :disabled="!isCompleted" @click="exportPdf">导出 PDF</a-button>
</div> </div>
</div> </div>
@ -34,69 +33,6 @@
<div class="status-message">{{ progressMessage || documentInfo.error || '任务已创建,等待执行。' }}</div> <div class="status-message">{{ progressMessage || documentInfo.error || '任务已创建,等待执行。' }}</div>
</a-card> </a-card>
<!-- 块模式 -->
<template v-if="isBlockMode">
<div class="detail-layout">
<aside class="detail-left">
<a-card class="left-card" title="块列表">
<div v-if="templateBlocks.length" class="block-list">
<div
v-for="block in templateBlocks"
:key="block.id"
:class="['block-item', {
active: selectedBlockId === block.id,
'block-ai': block.edit_mode === 'ai',
'block-fixed': block.edit_mode === 'manual',
}]"
@click="selectBlock(block.id)"
>
<div class="block-top">
<span class="block-index">{{ block.sort_index }}</span>
<div class="block-main">
<div class="block-title">{{ block.title || blockTypeLabel(block.block_type) }}</div>
<div class="block-type-tag">{{ blockTypeLabel(block.block_type) }}</div>
</div>
<a-tag :color="block.edit_mode === 'ai' ? 'purple' : 'default'" size="small">
{{ block.edit_mode === 'ai' ? 'AI' : '固定' }}
</a-tag>
</div>
<div class="block-status">
<a-badge :status="statusBadge(blockLogMap[block.id]?.status || 'pending')"
:text="statusText(blockLogMap[block.id]?.status || 'pending')" />
<span v-if="blockLogMap[block.id]?.duration" class="block-duration">{{ blockLogMap[block.id].duration }}s</span>
</div>
<div v-if="block.edit_mode === 'ai' && isCompleted" class="block-actions">
<a-button size="small" type="link" :loading="regeneratingBlockId === block.id" @click.stop="regenerateBlock(block)">
重新生成
</a-button>
<a-popconfirm title="确定恢复为模板默认内容?此操作会删除当前生成结果。" @confirm="resetBlock(block)" ok-text="确定" cancel-text="取消">
<a-button size="small" type="link" danger @click.stop>恢复默认</a-button>
</a-popconfirm>
</div>
</div>
</div>
<a-empty v-else description="当前任务未记录块信息" />
</a-card>
</aside>
<section class="detail-right">
<a-card class="preview-card block-preview-card" title="文档预览">
<DocRenderer
v-if="previewHtml"
ref="docRendererRef"
:html-content="previewHtml"
:blocks="templateBlocks"
initial-mode="preview"
@block-select="onBlockSelect"
/>
<a-empty v-else description="正在加载文档预览..." />
</a-card>
</section>
</div>
</template>
<!-- 段落模式回退 -->
<template v-else>
<div class="detail-layout"> <div class="detail-layout">
<aside class="detail-left"> <aside class="detail-left">
<a-card class="left-card" title="段落列表"> <a-card class="left-card" title="段落列表">
@ -146,18 +82,15 @@
</a-card> </a-card>
</section> </section>
</div> </div>
</template>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { useDocumentStore } from '@/stores/document' import { useDocumentStore } from '@/stores/document'
import { generateApi } from '@/api/generate' import { generateApi } from '@/api/generate'
import { templateApi } from '@/api/template'
import DocRenderer from '@/components/DocRenderer.vue'
interface ContentBlock { interface ContentBlock {
type: string type: string
@ -184,17 +117,8 @@ const progressMessage = ref('')
const selectedParagraphId = ref<number | null>(null) const selectedParagraphId = ref<number | null>(null)
let progressSource: EventSource | null = null let progressSource: EventSource | null = null
//
const templateBlocks = ref<any[]>([])
const blockLogMap = ref<Record<number, any>>({})
const selectedBlockId = ref<number | null>(null)
const previewHtml = ref('')
const docRendererRef = ref<InstanceType<typeof DocRenderer> | null>(null)
const regeneratingBlockId = ref<number | null>(null)
const isRunning = computed(() => ['pending', 'generating'].includes(documentInfo.value.status)) const isRunning = computed(() => ['pending', 'generating'].includes(documentInfo.value.status))
const isCompleted = computed(() => documentInfo.value.status === 'completed') const isCompleted = computed(() => documentInfo.value.status === 'completed')
const isBlockMode = computed(() => documentInfo.value.has_blocks || templateBlocks.value.length > 0)
const paragraphMappings = computed(() => documentInfo.value.request_payload?.paragraphs || []) const paragraphMappings = computed(() => documentInfo.value.request_payload?.paragraphs || [])
const logStatusMap = computed(() => const logStatusMap = computed(() =>
Object.fromEntries(logs.value.map((item) => [item.paragraph_id, item.status])) Object.fromEntries(logs.value.map((item) => [item.paragraph_id, item.status]))
@ -225,11 +149,6 @@ function statusBadge(status: string) {
return 'processing' return 'processing'
} }
function blockTypeLabel(type: string) {
const map: Record<string, string> = { heading: '标题', text: '正文', table: '表格', image: '图片' }
return map[type] || type
}
function renderTable(block: ContentBlock) { function renderTable(block: ContentBlock) {
const headers = block.headers || [] const headers = block.headers || []
const rows = block.rows || [] const rows = block.rows || []
@ -254,17 +173,9 @@ async function loadDocument() {
const response: any = await generateApi.getDocument(id) const response: any = await generateApi.getDocument(id)
documentInfo.value = response.data || {} documentInfo.value = response.data || {}
logs.value = response.data?.logs || [] logs.value = response.data?.logs || []
if (!selectedParagraphId.value) {
//
templateBlocks.value = response.data?.template_blocks || []
blockLogMap.value = response.data?.block_logs || {}
if (!selectedParagraphId.value && !isBlockMode.value) {
selectedParagraphId.value = logs.value[0]?.paragraph_id || paragraphMappings.value[0]?.paragraph_id || null selectedParagraphId.value = logs.value[0]?.paragraph_id || paragraphMappings.value[0]?.paragraph_id || null
} }
if (isBlockMode.value && !selectedBlockId.value) {
selectedBlockId.value = templateBlocks.value[0]?.id || null
}
if ( if (
selectedParagraphId.value && selectedParagraphId.value &&
!paragraphMappings.value.some((item: any) => item.paragraph_id === selectedParagraphId.value) && !paragraphMappings.value.some((item: any) => item.paragraph_id === selectedParagraphId.value) &&
@ -274,72 +185,10 @@ async function loadDocument() {
} }
} }
async function loadPreviewHtml() {
if (!documentInfo.value.template_id) return
try {
const res: any = await templateApi.getPreview(documentInfo.value.template_id)
previewHtml.value = res.data?.html || ''
} catch {
previewHtml.value = ''
}
}
function selectParagraph(paragraphId: number) { function selectParagraph(paragraphId: number) {
selectedParagraphId.value = paragraphId selectedParagraphId.value = paragraphId
} }
function selectBlock(blockId: number) {
selectedBlockId.value = blockId
// DocRenderer
const block = templateBlocks.value.find(b => b.id === blockId)
if (block && docRendererRef.value) {
docRendererRef.value.clearSelection()
if (block.anchor_start_index != null) {
docRendererRef.value.highlightBlocks([block.anchor_start_index], 'block-selected')
}
}
}
function onBlockSelect(indices: number[]) {
if (indices.length > 0) {
//
const idx = indices[0]
const block = templateBlocks.value.find(b => b.anchor_start_index === idx)
if (block) {
selectedBlockId.value = block.id
}
}
}
async function regenerateBlock(block: any) {
regeneratingBlockId.value = block.id
try {
const res: any = await generateApi.regenerateBlock(block.id, {
template_id: Number(route.params.id),
prompt_text: block.prompt_text || '',
model_id: block.model_id || undefined,
})
if (res.data) {
blockLogMap.value[block.id] = res.data
message.success(`块 "${block.title || blockTypeLabel(block.block_type)}" 已重新生成`)
}
} catch (error: any) {
message.error(error.message || '重新生成失败')
} finally {
regeneratingBlockId.value = null
}
}
async function resetBlock(block: any) {
try {
await generateApi.resetBlock(block.id, Number(route.params.id))
delete blockLogMap.value[block.id]
message.success(`块 "${block.title || blockTypeLabel(block.block_type)}" 已恢复为模板默认内容`)
} catch (error: any) {
message.error(error.message || '恢复默认失败')
}
}
function bindProgress() { function bindProgress() {
const id = Number(route.params.id) const id = Number(route.params.id)
if (progressSource) progressSource.close() if (progressSource) progressSource.close()
@ -370,10 +219,6 @@ function exportDocx() {
window.open(generateApi.exportDocx(Number(route.params.id))) window.open(generateApi.exportDocx(Number(route.params.id)))
} }
function exportDocxV2() {
window.open(generateApi.exportDocxV2(Number(route.params.id)))
}
function exportPdf() { function exportPdf() {
window.open(generateApi.exportPdf(Number(route.params.id))) window.open(generateApi.exportPdf(Number(route.params.id)))
} }
@ -381,9 +226,6 @@ function exportPdf() {
onMounted(async () => { onMounted(async () => {
try { try {
await loadDocument() await loadDocument()
if (isBlockMode.value) {
await loadPreviewHtml()
}
progressPercent.value = Math.floor(((documentInfo.value.para_count_done || 0) / Math.max(documentInfo.value.para_count_total || 1, 1)) * 100) progressPercent.value = Math.floor(((documentInfo.value.para_count_done || 0) / Math.max(documentInfo.value.para_count_total || 1, 1)) * 100)
if (isRunning.value) bindProgress() if (isRunning.value) bindProgress()
} catch (error: any) { } catch (error: any) {
@ -458,23 +300,13 @@ onBeforeUnmount(() => {
color: #111827; color: #111827;
} }
.status-card { .status-card,
.preview-card {
margin-bottom: 16px; margin-bottom: 16px;
border-radius: 18px; border-radius: 18px;
flex-shrink: 0; flex-shrink: 0;
} }
.preview-card {
margin-bottom: 0;
border-radius: 18px;
}
.status-message {
margin-top: 12px;
font-size: 13px;
color: #6b7280;
}
.detail-layout { .detail-layout {
display: flex; display: flex;
gap: 16px; gap: 16px;
@ -503,111 +335,12 @@ onBeforeUnmount(() => {
height: 100%; height: 100%;
} }
/* ===== 块列表样式 ===== */ .status-message {
.block-list { margin-top: 12px;
display: grid; font-size: 13px;
gap: 10px;
}
.block-item {
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #fafbfc;
cursor: pointer;
transition: all 0.15s ease;
}
.block-item:hover {
border-color: #c7d2fe;
background: #f8faff;
}
.block-item.active {
border-color: #4f46e5;
background: #eef2ff;
}
.block-item.block-ai {
border-left: 3px solid #7c3aed;
}
.block-item.block-fixed {
border-left: 3px solid #9ca3af;
}
.block-top {
display: flex;
gap: 10px;
align-items: flex-start;
}
.block-index {
width: 24px;
height: 24px;
border-radius: 50%;
background: #eef2ff;
color: #4f46e5;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.block-main {
flex: 1;
min-width: 0;
}
.block-title {
font-size: 14px;
font-weight: 600;
color: #111827;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.block-type-tag {
margin-top: 4px;
font-size: 12px;
color: #6b7280; color: #6b7280;
} }
.block-status {
margin-top: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.block-duration {
font-size: 12px;
color: #9ca3af;
}
.block-actions {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #f0f0f0;
}
.block-preview-card {
height: 100%;
display: flex;
flex-direction: column;
}
.block-preview-card :deep(.ant-card-body) {
flex: 1;
min-height: 0;
overflow: hidden;
padding: 0;
}
/* ===== 段落列表样式 ===== */
.mapping-list { .mapping-list {
display: grid; display: grid;
gap: 12px; gap: 12px;
@ -725,7 +458,6 @@ onBeforeUnmount(() => {
margin-bottom: 14px; margin-bottom: 14px;
} }
/* ===== 深度样式 ===== */
:deep(.left-card .ant-card-body) { :deep(.left-card .ant-card-body) {
height: calc(100% - 57px); height: calc(100% - 57px);
overflow-y: auto; overflow-y: auto;

View File

@ -9,7 +9,6 @@
<span class="editor-title">{{ templateName }}</span> <span class="editor-title">{{ templateName }}</span>
<a-radio-group v-model:value="editorMode" size="small" button-style="solid"> <a-radio-group v-model:value="editorMode" size="small" button-style="solid">
<a-radio-button value="paragraph">段落配置</a-radio-button> <a-radio-button value="paragraph">段落配置</a-radio-button>
<a-radio-button value="visual">可视化编辑</a-radio-button>
<a-radio-button value="manual">手动编辑模板</a-radio-button> <a-radio-button value="manual">手动编辑模板</a-radio-button>
</a-radio-group> </a-radio-group>
<span class="flex-spacer" /> <span class="flex-spacer" />
@ -18,15 +17,11 @@
<div class="editor-layout"> <div class="editor-layout">
<aside class="editor-left"> <aside class="editor-left">
<div class="left-head" v-if="editorMode !== 'visual'"> <div class="left-head">
段落列表 段落列表
<span class="left-head-tip">(点击定位)</span> <span class="left-head-tip">(点击定位)</span>
</div> </div>
<div class="left-head" v-else> <div class="left-scroll">
块列表
<span class="left-head-tip">({{ previewBlocks.length }} 个块)</span>
</div>
<div class="left-scroll" v-if="editorMode !== 'visual'">
<div <div
v-for="paragraph in paragraphs" v-for="paragraph in paragraphs"
:key="paragraph.id" :key="paragraph.id"
@ -51,29 +46,6 @@
</span> </span>
</div> </div>
</div> </div>
<div class="left-scroll" v-else>
<div
v-for="(block, idx) in previewBlocks"
:key="block.id || block.anchor_start_index"
:class="['para-list-item', {
active: selectedBlockIndices.includes(block.anchor_start_index),
'drag-over': dragOverIndex === idx,
}]"
draggable="true"
@click="onBlockClick(block)"
@dragstart="onDragStart(idx, $event)"
@dragover.prevent="onDragOver(idx)"
@dragleave="onDragLeave"
@drop="onDrop(idx)"
@dragend="onDragEnd"
>
<span class="pli-index">{{ block.sort_index }}</span>
<span class="pli-title">{{ block.title || (block.block_type === 'table' ? '表格' : '文本块') }}</span>
<span :class="['pli-badge', block.edit_mode === 'ai' ? 'ai' : 'manual']">
{{ block.edit_mode === 'ai' ? 'AI' : '固定' }}
</span>
</div>
</div>
</aside> </aside>
<section class="editor-center"> <section class="editor-center">
@ -97,19 +69,7 @@
</div> </div>
<div class="center-scroll"> <div class="center-scroll">
<div v-if="editorMode === 'visual'" class="doc-visual-wrap"> <div class="doc-edit-page">
<DocRenderer
ref="docRendererRef"
:key="previewKey"
:html-content="previewHtml"
:blocks="previewBlocks"
initial-mode="select"
@block-select="onBlockSelect"
@block-set-ai="onBlockSetAI"
@block-set-fixed="onBlockSetFixed"
/>
</div>
<div v-else class="doc-edit-page">
<template v-if="editorMode === 'paragraph'"> <template v-if="editorMode === 'paragraph'">
<div v-for="paragraph in paragraphs" :key="paragraph.id" :id="`paraBlock${paragraph.id}`" :class="['para-block', { selected: selectedId === paragraph.id }]" @click="selectPara(paragraph.id)"> <div v-for="paragraph in paragraphs" :key="paragraph.id" :id="`paraBlock${paragraph.id}`" :class="['para-block', { selected: selectedId === paragraph.id }]" @click="selectPara(paragraph.id)">
<span class="para-block-actions" @click.stop> <span class="para-block-actions" @click.stop>
@ -181,12 +141,10 @@
<aside class="editor-right"> <aside class="editor-right">
<div class="right-head"> <div class="right-head">
<span v-if="editorMode !== 'visual'">段落配置</span> <span>段落配置</span>
<span v-else>块配置</span> <span class="right-head-name">{{ selectedPara?.title || '' }}</span>
<span class="right-head-name">{{ editorMode === 'visual' ? selectedBlockTitle : (selectedPara?.title || '') }}</span>
</div> </div>
<!-- 段落模式配置 --> <div class="right-scroll" v-if="selectedPara">
<div class="right-scroll" v-if="editorMode !== 'visual' && selectedPara">
<div class="config-section"> <div class="config-section">
<div class="config-section-title">生成设置</div> <div class="config-section-title">生成设置</div>
<a-form layout="vertical"> <a-form layout="vertical">
@ -273,53 +231,6 @@
立即测试 立即测试
</a-button> </a-button>
</div> </div>
<!-- 可视化模式块配置 -->
<div class="right-scroll" v-if="editorMode === 'visual' && selectedVisualBlock">
<div class="config-section">
<div class="config-section-title">块信息</div>
<a-form layout="vertical">
<a-form-item label="块类型">
<a-tag>{{ blockTypeLabel(selectedVisualBlock.block_type) }}</a-tag>
</a-form-item>
<a-form-item label="编辑方式">
<a-select v-model:value="selectedVisualBlock.edit_mode" @change="onBlockConfigChange">
<a-select-option value="ai">AI 生成</a-select-option>
<a-select-option value="manual">固定内容</a-select-option>
</a-select>
</a-form-item>
</a-form>
</div>
<div class="config-section" v-if="selectedVisualBlock.edit_mode === 'ai'">
<div class="config-section-title">AI 生成设置</div>
<a-form layout="vertical">
<a-form-item label="生成模型">
<a-select v-model:value="selectedVisualBlock.model_id" allowClear placeholder="使用默认模型" @change="onBlockConfigChange">
<a-select-option v-for="model in models" :key="model.id" :value="model.id">{{ model.name }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="输出格式">
<a-select v-model:value="selectedVisualBlock.output_format" @change="onBlockConfigChange">
<a-select-option value="text">正式报告段落</a-select-option>
<a-select-option value="table">表格形式</a-select-option>
<a-select-option value="mixed">混合内容</a-select-option>
<a-select-option value="chart">图表形式</a-select-option>
</a-select>
</a-form-item>
</a-form>
<div class="toggle-row">
<span>需要提示词</span>
<a-switch v-model:checked="selectedVisualBlock.need_prompt" @change="onBlockConfigChange" />
</div>
<a-textarea
v-if="selectedVisualBlock.need_prompt"
v-model:value="selectedVisualBlock.prompt_text"
:rows="4"
placeholder="在此输入提示词..."
@change="onBlockConfigChange"
/>
</div>
</div>
</aside> </aside>
</div> </div>
@ -389,8 +300,6 @@ import { useModelStore } from '@/stores/model'
import { generateApi } from '@/api/generate' import { generateApi } from '@/api/generate'
import { templateApi } from '@/api/template' import { templateApi } from '@/api/template'
import ReferenceFileSelector from '@/components/ReferenceFileSelector.vue' import ReferenceFileSelector from '@/components/ReferenceFileSelector.vue'
import DocRenderer from '@/components/DocRenderer.vue'
import type { TemplateBlock } from '@/types'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -401,16 +310,7 @@ const paragraphs = ref<any[]>([])
const models = ref<any[]>([]) const models = ref<any[]>([])
const selectedId = ref(0) const selectedId = ref(0)
const templateName = ref('模板编辑') const templateName = ref('模板编辑')
const editorMode = ref<'paragraph' | 'visual' | 'manual'>('paragraph') const editorMode = ref<'paragraph' | 'manual'>('paragraph')
//
const docRendererRef = ref<InstanceType<typeof DocRenderer> | null>(null)
const previewHtml = ref('')
const previewKey = ref(0)
const previewBlocks = ref<TemplateBlock[]>([])
const selectedBlockIndices = ref<number[]>([])
const dragIndex = ref<number | null>(null)
const dragOverIndex = ref<number | null>(null)
const testOpen = ref(false) const testOpen = ref(false)
const testStep = ref(0) const testStep = ref(0)
@ -424,14 +324,6 @@ const streamedText = ref('')
const selectedPara = computed(() => paragraphs.value.find((item) => item.id === selectedId.value)) const selectedPara = computed(() => paragraphs.value.find((item) => item.id === selectedId.value))
const selectedVisualBlock = computed(() => {
if (selectedBlockIndices.value.length === 0) return null
const idx = selectedBlockIndices.value[0]
return previewBlocks.value.find(b => b.anchor_start_index === idx) || null
})
const selectedBlockTitle = computed(() => selectedVisualBlock.value?.title || '')
function selectPara(id: number) { function selectPara(id: number) {
selectedId.value = id selectedId.value = id
} }
@ -754,185 +646,6 @@ async function runStreamingTest(templateId: number, filePaths: string[]) {
} }
} }
// ====== ======
function onBlockClick(block: TemplateBlock) {
selectedBlockIndices.value = [block.anchor_start_index]
// iframe block
if (docRendererRef.value) {
docRendererRef.value.clearSelection()
docRendererRef.value.highlightBlocks([block.anchor_start_index], 'block-selected')
}
}
//
function onDragStart(idx: number, event: DragEvent) {
dragIndex.value = idx
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', String(idx))
}
}
function onDragOver(idx: number) {
dragOverIndex.value = idx
}
function onDragLeave() {
dragOverIndex.value = null
}
function onDrop(idx: number) {
const from = dragIndex.value
dragOverIndex.value = null
dragIndex.value = null
if (from === null || from === idx) return
const items = [...previewBlocks.value]
const [moved] = items.splice(from, 1)
items.splice(idx, 0, moved)
// sort_index
items.forEach((item, i) => { item.sort_index = i + 1 })
previewBlocks.value = items
rebuildPreviewHtml()
saveBlocks()
}
function rebuildPreviewHtml() {
const sorted = [...previewBlocks.value].sort((a, b) => (a.sort_index || 0) - (b.sort_index || 0))
const bodyHtml = sorted
.map(b => b.html_snippet || '')
.filter(Boolean)
.join('\n')
previewHtml.value = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: 'SimSun', 'Microsoft YaHei', 'PingFang SC', sans-serif;
font-size: 12pt;
color: #1a1d24;
line-height: 1.8;
padding: 40px 60px;
max-width: 794px;
margin: 0 auto;
background: #fff;
}
.block-heading { font-weight: 600; }
.block-text { }
.block-table { width: 100%; overflow-x: auto; }
.block-table table { width: 100%; border-collapse: collapse; }
.block-table th, .block-table td { padding: 4px 6px; text-align: left; }
.block-empty { }
[data-block-index]:hover {
outline: 1px dashed #5b5bd6;
outline-offset: 2px;
}
[data-block-index].block-selected {
outline: 2px solid #5b5bd6;
outline-offset: 2px;
background-color: rgba(91,91,214,0.06);
}
[data-block-index].block-ai {
outline: 2px solid rgba(91,91,214,0.5);
outline-offset: 2px;
background-color: rgba(91,91,214,0.08);
}
[data-block-index].block-fixed {
outline: 2px solid rgba(154,161,173,0.5);
outline-offset: 2px;
background-color: rgba(154,161,173,0.04);
}
</style>
</head>
<body>
${bodyHtml}
</body>
</html>`
previewKey.value++
//
if (docRendererRef.value) {
docRendererRef.value.clearSelection()
}
}
function onDragEnd() {
dragIndex.value = null
dragOverIndex.value = null
}
async function loadPreview() {
const templateId = Number(route.params.id)
try {
const res: any = await templateApi.getPreview(templateId)
previewHtml.value = res.data.html || ''
previewBlocks.value = (res.data.blocks || []) as TemplateBlock[]
} catch (e: any) {
message.error(e?.message || '加载预览失败')
}
}
function onBlockSelect(indices: number[]) {
selectedBlockIndices.value = indices
}
function onBlockSetAI(indices: number[]) {
previewBlocks.value = previewBlocks.value.map(block => {
if (block.anchor_start_index !== undefined && indices.includes(block.anchor_start_index)) {
const updated = { ...block, edit_mode: 'ai' as const, need_prompt: true }
// html_snippet CSS class
if (updated.html_snippet) {
updated.html_snippet = updated.html_snippet
.replace('class="block-fixed"', '')
.replace('data-block-index=', 'class="block-ai" data-block-index=')
}
return updated
}
return block
})
rebuildPreviewHtml()
saveBlocks()
}
function onBlockSetFixed(indices: number[]) {
previewBlocks.value = previewBlocks.value.map(block => {
if (block.anchor_start_index !== undefined && indices.includes(block.anchor_start_index)) {
const updated = { ...block, edit_mode: 'manual' as const }
// html_snippet CSS class
if (updated.html_snippet) {
updated.html_snippet = updated.html_snippet
.replace('class="block-ai"', '')
.replace('data-block-index=', 'class="block-fixed" data-block-index=')
}
return updated
}
return block
})
rebuildPreviewHtml()
saveBlocks()
}
function blockTypeLabel(type: string) {
const map: Record<string, string> = { heading: '标题', text: '正文', table: '表格', image: '图片' }
return map[type] || type
}
function onBlockConfigChange() {
saveBlocks()
}
async function saveBlocks() {
const templateId = Number(route.params.id)
try {
await templateApi.saveBlocks(templateId, previewBlocks.value)
message.success('块配置已保存')
} catch (e: any) {
message.error(e?.message || '保存块配置失败')
}
}
onMounted(async () => { onMounted(async () => {
const id = Number(route.params.id) const id = Number(route.params.id)
const template = await store.fetchOne(id) const template = await store.fetchOne(id)
@ -943,8 +656,6 @@ onMounted(async () => {
if (paragraphs.value.length) { if (paragraphs.value.length) {
selectedId.value = paragraphs.value[0].id selectedId.value = paragraphs.value[0].id
} }
//
loadPreview()
}) })
</script> </script>
@ -1088,13 +799,6 @@ onMounted(async () => {
flex-shrink: 0; flex-shrink: 0;
} }
.doc-visual-wrap {
width: 100%;
flex: 1;
display: flex;
flex-direction: column;
}
.para-block { .para-block {
position: relative; position: relative;
padding: 8px; padding: 8px;
@ -1278,11 +982,6 @@ onMounted(async () => {
font-weight: 500; font-weight: 500;
} }
.para-list-item.drag-over {
border-top: 2px solid #5b5bd6;
background: #f0f0ff;
}
.pli-index { .pli-index {
width: 20px; width: 20px;
height: 20px; height: 20px;