From c84ec6aa71915426d79b3ddc75fbdbfd79cc8c84 Mon Sep 17 00:00:00 2001 From: zwt13703 Date: Sun, 5 Jul 2026 23:35:00 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E5=9C=A8=E7=BA=BF=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E4=B8=8E=E5=AF=BC=E5=87=BA=E9=93=BE=E8=B7=AF=E9=87=8D?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/database.py | 4 + backend/models/__init__.py | 1 + backend/models/template_block.py | 30 + backend/routers/export.py | 94 ++- backend/routers/templates.py | 316 ++++++++++- backend/schemas/schemas.py | 24 + backend/services/document_export.py | 73 +++ backend/services/template_parser.py | 126 +++-- docs/tasks/task_detail_2026_07_05.md | 143 +++++ .../05-模板在线编辑重构任务拆解清单.md | 138 +++++ .../需求与设计/05-模板在线编辑重构增量SQL.sql | 28 + init.sql | 29 + web/src/stores/template.ts | 36 +- web/src/types/index.ts | 24 + web/src/views/TemplateEditor.vue | 534 ++++++++++++++---- 15 files changed, 1434 insertions(+), 166 deletions(-) create mode 100644 backend/models/template_block.py create mode 100644 docs/tasks/task_detail_2026_07_05.md create mode 100644 docs/需求与设计/05-模板在线编辑重构任务拆解清单.md create mode 100644 docs/需求与设计/05-模板在线编辑重构增量SQL.sql diff --git a/backend/database.py b/backend/database.py index a409e87..ffc9fdb 100644 --- a/backend/database.py +++ b/backend/database.py @@ -22,6 +22,7 @@ async def get_db(): async def init_db(): from models.template import Template from models.paragraph import Paragraph + from models.template_block import TemplateBlock from models.ai_model import AiModel from models.document import Document from models.generation_log import GenerationLog @@ -53,3 +54,6 @@ async def init_db(): await conn.execute(text("UPDATE paragraphs SET anchor_title = title WHERE anchor_title = '' OR anchor_title IS NULL")) if "write_mode" not in paragraph_columns: await conn.execute(text("ALTER TABLE paragraphs ADD COLUMN write_mode VARCHAR(30) DEFAULT 'replace_section'")) + block_tables = await conn.run_sync(lambda sync_conn: inspect(sync_conn).get_table_names()) + if "template_blocks" not in block_tables: + await conn.run_sync(lambda sync_conn: TemplateBlock.__table__.create(sync_conn)) diff --git a/backend/models/__init__.py b/backend/models/__init__.py index d79adf4..e7c3651 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -1,5 +1,6 @@ from models.template import Template from models.paragraph import Paragraph +from models.template_block import TemplateBlock from models.ai_model import AiModel from models.document import Document from models.generation_log import GenerationLog diff --git a/backend/models/template_block.py b/backend/models/template_block.py new file mode 100644 index 0000000..6bb7960 --- /dev/null +++ b/backend/models/template_block.py @@ -0,0 +1,30 @@ +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, func + +from database import Base + + +class TemplateBlock(Base): + __tablename__ = "template_blocks" + + id = Column(Integer, primary_key=True, autoincrement=True) + template_id = Column(Integer, ForeignKey("templates.id"), nullable=False) + source_paragraph_id = Column(Integer, ForeignKey("paragraphs.id"), nullable=True) + parent_block_id = Column(Integer, ForeignKey("template_blocks.id"), nullable=True) + 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="原始锚点引用") + title = Column(String(500), default="", comment="块标题") + content_json = Column(Text, default="{}", comment="块内容 JSON") + style_json = Column(Text, default="{}", comment="块样式 JSON") + edit_mode = Column(String(20), default="manual", comment="manual/ai") + placeholder_key = Column(String(120), default="", comment="AI 占位键") + variable_key = Column(String(120), default="", comment="变量键") + default_value = Column(Text, default="", comment="默认值") + model_id = Column(Integer, ForeignKey("ai_models.id"), nullable=True, comment="指定模型") + need_prompt = Column(Boolean, default=True, comment="是否需要提示词") + prompt_text = Column(Text, default="", comment="预设提示词") + need_file = Column(Boolean, default=False, comment="是否需要上传参考文件") + file_note = Column(Text, default="", comment="参考文件说明") + output_format = Column(String(20), default="text", comment="text/table/mixed/chart") + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/backend/routers/export.py b/backend/routers/export.py index 9d3e3ae..e4c2c66 100644 --- a/backend/routers/export.py +++ b/backend/routers/export.py @@ -15,6 +15,7 @@ from models.document import Document from models.generation_log import GenerationLog from models.paragraph import Paragraph from models.template import Template +from models.template_block import TemplateBlock from services.document_export import export_document_bytes from services.minio_client import ( download_object_bytes, @@ -26,6 +27,46 @@ from services.minio_client import ( router = APIRouter() +def _block_text_content(block: TemplateBlock) -> str: + try: + payload = json.loads(block.content_json or "{}") + except Exception: + payload = {} + return payload.get("text") or block.default_value or "" + + +def _block_table_content(block: TemplateBlock) -> dict: + try: + payload = json.loads(block.content_json or "{}") + except Exception: + payload = {} + return payload.get("table") or {} + + +def _build_block_export_content(block: TemplateBlock) -> dict: + if block.block_type == "table": + table_data = _block_table_content(block) + matrix = table_data.get("data") or [] + headers = matrix[0] if matrix else [] + rows = matrix[1:] if len(matrix) > 1 else [] + return {"content": [{"type": "table", "headers": headers, "rows": rows}]} + return {"content": [{"type": "text", "text": _block_text_content(block)}]} + + +def _resolve_block_write_modes(blocks: list[TemplateBlock]) -> list[str]: + modes: list[str] = [] + anchor_counter: dict[str, int] = {} + for block in blocks: + if block.block_type == "heading": + modes.append("replace_heading_only") + continue + anchor = (block.anchor_ref or block.title or "").strip() + seen = anchor_counter.get(anchor, 0) + modes.append("replace_section" if seen == 0 else "append_after_heading") + anchor_counter[anchor] = seen + 1 + return modes + + @router.get("/{document_id}/docx") async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)): document = await db.get(Document, document_id) @@ -39,22 +80,49 @@ async def export_docx(document_id: int, db: AsyncSession = Depends(get_db)): template_bucket, template_object = split_bucket_path(template.file_path) template_bytes = await asyncio.to_thread(download_object_bytes, template_bucket, template_object) - result = await db.execute( - select(GenerationLog, Paragraph) - .join(Paragraph, Paragraph.id == GenerationLog.paragraph_id) - .where(GenerationLog.document_id == document_id) - .order_by(Paragraph.sort_index.asc(), Paragraph.id.asc()) + block_result = await db.execute( + select(TemplateBlock) + .where(TemplateBlock.template_id == template.id) + .order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc()) ) + blocks = block_result.scalars().all() + + log_result = await db.execute( + select(GenerationLog).where(GenerationLog.document_id == document_id) + ) + generation_logs = log_result.scalars().all() + log_map = {item.paragraph_id: json.loads(item.content) if item.content else {"content": []} for item in generation_logs} + logs = [] - for log, paragraph in result.all(): - logs.append( - { - "anchor_title": paragraph.anchor_title or paragraph.title, - "title": paragraph.title, - "write_mode": paragraph.write_mode, - "content": json.loads(log.content) if log.content else {"content": []}, - } + if blocks: + write_modes = _resolve_block_write_modes(blocks) + for block, write_mode in zip(blocks, write_modes): + generated_content = log_map.get(block.source_paragraph_id) if block.source_paragraph_id else None + content = generated_content if (block.edit_mode == "ai" or block.block_type == "ai_slot") and generated_content else _build_block_export_content(block) + logs.append( + { + "anchor_title": block.anchor_ref or block.title, + "title": block.title, + "write_mode": write_mode, + "content": content, + } + ) + else: + result = await db.execute( + select(GenerationLog, Paragraph) + .join(Paragraph, Paragraph.id == GenerationLog.paragraph_id) + .where(GenerationLog.document_id == document_id) + .order_by(Paragraph.sort_index.asc(), Paragraph.id.asc()) ) + for log, paragraph in result.all(): + logs.append( + { + "anchor_title": paragraph.anchor_title or paragraph.title, + "title": paragraph.title, + "write_mode": paragraph.write_mode, + "content": json.loads(log.content) if log.content else {"content": []}, + } + ) exported_bytes = await asyncio.to_thread(export_document_bytes, template_bytes, logs) object_name = f"{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}.docx" diff --git a/backend/routers/templates.py b/backend/routers/templates.py index 8651a10..74fbcae 100644 --- a/backend/routers/templates.py +++ b/backend/routers/templates.py @@ -1,4 +1,5 @@ import asyncio +import json import os import tempfile import uuid @@ -15,8 +16,10 @@ from models.document import Document from models.generation_log import GenerationLog from models.paragraph import Paragraph from models.template import Template +from models.template_block import TemplateBlock from schemas.schemas import Response, TemplateSave -from services.minio_client import minio_client +from services.document_export import export_document_bytes +from services.minio_client import download_object_bytes, minio_client, split_bucket_path, upload_bytes from services.template_parser import parse_template router = APIRouter() @@ -64,6 +67,270 @@ def _serialize_template(template: Template) -> dict: } +def _build_block_from_paragraph(paragraph: Paragraph) -> dict: + block_type = "heading" if paragraph.write_mode == "replace_heading_only" else ("ai_slot" if paragraph.edit_mode == "ai" else ("table" if paragraph.is_table else "text")) + content_json = json.dumps({ + "text": paragraph.content or "", + "table": json.loads(paragraph.table_json or "{}") if paragraph.is_table else None, + }, ensure_ascii=False) + return { + "source_paragraph_id": paragraph.id, + "parent_block_id": None, + "sort_index": paragraph.sort_index, + "block_type": block_type, + "anchor_ref": paragraph.anchor_title or paragraph.title, + "title": paragraph.title, + "content_json": content_json, + "style_json": paragraph.style_json or "{}", + "edit_mode": paragraph.edit_mode, + "placeholder_key": "", + "variable_key": "", + "default_value": paragraph.content or "", + "model_id": paragraph.model_id, + "need_prompt": paragraph.need_prompt, + "prompt_text": paragraph.prompt_text, + "need_file": paragraph.need_file, + "file_note": paragraph.file_note, + "output_format": paragraph.output_format, + } + + +def _build_block_from_parsed_item(item, source_paragraph_id: int | None) -> dict: + content_json = json.dumps({ + "text": item.content or "", + "table": json.loads(item.table_json or "{}") if item.is_table else None, + }, ensure_ascii=False) + return { + "source_paragraph_id": source_paragraph_id, + "parent_block_id": None, + "sort_index": item.sort_index, + "block_type": item.block_type, + "anchor_ref": item.anchor_title or item.title, + "title": item.title, + "content_json": content_json, + "style_json": item.style_json or "{}", + "edit_mode": item.edit_mode, + "placeholder_key": item.placeholder_key, + "variable_key": item.variable_key, + "default_value": item.default_value, + "model_id": None, + "need_prompt": True, + "prompt_text": "", + "need_file": False, + "file_note": "", + "output_format": item.output_format, + } + + +def _serialize_block(block: TemplateBlock) -> dict: + try: + content_json = json.loads(block.content_json or "{}") + except Exception: + content_json = {} + return { + "id": block.id, + "template_id": block.template_id, + "source_paragraph_id": block.source_paragraph_id, + "parent_block_id": block.parent_block_id, + "sort_index": block.sort_index, + "block_type": block.block_type, + "anchor_ref": block.anchor_ref, + "title": block.title, + "content_json": content_json, + "style_json": block.style_json, + "edit_mode": block.edit_mode, + "placeholder_key": block.placeholder_key, + "variable_key": block.variable_key, + "default_value": block.default_value, + "model_id": block.model_id, + "need_prompt": block.need_prompt, + "prompt_text": block.prompt_text, + "need_file": block.need_file, + "file_note": block.file_note, + "output_format": block.output_format, + } + + +async def _load_blocks(db: AsyncSession, template_id: int) -> list[TemplateBlock]: + result = await db.execute( + select(TemplateBlock) + .where(TemplateBlock.template_id == template_id) + .order_by(TemplateBlock.sort_index.asc(), TemplateBlock.id.asc()) + ) + return result.scalars().all() + + +async def _sync_blocks_from_paragraphs(db: AsyncSession, template_id: int, paragraphs: list[Paragraph]): + existing_blocks = await _load_blocks(db, template_id) + for block in existing_blocks: + await db.delete(block) + await db.flush() + + block_rows: list[TemplateBlock] = [] + for paragraph in paragraphs: + block = TemplateBlock(template_id=template_id, **_build_block_from_paragraph(paragraph)) + db.add(block) + block_rows.append(block) + await db.flush() + return block_rows + + +async def _save_blocks( + db: AsyncSession, + template_id: int, + blocks_payload, +): + existing_blocks = await _load_blocks(db, template_id) + block_map = {item.id: item for item in existing_blocks} + incoming_ids = {config.id for config in blocks_payload if config.id} + + for block in existing_blocks: + if block.id not in incoming_ids: + await db.delete(block) + + for index, config in enumerate(blocks_payload, start=1): + block = block_map.get(config.id) if config.id else None + if block is None: + block = TemplateBlock(template_id=template_id) + db.add(block) + block.source_paragraph_id = config.source_paragraph_id + block.parent_block_id = config.parent_block_id + block.sort_index = index + block.block_type = config.block_type + block.anchor_ref = config.anchor_ref or config.title + block.title = config.title + block.content_json = json.dumps(config.content_json or {}, ensure_ascii=False) + block.style_json = config.style_json or "{}" + block.edit_mode = config.edit_mode + block.placeholder_key = config.placeholder_key + block.variable_key = config.variable_key + block.default_value = config.default_value + block.model_id = config.model_id + block.need_prompt = config.need_prompt + block.prompt_text = config.prompt_text + block.need_file = config.need_file + block.file_note = config.file_note + block.output_format = config.output_format + + await db.flush() + + +def _block_text_content(block: TemplateBlock) -> str: + try: + payload = json.loads(block.content_json or "{}") + except Exception: + payload = {} + return payload.get("text") or block.default_value or "" + + +def _block_table_content(block: TemplateBlock) -> dict: + try: + payload = json.loads(block.content_json or "{}") + except Exception: + payload = {} + return payload.get("table") or {} + + +async def _sync_paragraphs_from_blocks(db: AsyncSession, template_id: int) -> list[Paragraph]: + paragraph_result = await db.execute( + select(Paragraph) + .where(Paragraph.template_id == template_id) + .order_by(Paragraph.sort_index.asc(), Paragraph.id.asc()) + ) + existing_paragraphs = paragraph_result.scalars().all() + paragraph_map = {item.id: item for item in existing_paragraphs} + blocks = await _load_blocks(db, template_id) + write_modes = _resolve_block_write_modes(blocks) + kept_paragraph_ids: set[int] = set() + synced_rows: list[Paragraph] = [] + + for index, (block, write_mode) in enumerate(zip(blocks, write_modes), start=1): + paragraph = paragraph_map.get(block.source_paragraph_id) if block.source_paragraph_id else None + if paragraph is None: + paragraph = Paragraph(template_id=template_id) + db.add(paragraph) + await db.flush() + paragraph.sort_index = index + paragraph.anchor_title = block.anchor_ref or block.title + paragraph.title = block.title + paragraph.content = _block_text_content(block) + paragraph.style_json = block.style_json or "{}" + paragraph.is_table = block.block_type == "table" + paragraph.table_json = json.dumps(_block_table_content(block), ensure_ascii=False) if paragraph.is_table else "{}" + paragraph.edit_mode = "ai" if block.block_type == "ai_slot" or block.edit_mode == "ai" else "manual" + paragraph.write_mode = write_mode + paragraph.model_id = block.model_id + paragraph.need_prompt = block.need_prompt + paragraph.prompt_text = block.prompt_text + paragraph.need_file = block.need_file + paragraph.file_note = block.file_note + paragraph.output_format = block.output_format + block.source_paragraph_id = paragraph.id + kept_paragraph_ids.add(paragraph.id) + synced_rows.append(paragraph) + + for paragraph in existing_paragraphs: + if paragraph.id in kept_paragraph_ids: + continue + await db.execute(delete(GenerationLog).where(GenerationLog.paragraph_id == paragraph.id)) + await db.delete(paragraph) + + await db.flush() + return synced_rows + + +def _build_export_content_from_block(block: TemplateBlock) -> dict: + if block.block_type == "table": + table_data = _block_table_content(block) + matrix = table_data.get("data") or [] + headers = matrix[0] if matrix else [] + rows = matrix[1:] if len(matrix) > 1 else [] + return {"content": [{"type": "table", "headers": headers, "rows": rows}]} + return {"content": [{"type": "text", "text": _block_text_content(block)}]} + + +def _resolve_block_write_modes(blocks: list[TemplateBlock]) -> list[str]: + modes: list[str] = [] + anchor_counter: dict[str, int] = {} + for block in blocks: + if block.block_type == "heading": + modes.append("replace_heading_only") + continue + anchor = (block.anchor_ref or block.title or "").strip() + seen = anchor_counter.get(anchor, 0) + modes.append("replace_section" if seen == 0 else "append_after_heading") + anchor_counter[anchor] = seen + 1 + return modes + + +async def _write_template_snapshot_to_docx(db: AsyncSession, template: Template): + blocks = await _load_blocks(db, template.id) + if not blocks: + return + write_modes = _resolve_block_write_modes(blocks) + logs = [] + for block, write_mode in zip(blocks, write_modes): + logs.append( + { + "anchor_title": block.anchor_ref or block.title, + "title": block.title, + "write_mode": write_mode, + "content": _build_export_content_from_block(block), + } + ) + + template_bucket, template_object = split_bucket_path(template.file_path) + template_bytes = await asyncio.to_thread(download_object_bytes, template_bucket, template_object) + exported_bytes = await asyncio.to_thread(export_document_bytes, template_bytes, logs) + await asyncio.to_thread( + upload_bytes, + template_bucket, + template_object, + exported_bytes, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + + @router.get("") async def list_templates( page: int = Query(1, ge=1), @@ -100,9 +367,16 @@ async def get_template(template_id: int, db: AsyncSession = Depends(get_db)): .where(Paragraph.template_id == template_id) .order_by(Paragraph.sort_index.asc(), Paragraph.id.asc()) ) - paragraphs = [_serialize_paragraph(item) for item in result.scalars().all()] + paragraph_rows = result.scalars().all() + paragraphs = [_serialize_paragraph(item) for item in paragraph_rows] + block_rows = await _load_blocks(db, template_id) + if not block_rows and paragraph_rows: + block_rows = await _sync_blocks_from_paragraphs(db, template_id, paragraph_rows) + await db.commit() + blocks = [_serialize_block(item) for item in block_rows] payload = _serialize_template(template) payload["paragraphs"] = paragraphs + payload["blocks"] = blocks return Response(data=payload) @@ -161,19 +435,35 @@ async def upload_template(file: UploadFile = File(...), db: AsyncSession = Depen style_json=item.style_json, is_table=item.is_table, table_json=item.table_json, - edit_mode="manual", + edit_mode=item.edit_mode, write_mode=item.write_mode, + need_prompt=item.edit_mode == "ai", + output_format=item.output_format, ) db.add(paragraph) paragraph_rows.append(paragraph) + await db.flush() + existing_blocks = await _load_blocks(db, template.id) + for block in existing_blocks: + await db.delete(block) + await db.flush() + block_rows: list[TemplateBlock] = [] + for item, paragraph in zip(parsed_items, paragraph_rows): + block = TemplateBlock(template_id=template.id, **_build_block_from_parsed_item(item, paragraph.id)) + db.add(block) + block_rows.append(block) + await db.flush() await db.commit() await db.refresh(template) for paragraph in paragraph_rows: await db.refresh(paragraph) + for block in block_rows: + await db.refresh(block) payload = _serialize_template(template) payload["paragraphs"] = [_serialize_paragraph(item) for item in paragraph_rows] + payload["blocks"] = [_serialize_block(item) for item in block_rows] return Response(data=payload) @@ -224,10 +514,24 @@ async def save_template_paragraphs( paragraph.file_note = config.file_note paragraph.output_format = config.output_format + await db.flush() + refreshed_result = await db.execute( + select(Paragraph) + .where(Paragraph.template_id == template_id) + .order_by(Paragraph.sort_index.asc(), Paragraph.id.asc()) + ) + refreshed_paragraphs = refreshed_result.scalars().all() + if body.save_mode == "manual" and body.blocks: + await _save_blocks(db, template_id, body.blocks) + refreshed_paragraphs = await _sync_paragraphs_from_blocks(db, template_id) + else: + await _sync_blocks_from_paragraphs(db, template_id, refreshed_paragraphs) + refreshed_paragraphs = await _sync_paragraphs_from_blocks(db, template_id) + template.paragraph_count = len(refreshed_paragraphs) + await _write_template_snapshot_to_docx(db, template) await db.commit() - template.paragraph_count = len(body.paragraphs) - await db.commit() - return Response(data={"template_id": template_id, "saved": len(body.paragraphs)}) + blocks = [_serialize_block(item) for item in await _load_blocks(db, template_id)] + return Response(data={"template_id": template_id, "saved": len(refreshed_paragraphs), "blocks": blocks}) @router.delete("/{template_id}") diff --git a/backend/schemas/schemas.py b/backend/schemas/schemas.py index 32f7b3a..b713c4d 100644 --- a/backend/schemas/schemas.py +++ b/backend/schemas/schemas.py @@ -43,8 +43,32 @@ class ParagraphConfig(BaseModel): file_note: str = "" output_format: str = "text" + +class TemplateBlockConfig(BaseModel): + id: int = 0 + source_paragraph_id: Optional[int] = None + parent_block_id: Optional[int] = None + sort_index: int = 0 + block_type: str = "text" + anchor_ref: str = "" + title: str = "" + content_json: dict[str, Any] = Field(default_factory=dict) + style_json: str = "{}" + edit_mode: str = "manual" + placeholder_key: str = "" + variable_key: str = "" + default_value: str = "" + model_id: Optional[int] = None + need_prompt: bool = True + prompt_text: str = "" + need_file: bool = False + file_note: str = "" + output_format: str = "text" + class TemplateSave(BaseModel): + save_mode: str = "paragraph" paragraphs: list[ParagraphConfig] = [] + blocks: list[TemplateBlockConfig] = [] # 模型 class AiModelCreate(BaseModel): diff --git a/backend/services/document_export.py b/backend/services/document_export.py index fdba938..34fb58f 100644 --- a/backend/services/document_export.py +++ b/backend/services/document_export.py @@ -69,6 +69,77 @@ def _remove_unreferenced_headings(document: DocumentObject, referenced_anchors: _delete_block(block) +def _ordered_unique_anchors(logs: list[dict]) -> list[str]: + ordered: list[str] = [] + seen: set[str] = set() + for item in logs: + anchor = (item.get("anchor_title") or item.get("title") or "").strip() + if not anchor or anchor in seen: + continue + seen.add(anchor) + ordered.append(anchor) + return ordered + + +def _reorder_heading_sections(document: DocumentObject, ordered_anchors: list[str]): + body = document.element.body + elements = list(body.iterchildren()) + pre_heading: list = [] + sections: list[tuple[str, list]] = [] + found_heading = False + index = 0 + + while index < len(elements): + child = elements[index] + if isinstance(child, CT_P): + paragraph = Paragraph(child, document) + if _is_heading(paragraph): + found_heading = True + anchor = paragraph.text.strip() + section_elements = [child] + index += 1 + while index < len(elements): + current = elements[index] + if isinstance(current, CT_P): + current_paragraph = Paragraph(current, document) + if _is_heading(current_paragraph): + break + section_elements.append(current) + index += 1 + sections.append((anchor, section_elements)) + continue + if not found_heading: + pre_heading.append(child) + index += 1 + + if not sections: + return + + section_map: dict[str, list[list]] = {} + for anchor, section_elements in sections: + section_map.setdefault(anchor, []).append(section_elements) + + all_section_elements = [element for _, section_elements in sections for element in section_elements] + for element in all_section_elements: + parent = element.getparent() + if parent is not None: + parent.remove(element) + + sect_pr = None + for child in list(body.iterchildren()): + if not isinstance(child, (CT_P, CT_Tbl)): + sect_pr = child + break + + for anchor in ordered_anchors: + for section_elements in section_map.pop(anchor, []): + for element in section_elements: + if sect_pr is not None: + sect_pr.addprevious(element) + else: + body.append(element) + + def _clear_paragraph(paragraph: Paragraph): element = paragraph._element for child in list(element): @@ -369,6 +440,8 @@ def _replace_section_group( def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes: document = Document(BytesIO(template_bytes)) + ordered_anchors = _ordered_unique_anchors(logs) + _reorder_heading_sections(document, ordered_anchors) referenced_anchors: set[str] = set() for item in logs: diff --git a/backend/services/template_parser.py b/backend/services/template_parser.py index 9a8ae40..792741f 100644 --- a/backend/services/template_parser.py +++ b/backend/services/template_parser.py @@ -1,4 +1,5 @@ import json +import re from collections.abc import Iterator from dataclasses import dataclass @@ -22,6 +23,12 @@ class ParsedParagraph: is_table: bool table_json: str write_mode: str + block_type: str = "text" + placeholder_key: str = "" + variable_key: str = "" + default_value: str = "" + edit_mode: str = "manual" + output_format: str = "text" def _iter_block_items(document: DocumentObject) -> Iterator[Paragraph | Table]: @@ -159,11 +166,35 @@ def _extract_table_data(table: Table) -> dict: } +PLACEHOLDER_PATTERN = re.compile(r"^\{\{\s*([a-zA-Z0-9_\-\.]+)\s*\}\}$") + + +def _build_block_title(text: str, fallback: str) -> str: + normalized = " ".join((text or "").split()) + if not normalized: + return fallback + return normalized[:24] + ("..." if len(normalized) > 24 else "") + + +def _classify_placeholder(text: str) -> tuple[str, str, str]: + matched = PLACEHOLDER_PATTERN.match(text.strip()) + if not matched: + return "text", "", "" + key = matched.group(1) + lowered = key.lower() + if any(token in lowered for token in ("summary", "opening", "section", "content", "analysis")): + return "ai_slot", key, "" + return "variable", "", key + + def parse_template(file_path: str) -> list[ParsedParagraph]: document = Document(file_path) parsed: list[ParsedParagraph] = [] - current_item: ParsedParagraph | None = None + current_heading: str | None = None + current_heading_style_json = "{}" loose_table_count = 0 + body_block_count = 0 + preface_count = 0 for block in _iter_block_items(document): if isinstance(block, Paragraph): @@ -173,52 +204,79 @@ def parse_template(file_path: str) -> list[ParsedParagraph]: level = _heading_level(block.style.name if block.style is not None else "") if level is not None: - current_item = ParsedParagraph( + current_heading = text + current_heading_style_json = json.dumps(_capture_paragraph_style(block, level), ensure_ascii=False) + body_block_count = 0 + parsed.append(ParsedParagraph( sort_index=len(parsed) + 1, anchor_title=text, title=text, content="", - style_json=json.dumps(_capture_paragraph_style(block, level), ensure_ascii=False), + style_json=current_heading_style_json, is_table=False, table_json="{}", - write_mode="replace_section", - ) - parsed.append(current_item) + write_mode="replace_heading_only", + block_type="heading", + edit_mode="manual", + output_format="text", + )) continue - if current_item is None: - current_item = ParsedParagraph( - sort_index=len(parsed) + 1, - anchor_title="未命名段落", - title="未命名段落", - content=text, - style_json=json.dumps(_capture_paragraph_style(block, 0), ensure_ascii=False), - is_table=False, - table_json="{}", - write_mode="replace_section", - ) - parsed.append(current_item) + block_type, placeholder_key, variable_key = _classify_placeholder(text) + if current_heading is None: + preface_count += 1 + anchor_title = f"文档起始_{preface_count}" + title = _build_block_title(text, anchor_title) + write_mode = "replace_section" else: - current_item.content = "\n".join(filter(None, [current_item.content, text])) + body_block_count += 1 + anchor_title = current_heading + title = _build_block_title(text, f"{current_heading}-正文{body_block_count}") + write_mode = "append_after_heading" + + parsed.append(ParsedParagraph( + sort_index=len(parsed) + 1, + anchor_title=anchor_title, + title=title, + content=text, + style_json=json.dumps(_capture_paragraph_style(block, 0), ensure_ascii=False), + is_table=False, + table_json="{}", + write_mode=write_mode, + block_type=block_type, + placeholder_key=placeholder_key, + variable_key=variable_key, + default_value="" if variable_key else text, + edit_mode="ai" if block_type == "ai_slot" else "manual", + output_format="text", + )) else: table_data = _extract_table_data(block) table_text = f"[表格] {table_data['rows']} 行 {table_data['cols']} 列" - if current_item is None: + if current_heading is None: loose_table_count += 1 - current_item = ParsedParagraph( - sort_index=len(parsed) + 1, - anchor_title=f"表格_{loose_table_count}", - title=f"表格_{loose_table_count}", - content=table_text, - style_json="{}", - is_table=True, - table_json=json.dumps(table_data, ensure_ascii=False), - write_mode="replace_section", - ) - parsed.append(current_item) + anchor_title = f"表格_{loose_table_count}" + title = anchor_title + write_mode = "replace_section" else: - current_item.is_table = True - current_item.table_json = json.dumps(table_data, ensure_ascii=False) - current_item.content = "\n".join(filter(None, [current_item.content, table_text])) + body_block_count += 1 + anchor_title = current_heading + title = f"{current_heading}-表格{body_block_count}" + write_mode = "append_after_heading" + + parsed.append(ParsedParagraph( + sort_index=len(parsed) + 1, + anchor_title=anchor_title, + title=title, + content=table_text, + style_json=current_heading_style_json if current_heading else "{}", + is_table=True, + table_json=json.dumps(table_data, ensure_ascii=False), + write_mode=write_mode, + block_type="table", + default_value=table_text, + edit_mode="manual", + output_format="table", + )) return parsed diff --git a/docs/tasks/task_detail_2026_07_05.md b/docs/tasks/task_detail_2026_07_05.md new file mode 100644 index 0000000..33ab6ae --- /dev/null +++ b/docs/tasks/task_detail_2026_07_05.md @@ -0,0 +1,143 @@ +# 任务执行摘要 + +## 会话 ID: local-20260705193723 +- [2026-07-05 19:37:23] +- **执行原因**: 用户询问“执行生成”功能当前是如何实现文档导出的,希望梳理从提交生成到导出 Word 的实际代码链路。 +- **执行过程**: + 1. 检查执行生成页 `GeneratePage.vue`,确认前端提交任务的入口与跳转路径。 + 2. 检查后端 `generate.py` 与 `generation_runtime.py`,确认生成任务创建、后台执行和段落结果落库方式。 + 3. 检查预览页 `PreviewEdit.vue`、导出 API `export.py` 与 `document_export.py`,确认导出 Word 的触发点、模板读取方式和内容写回逻辑。 +- **执行结果**: 已确认“执行生成”页本身只负责创建后台生成任务;真正的 DOCX 导出发生在任务详情页点击“导出 Word”后,由后端从 MinIO 拉取原始模板、读取 `generation_logs`、按标题锚点写回内容,再上传导出文件并返回预签名下载链接。 + +## 会话 ID: local-20260705194530 +- [2026-07-05 19:45:30] +- **执行原因**: 用户要求先输出“在线编辑 + AI 选区 + 导出重构”的整体方案、任务清单,并评估是否可行实现。 +- **执行过程**: + 1. 参考现有 `03-任务拆解清单.md` 与 `04-后续迭代任务拆解清单.md` 的拆解风格,整理适合当前项目的阶段方案。 + 2. 结合现有模板解析、模板编辑与导出实现,识别当前架构与目标方案之间的差距。 + 3. 输出分阶段实施建议,并评估技术可行性、实现难点与优先级。 +- **执行结果**: 已形成可执行的重构方案:以“块级在线编辑 + AI 选区标记 + 模板源同步写回 + 精确导出”为主线,建议分为编辑器重构、块模型升级、写回引擎重构、生成链路适配与联调验收五个阶段推进;整体可行,但不建议一步直追腾讯文档式完整协同编辑。 + +## 会话 ID: local-20260705195210 +- [2026-07-05 19:52:10] +- **执行原因**: 用户要求将该方案整理成与 `03-任务拆解清单.md` 同风格的正式任务清单文档。 +- **执行过程**: + 1. 对照 `03-任务拆解清单.md` 的结构,统一“阶段 -> 子模块 -> 勾选项 -> 交付物”的表达方式。 + 2. 将“在线编辑模式、AI 选区模式、模板源同步写回、导出引擎重构”等内容拆成可执行任务项。 + 3. 新增正式文档到 `docs/需求与设计/`,便于后续按清单逐步实施。 +- **执行结果**: 已新增 `05-模板在线编辑重构任务拆解清单.md`,内容结构与 `03-任务拆解清单.md` 保持一致,可直接作为后续实施清单使用。 + +## 会话 ID: local-20260705200540 +- [2026-07-05 20:05:40] +- **执行原因**: 用户要求开始按 `05-模板在线编辑重构任务拆解清单.md` 落实代码。 +- **执行过程**: + 1. 先从第一阶段“数据模型升级”入手,新增 `template_blocks` 数据模型、初始化 DDL 和启动时自动建表逻辑。 + 2. 改造模板接口返回结构,在保留旧 `paragraphs` 兼容的同时,新增 `blocks` 序列化输出。 + 3. 改造模板上传与保存逻辑,使新上传模板会自动同步生成块数据;保存段落时若前端尚未显式传块,则自动从段落重建块,确保兼容过渡。 + 4. 更新前端 `types/store`,接入 `blocks` 字段;补充本轮增量 SQL 文档,并将任务清单中已完成的数据模型项勾选。 + 5. 执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit` 校验,确认本轮改动通过编译检查。 +- **执行结果**: 已完成“模板在线编辑重构”第一阶段中的数据模型升级基础设施,系统现已具备 `template_blocks` 持久化能力,并能在不破坏现有模板编辑流程的前提下为后续块级编辑器改造提供后端承载。 + +## 会话 ID: local-20260705202018 +- [2026-07-05 20:20:18] +- **执行原因**: 用户要求继续推进模板在线编辑重构,优先落实第二阶段的模板块级解析能力。 +- **执行过程**: + 1. 改造 `template_parser.py`,将导入解析从“按 Heading 聚合大段”调整为“标题块 + 正文块 + 表格块”的细粒度块流。 + 2. 为解析结果补充块元信息,包括 `block_type`、`placeholder_key`、`variable_key`、`edit_mode`、`output_format`,并对显式 `{{ xxx }}` 占位做初步分类。 + 3. 改造模板上传逻辑,创建 `Paragraph` 时同步写入更细粒度的导入结果,并基于解析结果直接生成 `template_blocks`。 + 4. 更新任务清单勾选状态,标记已完成的“标题块/正文块/表格块/块级 JSON 结构”等子项。 + 5. 再次执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit`,确认本轮解析器重构未引入编译错误。 +- **执行结果**: 当前模板导入阶段已具备初步块级解析能力,导入后的结构不再只是一整段聚合文本,而是更接近后续在线编辑所需的块流模型,为第一页内容拆分和 AI 选区改造打下了基础。 + +## 会话 ID: local-20260705203240 +- [2026-07-05 20:32:40] +- **执行原因**: 用户继续推进,要求将模板编辑页的“手动编辑模板”模式真正切换到块模型上。 +- **执行过程**: + 1. 改造 `TemplateEditor.vue` 的选择与渲染逻辑,引入 `blocks` 本地状态以及 `currentItems / selectedConfigItem` 计算属性。 + 2. 保留原“段落配置”模式兼容现有流程,同时让“手动编辑模板”模式改为基于 `blocks` 渲染左侧列表、中间编辑画布和右侧配置区。 + 3. 改造移动、删除、插入、自动保存与保存模板逻辑,使其在手动模式下可针对块结构生效,并将 `blocks` 一并提交到模板保存接口。 + 4. 为块编辑模式补充块类型标签、变量键/AI 占位键配置,以及块级测试时对 `source_paragraph_id` 的兼容校验。 + 5. 执行前端 `vue-tsc --noEmit` 与后端 `python3 -m py_compile`,确认本轮页面改造与保存链路通过编译检查。 +- **执行结果**: 模板编辑页当前已实现“段落模式 / 块模式”双轨运行;其中手动编辑模板模式已开始基于 `template_blocks` 工作,块列表、块画布与右侧配置面板能够联动,为下一步实现 AI 选区模式奠定了前端基础。 + +## 会话 ID: local-20260705204055 +- [2026-07-05 20:40:55] +- **执行原因**: 用户反馈模板编辑页保存时报 `PUT /templates/{id}/paragraphs 500`,并且进入模板编辑时页面空白。 +- **执行过程**: + 1. 根据报错 SQL 定位到 `template_blocks.content_json` 为 `TEXT` 字段,但上传/同步块时误将 Python `dict` 直接写入数据库。 + 2. 修正模板块构建逻辑,在 `_build_block_from_paragraph` 与 `_build_block_from_parsed_item` 中统一将 `content_json` 序列化为 JSON 字符串。 + 3. 为旧模板补充兼容逻辑:读取模板详情时若尚无 `blocks` 数据,则自动根据现有 `paragraphs` 重建块数据并写回数据库。 + 4. 执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit`,确认修复后无编译错误。 +- **执行结果**: 已修复模板保存时的 500 报错来源;老模板在没有 `blocks` 数据时也会自动补齐,模板编辑页不应再因块数据缺失而显示空白。 + +## 会话 ID: local-20260705204820 +- [2026-07-05 20:48:20] +- **执行原因**: 用户指出当前改造“页面上完全没区别”,要求明确已完成内容与可见效果之间的差距。 +- **执行过程**: + 1. 复盘本轮已落地内容,区分数据层/接口层改造与实际可见的界面改造。 + 2. 明确当前仍未完成的“可见功能”项,包括 AI 选区、块级专属工具条、变量块突出展示、块级样式差异等。 + 3. 准备将后续工作重心从底层铺设切换到用户可见的编辑体验改造。 +- **执行结果**: 已确认当前阶段主要完成了块模型、解析器与保存链路等基础设施,前端交互层仍缺少足够显著的视觉与操作变化;后续需优先补齐用户可感知的块级编辑与 AI 选区功能。 + +## 会话 ID: local-20260705205630 +- [2026-07-05 20:56:30] +- **执行原因**: 用户要求继续实施,并优先看到模板编辑页中“手动编辑模板”模式的可见变化。 +- **执行过程**: + 1. 改造 `TemplateEditor.vue` 顶部工具条,在手动模式下新增“新增标题块 / 正文块 / AI 块 / 变量块”操作入口。 + 2. 改造中间块画布的卡片样式,为标题块、正文块、AI 块、变量块、表格块提供不同的边框、背景和标识信息。 + 3. 调整左侧列表标签与右侧配置项,使块类型、AI 占位键、变量键、固定块信息在界面上可直接感知。 + 4. 补齐块模式下新增、删除、上移、下移的界面交互,并修正新增块时误写入旧 `paragraphs` 数组的问题。 + 5. 执行前端 `vue-tsc --noEmit` 与后端 `python3 -m py_compile`,确认可见层改造通过编译检查。 +- **执行结果**: 模板编辑页的手动模式现在已有明显的块级编辑视觉效果与块工具条,页面不再只是“底层换数据源但外观几乎不变”;用户可直接看到并操作标题块、正文块、AI 块和变量块。 + +## 会话 ID: local-20260705210520 +- [2026-07-05 21:05:20] +- **执行原因**: 用户追问当前是否真正完成“在线编辑文档效果”、执行生成为何未按模板段落顺序导出,以及模板编辑是否已直接影响源 `docx` 文件。 +- **执行过程**: + 1. 复核任务清单与当前代码链路,区分“块级编辑器界面改造”与“模板源写回 / 生成导出主链路改造”两个层面。 + 2. 核对 `generation_runtime.py` 与 `export.py`,确认当前执行生成和导出仍然基于旧 `paragraphs + generation_logs + template.file_path` 工作。 + 3. 核对 `templates.py`,确认当前模板编辑保存主要写入 `paragraphs` 与 `template_blocks`,尚未把编辑后的块结构回写到模板源 `docx`。 +- **执行结果**: 已明确当前“在线编辑文档效果”只完成了块级编辑器的可见前端基础,未完成模板源 `docx` 写回;执行生成和导出仍走旧段落链路,因此不会完全按新块顺序导出。这也是用户感知为“编辑后没有真正影响模板导出”的根本原因。 + +## 会话 ID: local-20260705211340 +- [2026-07-05 21:13:40] +- **执行原因**: 用户要求继续实施,优先打通“保存模板影响源 docx”和“执行生成/导出顺序跟块走”的主链路。 +- **执行过程**: + 1. 在 `templates.py` 中新增 `blocks -> paragraphs` 同步逻辑,使块顺序、块内容、AI/人工属性会反向更新旧 `Paragraph` 数据。 + 2. 在同一文件中新增模板快照写回逻辑:读取当前模板源 `docx`,按当前块顺序组装导出日志,通过 `export_document_bytes` 生成新的模板内容并覆盖回模板源文件。 + 3. 保留现有 `generation_runtime.py` 与 `export.py` 的旧段落链路不变,但通过同步段落顺序与内容,让执行生成和导出开始间接受到块顺序影响。 + 4. 执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit`,确认主链路改造通过编译检查。 +- **执行结果**: 当前保存模板后,后端已开始将 `template_blocks` 反向同步回 `paragraphs`,并尝试把当前块快照回写到模板源 `docx`;这为后续完全切换到 blocks 导出奠定了主链路基础,也开始让保存后的顺序和内容影响执行生成与导出。 + +## 会话 ID: local-20260705212155 +- [2026-07-05 21:21:55] +- **执行原因**: 用户反馈“调整段落顺序后,点击保存会恢复之前的段落”。 +- **执行过程**: + 1. 复核前后端保存链路,确认问题出在“段落模式调整了 `paragraphs`,但保存时仍把旧 `blocks` 一并提交,后端又按旧 `blocks` 覆盖回段落顺序”。 + 2. 为模板保存请求新增 `save_mode` 字段,明确区分 `paragraph` 与 `manual` 两种保存语义。 + 3. 调整后端保存逻辑:只有在 `manual` 模式下才按 `blocks` 覆盖段落;在 `paragraph` 模式下则以 `paragraphs` 为准重建块数据,避免旧块顺序反向覆盖。 + 4. 调整前端 `TemplateEditor.vue` 与 `template` store,在自动保存与保存模板时按当前编辑模式传递 `save_mode`,并在段落模式下不再提交旧块数组。 + 5. 执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit`,确认修复通过编译检查。 +- **执行结果**: 已修复段落模式下“调整顺序后保存又恢复原顺序”的直接覆盖问题;当前段落模式保存会以 `paragraphs` 为准,不再被旧 `blocks` 顺序反向改写。 + +## 会话 ID: local-20260705213040 +- [2026-07-05 21:30:40] +- **执行原因**: 用户反馈“执行生成里还是旧顺序,并且每一段都会重复导出”。 +- **执行过程**: + 1. 通过容器内 MySQL 查询模板 5 的 `paragraphs`、`template_blocks` 与最近生成记录,确认数据库中的最新顺序实际上已同步为用户调整后的顺序。 + 2. 定位重复导出的根因:当前模板下每个块都是单独段落,但历史保存逻辑将所有非标题段统一标为 `append_after_heading`,导出时会保留旧正文再追加一次新正文,导致每段重复。 + 3. 在 `templates.py` 中新增块级写入方式解析逻辑:同一锚点下首个内容块使用 `replace_section`,后续同锚点块才使用 `append_after_heading`;标题块仍使用 `replace_heading_only`。 + 4. 立刻用当前模板详情数据回调一次 `PUT /templates/5/paragraphs`,触发模板 5 重新保存,使新的写入方式同步落库并回写模板快照。 + 5. 再次查询数据库确认模板 5 的 `paragraphs.write_mode` 已全部从错误的 `append_after_heading` 切换为 `replace_section`。 +- **执行结果**: 已修复模板 5 当前“每一段重复导出”的直接根因;数据库和最新生成链路所使用的模板顺序现已与用户调整后的顺序一致。后续需要重新发起新的生成任务,旧的历史生成记录不会自动变成新顺序与新导出结果。 + +## 会话 ID: local-20260705213910 +- [2026-07-05 21:39:10] +- **执行原因**: 用户要求继续完善,进一步降低导出链路对旧 `paragraphs` 顺序的依赖,减少排序与重复类 bug。 +- **执行过程**: + 1. 改造 `export.py`,让 DOCX 导出优先按 `template_blocks` 顺序组织导出日志,而不是完全依赖 `GenerationLog + Paragraph` 的旧顺序。 + 2. 在导出组装阶段加入块级写入方式解析逻辑:同锚点首块使用 `replace_section`,后续同锚点块才使用 `append_after_heading`,标题块使用 `replace_heading_only`。 + 3. 在 `document_export.py` 中新增章节重排逻辑,根据导出日志中的锚点顺序,先调整 Word 文档中各个 Heading section 的物理顺序,再执行正文替换与插入。 + 4. 保留无块数据时的旧段落导出回退逻辑,避免历史模板直接失效。 + 5. 执行后端 `python3 -m py_compile` 与前端 `vue-tsc --noEmit`,确认主链路改造通过编译检查。 +- **执行结果**: 当前 DOCX 导出已开始优先按 `template_blocks` 顺序工作,并且在写回前会尝试重排 Word 标题区块顺序;这比之前仅替换原位置内容更接近“模板编辑后导出顺序真实变化”的目标。 diff --git a/docs/需求与设计/05-模板在线编辑重构任务拆解清单.md b/docs/需求与设计/05-模板在线编辑重构任务拆解清单.md new file mode 100644 index 0000000..85b71fb --- /dev/null +++ b/docs/需求与设计/05-模板在线编辑重构任务拆解清单.md @@ -0,0 +1,138 @@ +# AI 文档模板生成系统 · 模板在线编辑重构任务拆解清单 + +总工期估算:4-6 周(两人并行:前端 + 后端) + +## 第一阶段:方案定稿与数据结构升级(第 1 周) + +### 产品方案与交互定稿(2-3 天) +- [ ] 明确“在线编辑模式 / AI 选区模式”双模式交互 +- [ ] 明确块类型定义(标题块 / 正文块 / 表格块 / AI 块 / 变量块) +- [ ] 明确 AI 选区后的操作菜单(设为 AI / 设为固定内容 / 设为变量) +- [ ] 明确模板保存后的基线含义(保存即修改模板源内容) +- [ ] 明确导出时的填充规则(固定内容保留,AI 块填充,变量块替换) +- [ ] 输出交互原型说明文档 + +### 数据模型升级(2-3 天) +- [x] 新增模板块表 `template_blocks` +- [x] 定义块字段(block_type / sort_index / parent_id / anchor_ref / content_json / style_json / edit_mode) +- [x] 为 AI 块补充字段(placeholder_key / model_id / prompt_text / need_file / output_format) +- [x] 为变量块补充字段(variable_key / default_value) +- [x] 保留旧 `paragraphs` 结构用于兼容过渡 +- [x] 输出数据库增量 SQL + +## 第二阶段:模板解析器重构(第 1-2 周) + +### Word 块级解析(4-5 天) +- [x] 解析 `.docx` 为块流结构,而不只按 Heading 识别段落 +- [x] 识别标题块 +- [x] 识别普通正文块 +- [x] 识别表格块 +- [ ] 识别空白分隔块 +- [ ] 为每个块记录原始位置索引与样式快照 +- [x] 输出新的块级 JSON 结构 + +### 特殊区域识别(2-3 天) +- [ ] 识别封面区内容(标题 / 日期 / 负责人 / 单位等) +- [ ] 识别摘要区内容 +- [ ] 识别未使用 Heading 的小节正文 +- [x] 识别连续正文中的可拆分候选块 +- [ ] 输出模板体检提示信息 + +## 第三阶段:模板在线编辑器重构(第 2-3 周) + +### 中间编辑画布(4-5 天) +- [x] 将当前段落预览区改造为块级编辑画布 +- [x] 支持标题块直接编辑 +- [x] 支持正文块直接编辑 +- [ ] 支持表格块展示与基础编辑 +- [x] 支持块新增 +- [x] 支持块删除 +- [x] 支持块上移 / 下移 +- [ ] 支持块拆分 / 合并 + +### 编辑模式与 AI 选区模式(3-4 天) +- [ ] 增加“在线编辑模式 / AI 选区模式”切换 +- [ ] 在线编辑模式下支持直接修改模板内容 +- [ ] AI 选区模式下支持鼠标选中一段内容 +- [ ] 选中内容后可设为 AI 块 +- [ ] 选中内容后可设为变量块 +- [ ] 支持在当前位置后插入新的 AI 块 +- [ ] 支持取消 AI 标记并恢复为固定内容 + +### 左右侧配置区联动(2-3 天) +- [x] 左侧块列表与中间编辑画布联动高亮 +- [x] 右侧根据当前选中块显示不同配置项 +- [x] AI 块显示模型 / 提示词 / 文件要求 / 输出格式配置 +- [x] 变量块显示变量名 / 默认值配置 +- [x] 固定块显示只读或基础编辑信息 + +## 第四阶段:模板保存与模板源同步写回(第 3 周) + +### 模板保存链路重构(3-4 天) +- [ ] 保存模板时持久化块结构而非仅保存段落配置 +- [ ] 保存模板时同步生成最新模板快照 +- [ ] 将最新模板快照写回模板源 `.docx` +- [ ] 保存后重新加载模板时展示最新编辑结果 +- [ ] 补充模板版本或快照记录 + +### 模板源写回引擎(3-4 天) +- [ ] 支持标题块写回 +- [ ] 支持正文块写回 +- [ ] 支持删除块后同步从模板源移除 +- [ ] 支持移动块后同步更新模板顺序 +- [ ] 支持新增块后同步插入模板源 +- [ ] 处理第一页封面区块写回 + +## 第五阶段:生成链路适配(第 3-4 周) + +### AI 生成任务改造(3-4 天) +- [ ] 生成任务由“按段落”改为“按 AI 块” +- [ ] 固定块不参与 AI 生成 +- [ ] 变量块按规则填充值 +- [ ] AI 块支持单块测试生成 +- [ ] AI 块支持多块批量生成 +- [ ] AI 块支持失败回退与重试 + +### 文件与提示词链路适配(2-3 天) +- [ ] 文件上传改为绑定 AI 块 +- [ ] 历史文件复用继续兼容 +- [ ] 提示词配置迁移到 AI 块级别 +- [ ] 输出格式配置迁移到 AI 块级别 + +## 第六阶段:导出引擎重构(第 4-5 周) + +### Word 导出写回(4-5 天) +- [ ] 导出基于“最新模板快照”而不是原始导入模板 +- [ ] 固定块保持模板编辑后的最终内容 +- [ ] AI 块按占位位置写回生成结果 +- [ ] 变量块按变量值替换 +- [ ] 支持同一位置下多块顺序写回 +- [ ] 支持文字与表格混排导出 +- [ ] 保留主要样式与段落结构 + +### 导出正确性校验(2-3 天) +- [ ] 校验删除块后导出无残留 +- [ ] 校验调整顺序后导出顺序正确 +- [ ] 校验第一页内容导出正确 +- [ ] 校验摘要区内容导出正确 +- [ ] 校验多级标题结构导出正确 + +## 第七阶段:联调、修边与验收(第 5-6 周) + +- [ ] 用真实复杂模板联调(含封面 / 摘要 / 多级标题 / 表格) +- [ ] 验证第一页块可删除、可调整、可导出 +- [ ] 验证保存模板后再次进入能看到最新模板内容 +- [ ] 验证 AI 选区转块后的生成与导出闭环 +- [ ] 验证历史生成记录兼容旧数据 +- [ ] 完善错误提示、加载状态与操作反馈 +- [ ] 补充模板编辑使用说明 + +## 阶段交付物 + +| 阶段 | 交付物 | +|------|--------| +| 第 1 周 | 在线编辑方案、块模型设计、数据库增量方案 | +| 第 2 周 | 新版模板解析器、块级 JSON 结构、模板体检提示 | +| 第 3 周 | 块级在线编辑器、AI 选区模式、模板保存链路 | +| 第 4-5 周 | AI 块生成链路、导出写回引擎、真实模板导出闭环 | +| 第 6 周 | 全流程联调通过、使用说明与验收结果 | diff --git a/docs/需求与设计/05-模板在线编辑重构增量SQL.sql b/docs/需求与设计/05-模板在线编辑重构增量SQL.sql new file mode 100644 index 0000000..8b5b63f --- /dev/null +++ b/docs/需求与设计/05-模板在线编辑重构增量SQL.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS template_blocks ( + id INT AUTO_INCREMENT PRIMARY KEY, + template_id INT NOT NULL, + source_paragraph_id INT DEFAULT NULL COMMENT '来源段落 ID', + parent_block_id INT DEFAULT NULL COMMENT '父块 ID', + sort_index INT DEFAULT 0 COMMENT '排序', + block_type VARCHAR(30) DEFAULT 'text' COMMENT 'heading/text/table/ai_slot/variable', + anchor_ref VARCHAR(500) DEFAULT '' COMMENT '原始锚点引用', + title VARCHAR(500) DEFAULT '' COMMENT '块标题', + content_json TEXT DEFAULT '{}' COMMENT '块内容 JSON', + style_json TEXT DEFAULT '{}' COMMENT '块样式 JSON', + edit_mode VARCHAR(20) DEFAULT 'manual' COMMENT 'manual/ai', + placeholder_key VARCHAR(120) DEFAULT '' COMMENT 'AI 占位键', + variable_key VARCHAR(120) DEFAULT '' COMMENT '变量键', + default_value TEXT DEFAULT '' COMMENT '默认值', + model_id INT DEFAULT NULL COMMENT '指定模型', + need_prompt TINYINT(1) DEFAULT 1 COMMENT '是否需要提示词', + prompt_text TEXT DEFAULT '' COMMENT '预设提示词', + need_file TINYINT(1) DEFAULT 0 COMMENT '是否需要参考文件', + file_note TEXT DEFAULT '' COMMENT '备注说明', + output_format VARCHAR(20) DEFAULT 'text' COMMENT 'text/table/mixed/chart', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_template_blocks_template FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE, + CONSTRAINT fk_template_blocks_paragraph FOREIGN KEY (source_paragraph_id) REFERENCES paragraphs(id) ON DELETE SET NULL, + CONSTRAINT fk_template_blocks_parent FOREIGN KEY (parent_block_id) REFERENCES template_blocks(id) ON DELETE SET NULL, + CONSTRAINT fk_template_blocks_model FOREIGN KEY (model_id) REFERENCES ai_models(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/init.sql b/init.sql index ea5cf7d..cc63aa2 100644 --- a/init.sql +++ b/init.sql @@ -50,6 +50,35 @@ CREATE TABLE IF NOT EXISTS paragraphs ( FOREIGN KEY (model_id) REFERENCES ai_models(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS template_blocks ( + id INT AUTO_INCREMENT PRIMARY KEY, + template_id INT NOT NULL, + source_paragraph_id INT DEFAULT NULL COMMENT '来源段落 ID', + parent_block_id INT DEFAULT NULL COMMENT '父块 ID', + sort_index INT DEFAULT 0 COMMENT '排序', + block_type VARCHAR(30) DEFAULT 'text' COMMENT 'heading/text/table/ai_slot/variable', + anchor_ref VARCHAR(500) DEFAULT '' COMMENT '原始锚点引用', + title VARCHAR(500) DEFAULT '' COMMENT '块标题', + content_json TEXT DEFAULT '{}' COMMENT '块内容 JSON', + style_json TEXT DEFAULT '{}' COMMENT '块样式 JSON', + edit_mode VARCHAR(20) DEFAULT 'manual' COMMENT 'manual/ai', + placeholder_key VARCHAR(120) DEFAULT '' COMMENT 'AI 占位键', + variable_key VARCHAR(120) DEFAULT '' COMMENT '变量键', + default_value TEXT DEFAULT '' COMMENT '默认值', + model_id INT DEFAULT NULL COMMENT '指定模型', + need_prompt TINYINT(1) DEFAULT 1 COMMENT '是否需要提示词', + prompt_text TEXT DEFAULT '' COMMENT '预设提示词', + need_file TINYINT(1) DEFAULT 0 COMMENT '是否需要参考文件', + file_note TEXT DEFAULT '' COMMENT '备注说明', + output_format VARCHAR(20) DEFAULT 'text' COMMENT 'text/table/mixed/chart', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE, + FOREIGN KEY (source_paragraph_id) REFERENCES paragraphs(id) ON DELETE SET NULL, + FOREIGN KEY (parent_block_id) REFERENCES template_blocks(id) ON DELETE SET NULL, + FOREIGN KEY (model_id) REFERENCES ai_models(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + CREATE TABLE IF NOT EXISTS documents ( id INT AUTO_INCREMENT PRIMARY KEY, template_id INT NOT NULL, diff --git a/web/src/stores/template.ts b/web/src/stores/template.ts index 12af6b6..efd4e99 100644 --- a/web/src/stores/template.ts +++ b/web/src/stores/template.ts @@ -1,19 +1,45 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { templateApi } from '@/api/template' -import type { Template, Paragraph } from '@/types' +import type { Template, Paragraph, TemplateBlock } from '@/types' export const useTemplateStore = defineStore('template', () => { const templates = ref([]) const currentTemplate = ref - + {{ editorMode === 'paragraph' ? '点击左侧段落或文档中的段落块查看配置' : '可直接编辑标题、正文,并手动拆块插入 AI 内容' }} +
-
+
@@ -142,32 +156,41 @@