Compare commits
3 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
027eb32044 | |
|
|
e247f0db73 | |
|
|
80f5c45e10 |
|
|
@ -26,9 +26,23 @@ async def init_db():
|
|||
from models.document import Document
|
||||
from models.generation_log import GenerationLog
|
||||
from models.reference_file import ReferenceFile
|
||||
from models.template_block import TemplateBlock
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
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")])
|
||||
if "supports_streaming" not in columns:
|
||||
if dialect_name == "sqlite":
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ class GenerationLog(Base):
|
|||
__tablename__ = "generation_logs"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
document_id = Column(Integer, ForeignKey("documents.id"), nullable=False)
|
||||
paragraph_id = Column(Integer, ForeignKey("paragraphs.id"), nullable=False)
|
||||
paragraph_id = Column(Integer, ForeignKey("paragraphs.id"), nullable=True)
|
||||
block_id = Column(Integer, ForeignKey("template_blocks.id"), nullable=True, comment="块级生成日志关联")
|
||||
model_id = Column(Integer, ForeignKey("ai_models.id"), nullable=True)
|
||||
status = Column(String(20), default="pending", comment="pending/generating/success/failed")
|
||||
content = Column(Text, default="", comment="生成的内容")
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ class TemplateBlock(Base):
|
|||
sort_index = Column(Integer, default=0, comment="排序")
|
||||
block_type = Column(String(30), default="text", comment="heading/text/table/ai_slot/variable")
|
||||
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="块标题")
|
||||
content_json = Column(Text, default="{}", comment="块内容 JSON")
|
||||
style_json = Column(Text, default="{}", comment="块样式 JSON")
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ from models.document import Document
|
|||
from models.generation_log import GenerationLog
|
||||
from models.paragraph import Paragraph
|
||||
from models.template import Template
|
||||
from services.document_export import export_document_bytes
|
||||
from models.template_block import TemplateBlock
|
||||
from services.document_export import export_document_bytes, export_document_bytes_from_blocks
|
||||
from services.minio_client import (
|
||||
download_object_bytes,
|
||||
get_presigned_url,
|
||||
|
|
@ -74,6 +75,78 @@ 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")
|
||||
async def export_pdf(document_id: int):
|
||||
return PlainTextResponse(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
|
|
@ -17,14 +18,17 @@ from models.generation_log import GenerationLog
|
|||
from models.paragraph import Paragraph
|
||||
from models.reference_file import ReferenceFile
|
||||
from models.template import Template
|
||||
from models.template_block import TemplateBlock
|
||||
from schemas.schemas import GenerateFullRequest, GenerateTestRequest, ReferenceFileUpdate, Response
|
||||
from services.ai_service import call_ai, stream_ai_preview
|
||||
from services.file_summary import summarize_minio_files
|
||||
from services.generation_runtime import (
|
||||
build_mock_content,
|
||||
build_mock_content_for_block,
|
||||
generation_progress,
|
||||
request_cancel,
|
||||
run_generation,
|
||||
run_generation_for_blocks,
|
||||
update_progress,
|
||||
)
|
||||
from services.minio_client import delete_object, split_bucket_path, upload_bytes, get_presigned_url
|
||||
|
|
@ -368,6 +372,45 @@ async def generate_full(body: GenerateFullRequest, db: AsyncSession = Depends(ge
|
|||
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}")
|
||||
async def generate_progress(document_id: int):
|
||||
async def event_generator():
|
||||
|
|
@ -387,6 +430,86 @@ async def generate_progress(document_id: int):
|
|||
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")
|
||||
async def list_documents(
|
||||
page: int = Query(1, ge=1),
|
||||
|
|
@ -410,6 +533,7 @@ async def get_document(document_id: int, db: AsyncSession = Depends(get_db)):
|
|||
if document is None:
|
||||
raise HTTPException(status_code=404, detail="生成记录不存在")
|
||||
|
||||
# 查询段落级日志
|
||||
log_result = await db.execute(
|
||||
select(GenerationLog, Paragraph)
|
||||
.join(Paragraph, Paragraph.id == GenerationLog.paragraph_id)
|
||||
|
|
@ -431,6 +555,52 @@ async def get_document(document_id: int, db: AsyncSession = Depends(get_db)):
|
|||
|
||||
payload = _serialize_document(document)
|
||||
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)
|
||||
|
||||
|
||||
|
|
@ -460,3 +630,22 @@ async def delete_document(document_id: int, db: AsyncSession = Depends(get_db)):
|
|||
await db.delete(document)
|
||||
await db.commit()
|
||||
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)})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
|
|
@ -15,9 +16,11 @@ from models.document import Document
|
|||
from models.generation_log import GenerationLog
|
||||
from models.paragraph import Paragraph
|
||||
from models.template import Template
|
||||
from models.template_block import TemplateBlock
|
||||
from schemas.schemas import Response, TemplateSave
|
||||
from services.minio_client import minio_client
|
||||
from services.template_parser import parse_template
|
||||
from services.word_to_html import docx_to_full_html, docx_to_html_blocks
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -64,6 +67,32 @@ 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("")
|
||||
async def list_templates(
|
||||
page: int = Query(1, ge=1),
|
||||
|
|
@ -106,6 +135,105 @@ async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
|
|||
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")
|
||||
async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||
if not file.filename:
|
||||
|
|
@ -127,6 +255,8 @@ async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depen
|
|||
|
||||
try:
|
||||
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:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
|
@ -167,13 +297,37 @@ async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depen
|
|||
db.add(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.refresh(template)
|
||||
for paragraph in paragraph_rows:
|
||||
await db.refresh(paragraph)
|
||||
for br in block_rows:
|
||||
await db.refresh(br)
|
||||
|
||||
payload = _serialize_template(template)
|
||||
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)
|
||||
|
||||
|
||||
|
|
@ -230,6 +384,86 @@ async def save_template_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}")
|
||||
async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)):
|
||||
template = await db.get(Template, template_id)
|
||||
|
|
@ -240,6 +474,9 @@ async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)):
|
|||
paragraphs_to_delete = result.scalars().all()
|
||||
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))
|
||||
documents_to_delete = doc_result.scalars().all()
|
||||
|
||||
|
|
@ -256,6 +493,9 @@ async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)):
|
|||
for paragraph in paragraphs_to_delete:
|
||||
await db.delete(paragraph)
|
||||
|
||||
for block in blocks_to_delete:
|
||||
await db.delete(block)
|
||||
|
||||
file_path = template.file_path or ""
|
||||
if "/" in file_path:
|
||||
bucket, object_name = file_path.split("/", 1)
|
||||
|
|
|
|||
|
|
@ -388,3 +388,111 @@ def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
|
|||
output = BytesIO()
|
||||
document.save(output)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def export_document_bytes_from_blocks(template_bytes: bytes, blocks: list[dict], logs: list[dict]) -> bytes:
|
||||
"""基于块结构的文档导出,支持块排序。
|
||||
|
||||
blocks: TemplateBlock 序列化列表,每个包含 anchor_start_index、edit_mode、sort_index 等
|
||||
logs: GenerationLog 序列化列表,每个包含 block_id、content、status
|
||||
"""
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from models.document import Document
|
|||
from models.generation_log import GenerationLog
|
||||
from models.paragraph import Paragraph
|
||||
from models.template import Template
|
||||
from models.template_block import TemplateBlock
|
||||
from services.ai_service import call_ai
|
||||
from services.file_summary import summarize_minio_files
|
||||
|
||||
|
|
@ -195,3 +196,160 @@ async def run_generation(document_id: int, template_id: int):
|
|||
update_progress(document_id, status="failed", message=str(error), done=done_count, total=total)
|
||||
finally:
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,673 @@
|
|||
"""将 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 " "
|
||||
content_parts.append(f'<div style="{para_css};margin:0;">{inner_html}</div>')
|
||||
|
||||
cell_html = "".join(content_parts) if content_parts else " "
|
||||
|
||||
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)
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# 任务执行摘要
|
||||
|
||||
## 会话 ID: 2
|
||||
- [2026-07-06 00:22:56]
|
||||
- **执行原因**: 继续实施可视化区块编辑器重构任务拆解清单中的剩余项目
|
||||
- **执行过程**:
|
||||
1. 完成 GeneratePage.vue 块模式适配(fullV2 API 方法 + TypeScript 修复)。
|
||||
2. 重构 PreviewEdit.vue 支持段落/块双模式:块模式使用 DocRenderer 渲染 HTML 快照、块列表显示状态、单块重新生成、恢复默认内容。
|
||||
3. 新增后端 regenerateBlock API(POST /generate/blocks/{id}/regenerate)用于单块 AI 重新生成。
|
||||
4. 新增后端 resetBlock API(DELETE /generate/blocks/{id}/reset/{docId})用于恢复块为模板默认。
|
||||
5. 增强 GET /documents/{id} 接口返回块级数据(template_blocks + block_logs)。
|
||||
6. 实现列表项渲染(检测 Word w:numPr,支持 ordered/unordered,CSS ::before 标记)。
|
||||
7. 实现块保存时 html_snippet 快照同步(注入 block-ai/block-fixed CSS class)。
|
||||
8. 在 TemplateEditor 中实现块拖拽排序(dragstart/dragover/drop 事件,排序后自动保存)。
|
||||
- **执行结果**:
|
||||
- 前后端 TypeScript/Python 编译均通过。
|
||||
- 任务清单中完成项:生成页块模式、预览页块模式升级、列表渲染、快照同步、拖拽排序、单块重生成、恢复默认。
|
||||
- 更新了任务拆解清单文档,标记 10+ 个已完成项。
|
||||
- 剩余主要未完成项:图片 MinIO 上传、页眉页脚、树形块结构、变量块、Semaphore 限流、文件块级绑定、导出验证、历史页增强。
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
# 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/ordered,CSS ::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 周)
|
||||
|
||||
### 后端:块存储 API(2-3 天)
|
||||
- [x] 新建/修改 `routers/templates.py` 中的块保存接口 `PUT /api/v1/templates/{id}/blocks`
|
||||
- [x] 接口接收 `list[TemplateBlock]`,批量 upsert(新增/更新/删除)
|
||||
- [x] 旧 `paragraphs` 接口保留兼容,内部转换到新块结构
|
||||
- [x] 模板详情返回时优先返回 blocks,fallback 到 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 转换脚本,历史数据只读 |
|
||||
|
|
@ -9,11 +9,15 @@ export const generateApi = {
|
|||
deleteReferenceFile: (id: number) => http.delete(`/generate/reference-files/${id}`),
|
||||
downloadReferenceFile: (id: number) => http.get(`/generate/reference-files/${id}/download`),
|
||||
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}`,
|
||||
cancel: (id: number) => http.post(`/generate/cancel/${id}`),
|
||||
documents: (params?: any) => http.get('/generate/documents', { params }),
|
||||
getDocument: (id: number) => http.get(`/generate/documents/${id}`),
|
||||
deleteDocument: (id: number) => http.delete(`/generate/documents/${id}`),
|
||||
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`,
|
||||
regenerateBlock: (blockId: number, data: any) => http.post(`/generate/blocks/${blockId}/regenerate`, data),
|
||||
resetBlock: (blockId: number, documentId: number) => http.delete(`/generate/blocks/${blockId}/reset/${documentId}`),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,4 +6,8 @@ export const templateApi = {
|
|||
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),
|
||||
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 }),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,437 @@
|
|||
<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>
|
||||
|
|
@ -41,5 +41,23 @@ export interface ReferenceFile {
|
|||
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 PageData<T = any> { items: T[]; total: number; page: number; page_size: number }
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@
|
|||
<span>执行生成</span>
|
||||
<span>/</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 class="gen-layout">
|
||||
|
|
@ -51,9 +56,12 @@
|
|||
<div class="gen-right">
|
||||
<div class="panel-card fill-card">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">段落文件配置</div>
|
||||
<div class="panel-title">{{ genMode === 'block' ? 'AI 块列表' : '段落文件配置' }}</div>
|
||||
<a-tag v-if="genMode === 'block'" color="purple">{{ aiBlockCount }} 个 AI 块待生成</a-tag>
|
||||
</div>
|
||||
<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 class="para-info">
|
||||
<span class="idx">{{ paragraph.sort_index }}</span>
|
||||
|
|
@ -71,12 +79,29 @@
|
|||
</div>
|
||||
<span v-else class="no-file-tag">无需上传</span>
|
||||
</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 class="action-bar">
|
||||
<span>{{ fileCount }}/{{ needFileCount }} 个文件已上传</span>
|
||||
<a-button type="primary" size="large" :loading="generating" @click="startGen">立即生成</a-button>
|
||||
<span>{{ genMode === 'block' ? `${aiBlockCount} 个 AI 块待生成` : `${fileCount}/${needFileCount} 个文件已上传` }}</span>
|
||||
<a-button type="primary" size="large" :loading="generating" @click="startGen" :disabled="genMode === 'block' && aiBlockCount === 0">立即生成</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -90,6 +115,9 @@ import { message } from 'ant-design-vue'
|
|||
import { useTemplateStore } from '@/stores/template'
|
||||
import { useDocumentStore } from '@/stores/document'
|
||||
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 router = useRouter()
|
||||
|
|
@ -102,11 +130,17 @@ const selectedTplId = ref<number | undefined>(undefined)
|
|||
const uploadedFiles = ref<Record<number, Array<{ file_name: string; file_path: string }>>>({})
|
||||
const generating = ref(false)
|
||||
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 autoCount = computed(() => paragraphs.value.filter((item) => item.edit_mode === 'ai').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 aiBlocks = computed(() => previewBlocks.value.filter(b => b.edit_mode === 'ai'))
|
||||
const aiBlockCount = computed(() => aiBlocks.value.length)
|
||||
|
||||
watch(
|
||||
uploadedFiles,
|
||||
|
|
@ -144,6 +178,18 @@ async function onTplChange(id: number) {
|
|||
}
|
||||
uploadedFiles.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() {
|
||||
|
|
@ -151,6 +197,27 @@ async function startGen() {
|
|||
message.warning('请先选择模板')
|
||||
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])
|
||||
if (missing.length) {
|
||||
message.warning('还有必传文件未上传')
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
<div class="detail-actions">
|
||||
<a-button v-if="isRunning" danger @click="cancelTask">取消任务</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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -33,6 +34,69 @@
|
|||
<div class="status-message">{{ progressMessage || documentInfo.error || '任务已创建,等待执行。' }}</div>
|
||||
</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">
|
||||
<aside class="detail-left">
|
||||
<a-card class="left-card" title="段落列表">
|
||||
|
|
@ -82,15 +146,18 @@
|
|||
</a-card>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useDocumentStore } from '@/stores/document'
|
||||
import { generateApi } from '@/api/generate'
|
||||
import { templateApi } from '@/api/template'
|
||||
import DocRenderer from '@/components/DocRenderer.vue'
|
||||
|
||||
interface ContentBlock {
|
||||
type: string
|
||||
|
|
@ -117,8 +184,17 @@ const progressMessage = ref('')
|
|||
const selectedParagraphId = ref<number | 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 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 logStatusMap = computed(() =>
|
||||
Object.fromEntries(logs.value.map((item) => [item.paragraph_id, item.status]))
|
||||
|
|
@ -149,6 +225,11 @@ function statusBadge(status: string) {
|
|||
return 'processing'
|
||||
}
|
||||
|
||||
function blockTypeLabel(type: string) {
|
||||
const map: Record<string, string> = { heading: '标题', text: '正文', table: '表格', image: '图片' }
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
function renderTable(block: ContentBlock) {
|
||||
const headers = block.headers || []
|
||||
const rows = block.rows || []
|
||||
|
|
@ -173,9 +254,17 @@ async function loadDocument() {
|
|||
const response: any = await generateApi.getDocument(id)
|
||||
documentInfo.value = response.data || {}
|
||||
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
|
||||
}
|
||||
if (isBlockMode.value && !selectedBlockId.value) {
|
||||
selectedBlockId.value = templateBlocks.value[0]?.id || null
|
||||
}
|
||||
if (
|
||||
selectedParagraphId.value &&
|
||||
!paragraphMappings.value.some((item: any) => item.paragraph_id === selectedParagraphId.value) &&
|
||||
|
|
@ -185,10 +274,72 @@ 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) {
|
||||
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() {
|
||||
const id = Number(route.params.id)
|
||||
if (progressSource) progressSource.close()
|
||||
|
|
@ -219,6 +370,10 @@ function exportDocx() {
|
|||
window.open(generateApi.exportDocx(Number(route.params.id)))
|
||||
}
|
||||
|
||||
function exportDocxV2() {
|
||||
window.open(generateApi.exportDocxV2(Number(route.params.id)))
|
||||
}
|
||||
|
||||
function exportPdf() {
|
||||
window.open(generateApi.exportPdf(Number(route.params.id)))
|
||||
}
|
||||
|
|
@ -226,6 +381,9 @@ function exportPdf() {
|
|||
onMounted(async () => {
|
||||
try {
|
||||
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)
|
||||
if (isRunning.value) bindProgress()
|
||||
} catch (error: any) {
|
||||
|
|
@ -300,13 +458,23 @@ onBeforeUnmount(() => {
|
|||
color: #111827;
|
||||
}
|
||||
|
||||
.status-card,
|
||||
.preview-card {
|
||||
.status-card {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
margin-bottom: 0;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.detail-layout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
|
@ -335,12 +503,111 @@ onBeforeUnmount(() => {
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
/* ===== 块列表样式 ===== */
|
||||
.block-list {
|
||||
display: grid;
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
|
|
@ -458,6 +725,7 @@ onBeforeUnmount(() => {
|
|||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* ===== 深度样式 ===== */
|
||||
:deep(.left-card .ant-card-body) {
|
||||
height: calc(100% - 57px);
|
||||
overflow-y: auto;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
<span class="editor-title">{{ templateName }}</span>
|
||||
<a-radio-group v-model:value="editorMode" size="small" button-style="solid">
|
||||
<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-group>
|
||||
<span class="flex-spacer" />
|
||||
|
|
@ -17,11 +18,15 @@
|
|||
|
||||
<div class="editor-layout">
|
||||
<aside class="editor-left">
|
||||
<div class="left-head">
|
||||
<div class="left-head" v-if="editorMode !== 'visual'">
|
||||
段落列表
|
||||
<span class="left-head-tip">(点击定位)</span>
|
||||
</div>
|
||||
<div class="left-scroll">
|
||||
<div class="left-head" v-else>
|
||||
块列表
|
||||
<span class="left-head-tip">({{ previewBlocks.length }} 个块)</span>
|
||||
</div>
|
||||
<div class="left-scroll" v-if="editorMode !== 'visual'">
|
||||
<div
|
||||
v-for="paragraph in paragraphs"
|
||||
:key="paragraph.id"
|
||||
|
|
@ -46,6 +51,29 @@
|
|||
</span>
|
||||
</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>
|
||||
|
||||
<section class="editor-center">
|
||||
|
|
@ -69,7 +97,19 @@
|
|||
</div>
|
||||
|
||||
<div class="center-scroll">
|
||||
<div class="doc-edit-page">
|
||||
<div v-if="editorMode === 'visual'" class="doc-visual-wrap">
|
||||
<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'">
|
||||
<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>
|
||||
|
|
@ -141,10 +181,12 @@
|
|||
|
||||
<aside class="editor-right">
|
||||
<div class="right-head">
|
||||
<span>段落配置</span>
|
||||
<span class="right-head-name">{{ selectedPara?.title || '' }}</span>
|
||||
<span v-if="editorMode !== 'visual'">段落配置</span>
|
||||
<span v-else>块配置</span>
|
||||
<span class="right-head-name">{{ editorMode === 'visual' ? selectedBlockTitle : (selectedPara?.title || '') }}</span>
|
||||
</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-title">生成设置</div>
|
||||
<a-form layout="vertical">
|
||||
|
|
@ -231,6 +273,53 @@
|
|||
立即测试
|
||||
</a-button>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
|
@ -300,6 +389,8 @@ import { useModelStore } from '@/stores/model'
|
|||
import { generateApi } from '@/api/generate'
|
||||
import { templateApi } from '@/api/template'
|
||||
import ReferenceFileSelector from '@/components/ReferenceFileSelector.vue'
|
||||
import DocRenderer from '@/components/DocRenderer.vue'
|
||||
import type { TemplateBlock } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
|
@ -310,7 +401,16 @@ const paragraphs = ref<any[]>([])
|
|||
const models = ref<any[]>([])
|
||||
const selectedId = ref(0)
|
||||
const templateName = ref('模板编辑')
|
||||
const editorMode = ref<'paragraph' | 'manual'>('paragraph')
|
||||
const editorMode = ref<'paragraph' | 'visual' | '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 testStep = ref(0)
|
||||
|
|
@ -324,6 +424,14 @@ const streamedText = ref('')
|
|||
|
||||
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) {
|
||||
selectedId.value = id
|
||||
}
|
||||
|
|
@ -646,6 +754,185 @@ 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 () => {
|
||||
const id = Number(route.params.id)
|
||||
const template = await store.fetchOne(id)
|
||||
|
|
@ -656,6 +943,8 @@ onMounted(async () => {
|
|||
if (paragraphs.value.length) {
|
||||
selectedId.value = paragraphs.value[0].id
|
||||
}
|
||||
// 预加载可视化预览数据
|
||||
loadPreview()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -799,6 +1088,13 @@ onMounted(async () => {
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.doc-visual-wrap {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.para-block {
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
|
|
@ -982,6 +1278,11 @@ onMounted(async () => {
|
|||
font-weight: 500;
|
||||
}
|
||||
|
||||
.para-list-item.drag-over {
|
||||
border-top: 2px solid #5b5bd6;
|
||||
background: #f0f0ff;
|
||||
}
|
||||
|
||||
.pli-index {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
|
|
|||
Loading…
Reference in New Issue