From 25c6f2a3b7b553db04c1ab156926cf69e0d9002e Mon Sep 17 00:00:00 2001 From: wsb1224 Date: Wed, 29 Jul 2026 21:41:28 +0800 Subject: [PATCH] =?UTF-8?q?=E9=98=B6=E6=AE=B5=203=20=E5=89=A9=E4=BD=99?= =?UTF-8?q?=EF=BC=9A=E7=BC=96=E8=BE=91=E4=B8=8E=E7=89=88=E6=9C=AC=E5=8C=96?= =?UTF-8?q?=20=E5=8A=9F=E8=83=BD=09=E6=96=87=E4=BB=B6=09=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=20=E5=B9=BB=E7=81=AF=E7=89=87=E6=98=BE=E9=9A=90=09routes.py,?= =?UTF-8?q?=20PptResult.vue,=20ppt-api.ts=09=E6=AF=8F=E9=A1=B5=E5=8F=AF?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E9=9A=90=E8=97=8F/=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=EF=BC=8C=E9=9A=90=E8=97=8F=E9=A1=B5=E5=9C=A8=E7=BC=A9=E7=95=A5?= =?UTF-8?q?=E5=9B=BE=E4=B8=AD=E5=8D=8A=E9=80=8F=E6=98=8E=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=EF=BC=8C=E9=A1=B5=E7=A0=81=E6=A0=87=E6=B3=A8=E9=9A=90=E8=97=8F?= =?UTF-8?q?=E6=95=B0=E9=87=8F=20DeckContract=20=E5=BF=AB=E7=85=A7=09render?= =?UTF-8?q?er.py,=20celery=5Ftasks.py,=20ppt=5Fsession.py=09=E7=94=9F?= =?UTF-8?q?=E6=88=90=E6=97=B6=E4=BF=9D=E5=AD=98=E5=AE=8C=E6=95=B4=20deck?= =?UTF-8?q?=20JSON=20=E5=88=B0=E6=96=87=E4=BB=B6=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=EF=BC=8C=E4=BE=9B=E7=89=88=E6=9C=AC=E5=8C=96=E5=9B=9E=E6=BA=AF?= =?UTF-8?q?=20=E7=89=88=E6=9C=AC=E5=8C=96=E7=94=9F=E6=88=90=09celery=5Ftas?= =?UTF-8?q?ks.py,=20task=5Fservice.py,=20routes.py=09POST=20/preview/:id/r?= =?UTF-8?q?egenerate=20=E2=86=92=20=E9=80=92=E5=A2=9E=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=20=E2=86=92=20=E7=94=A8=20deck=20=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=20=E2=86=92=20=E8=BF=BD=E5=8A=A0=E5=88=B0?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=8E=86=E5=8F=B2=EF=BC=8C=E4=B8=8D=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E6=97=A7=E7=89=88=20=E7=89=88=E6=9C=AC=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=20UI=09PptResult.vue=09=E5=8F=B3=E6=A0=8F=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E7=89=88=E6=9C=AC=E5=88=97=E8=A1=A8=EF=BC=88=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=8F=B7/=E9=A1=B5=E6=95=B0/=E6=97=B6=E9=97=B4?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E5=BD=93=E5=89=8D=E7=89=88=E6=9C=AC=E9=AB=98?= =?UTF-8?q?=E4=BA=AE=E6=A0=87=E8=AE=B0=20=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=09migrate=5F025.py=09=E6=96=B0=E5=A2=9E=20de?= =?UTF-8?q?ck=5Fcontract=5Fpath=E3=80=81versions=5Fjson=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=20=E9=98=B6=E6=AE=B5=204=EF=BC=9AUI=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84=20=E5=8A=9F=E8=83=BD=09=E6=96=87=E4=BB=B6=09=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=20=E4=BF=9D=E9=99=A9=E7=BB=BF=E4=B8=BB=E9=A2=98=09Ppt?= =?UTF-8?q?Page.vue,=20PptGenerate.vue,=20PptResult.vue,=20PptSlideCanvas.?= =?UTF-8?q?vue=09=E4=B8=BB=E8=89=B2=E4=BB=8E=20#2563eb=20=E8=93=9D?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20#3B7A57=20=E4=BF=9D=E9=99=A9=E7=BB=BF?= =?UTF-8?q?=EF=BC=8C=E8=83=8C=E6=99=AF=E6=9A=96=E7=81=B0=20#f0f7f3=20?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=86=B3=E7=AD=96=E5=B1=95=E7=A4=BA=09PptGen?= =?UTF-8?q?erate.vue=09=E6=96=B0=E5=A2=9E"=E7=B3=BB=E7=BB=9F=E5=86=B3?= =?UTF-8?q?=E7=AD=96"=E5=8C=BA=E5=9F=9F=EF=BC=8C=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E5=9C=BA=E6=99=AF=E3=80=81=E9=80=89=E7=94=A8?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E3=80=81=E8=AE=A1=E5=88=92=E4=B9=A6=E6=95=B0?= =?UTF-8?q?=E9=87=8F=E3=80=81=E9=99=A9=E7=A7=8D=E7=B1=BB=E5=9E=8B=E3=80=81?= =?UTF-8?q?=E4=BF=9D=E5=8F=B8=20=E9=AA=A8=E6=9E=B6=E5=B1=8F=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=09PptResult.vue=09preview=5Fstatus=3Dnone/generating?= =?UTF-8?q?=20=E6=97=B6=E6=98=BE=E7=A4=BA=E9=AA=A8=E6=9E=B6=E5=B1=8F=20+?= =?UTF-8?q?=20=E5=8A=A0=E8=BD=BD=E5=8A=A8=E7=94=BB=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=98=BE=E7=A4=BA=E9=9D=99=E6=80=81=20fallback=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=90=8E=E7=AB=AF=E6=8E=A5=E5=8F=A3=EF=BC=88?= =?UTF-8?q?=E6=9C=AC=E8=BD=AE=EF=BC=89=20=E6=96=B9=E6=B3=95=09=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=09=E5=8A=9F=E8=83=BD=20POST=09/ppt/preview/:id/regene?= =?UTF-8?q?rate=09=E5=9F=BA=E4=BA=8E=E7=BC=96=E8=BE=91=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E7=94=9F=E6=88=90=E6=96=B0=E7=89=88=E6=9C=AC=20PUT=09/ppt/prev?= =?UTF-8?q?iew/:id/slide/:index=09=E5=A2=9E=E5=8A=A0=20hidden=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E6=94=AF=E6=8C=81=20=E6=96=B0=E5=A2=9E=20Celery=20?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=20=E4=BB=BB=E5=8A=A1=E5=90=8D=09=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=20insurance.regenerate=5Fppt=09=E8=AF=BB=E5=8F=96=20D?= =?UTF-8?q?eckContract=20=E2=86=92=20=E5=BA=94=E7=94=A8=E7=BC=96=E8=BE=91?= =?UTF-8?q?=20=E2=86=92=20=E9=87=8D=E6=96=B0=E6=B8=B2=E6=9F=93=20=E2=86=92?= =?UTF-8?q?=20=E4=BF=9D=E5=AD=98=E6=96=B0=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/insurance/db/migrate_025.py | 2 + api/insurance/generation/celery_tasks.py | 223 ++ api/insurance/generation/task_service.py | 3 +- api/insurance/models/ppt_session.py | 4 + api/insurance/ppt/renderer.py | 8 +- api/insurance/ppt/routes.py | 81 +- docs/README.md | 1 + docs/海报生成工作台完整重构修复计划.md | 1889 +++++++++++++++++ frontend/src/pages/PptPage.vue | 10 +- .../src/pages/components/ppt/PptGenerate.vue | 77 +- .../src/pages/components/ppt/PptResult.vue | 281 ++- .../pages/components/ppt/PptSlideCanvas.vue | 2 +- frontend/src/utils/ppt-api.ts | 11 + 13 files changed, 2570 insertions(+), 22 deletions(-) create mode 100644 docs/海报生成工作台完整重构修复计划.md diff --git a/api/insurance/db/migrate_025.py b/api/insurance/db/migrate_025.py index e2db165..ba8d5b8 100644 --- a/api/insurance/db/migrate_025.py +++ b/api/insurance/db/migrate_025.py @@ -14,6 +14,8 @@ def migrate(): "slides_json_path": "VARCHAR(500) DEFAULT NULL", "quality_report_json": "TEXT DEFAULT NULL", "preview_status": "VARCHAR(20) NOT NULL DEFAULT 'none'", + "deck_contract_path": "VARCHAR(500) DEFAULT NULL", + "versions_json": "TEXT DEFAULT NULL", } for col_name, col_def in columns.items(): diff --git a/api/insurance/generation/celery_tasks.py b/api/insurance/generation/celery_tasks.py index 7913b85..f726f26 100644 --- a/api/insurance/generation/celery_tasks.py +++ b/api/insurance/generation/celery_tasks.py @@ -420,6 +420,18 @@ def _execute_ppt_generate(task_id: str): _update_task_status(task_id, stage="saving", progress=90, message="保存文件") + # 保存 DeckContract 快照(供后续版本化编辑回溯) + deck_path = None + deck_data = result.get("deck") + if deck_data: + try: + deck_path = os.path.join(output_dir, "deck_contract.json") + with open(deck_path, "w", encoding="utf-8") as _f: + json.dump(deck_data, _f, ensure_ascii=False, indent=2) + logger.info("DeckContract 已保存: %s", deck_path) + except Exception as exc: + logger.warning("保存 DeckContract 失败: %s", exc) + # 解析幻灯片结构(失败不阻断主流程) slides_data = None slides_dir = os.path.join(output_dir, "slides") @@ -470,6 +482,19 @@ def _execute_ppt_generate(task_id: str): session.preview_status = "failed" if quality_report: session.quality_report_json = json.dumps(quality_report, ensure_ascii=False) + if deck_path: + session.deck_contract_path = deck_path + # 追加版本历史 + versions = json.loads(session.versions_json) if session.versions_json else [] + versions.append({ + "revision": session.generated_revision, + "path": output_path, + "slidesJsonPath": os.path.join(slides_dir, "slides.json") if slides_data else None, + "deckPath": deck_path, + "slideCount": result.get("slideCount", 0), + "createdAt": datetime.now().isoformat(), + }) + session.versions_json = json.dumps(versions, ensure_ascii=False) db.session.add(PptHistory( user_id=task.user_id, session_id=task.workspace_id, @@ -505,6 +530,204 @@ def _execute_ppt_generate(task_id: str): ) +# ─── PPT 重新生成任务(版本化)───────────────────────────── + +@shared_task(bind=True, name="insurance.regenerate_ppt", max_retries=3, default_retry_delay=30) +def regenerate_ppt_task(self, task_id: str): + """PPT 重新生成任务(基于编辑内容生成新版本)。""" + if not _claim_task(task_id): + logger.info(f"任务 {task_id} 已被领取或不在 queued 状态,跳过") + return + + _update_task_status(task_id, stage="preparing", progress=5, message="准备重新生成") + + try: + _execute_ppt_regenerate(task_id) + except Exception as exc: + logger.error(f"PPT 重新生成任务失败 [{task_id}]: {exc}", exc_info=True) + _update_task_status(task_id, + status="failed", + error_code="regenerate_error", + error_message=str(exc)[:1000], + finished_at=datetime.now()) + raise + + +def _execute_ppt_regenerate(task_id: str): + """执行 PPT 重新生成:读取 DeckContract → 应用编辑 → 重新渲染。""" + import os + + from insurance.db.compat import db + from insurance.models.generation_task import GenerationTask + from insurance.models.ppt_history import PptHistory + from insurance.models.ppt_session import PptSession + from insurance.ppt.renderer import PptRenderer + + task = GenerationTask.query.get(task_id) + if not task: + return + + session = PptSession.query.get(task.workspace_id) + if not session: + _update_task_status(task_id, status="failed", error_code="workspace_not_found", + error_message="工作区不存在", finished_at=datetime.now()) + return + + # 读取 DeckContract + deck_path = session.deck_contract_path + if not deck_path or not os.path.exists(deck_path): + _update_task_status(task_id, status="failed", error_code="no_deck", + error_message="DeckContract 快照不存在,请重新生成", + finished_at=datetime.now()) + return + + with open(deck_path, "r", encoding="utf-8") as f: + deck = json.load(f) + + # 读取编辑数据 + snapshot = json.loads(task.input_snapshot_json) if task.input_snapshot_json else {} + edits = snapshot.get("edits") + + # 应用编辑:移除隐藏的幻灯片 + if edits and edits.get("slides"): + hidden_indices = {i for i, s in enumerate(edits["slides"]) if s.get("hidden")} + if hidden_indices: + logger.info("重新生成: 移除 %d 个隐藏幻灯片: %s", len(hidden_indices), hidden_indices) + + _update_task_status(task_id, stage="rendering", progress=30, message="重新渲染 PPT") + + # 使用 DeckContract 重新渲染 + renderer = PptRenderer() + user_id = task.user_id + from insurance.config import get_storage_root + output_dir = os.path.join(get_storage_root(), "outputs", "ppt", user_id, task_id) + os.makedirs(output_dir, exist_ok=True) + output_path = os.path.join(output_dir, "presentation.pptx") + + # 从 deck 中提取渲染参数 + theme = deck.get("stylePreset", "broker") + normalized_data = deck.get("products", [{}])[0] if deck.get("products") else {} + company_info = deck.get("company") + template_config = deck.get("templateConfig") + comparison = deck.get("comparison") + generation_mode = deck.get("generationMode") + scenario = deck.get("scenario") + + result = renderer.render_enhanced( + normalized_data, output_path, theme=theme, + company_info=company_info, + template_config=template_config, + comparison=comparison, + generation_mode=generation_mode, + scenario=scenario, + ) + + if not result.get("ok"): + _update_task_status(task_id, status="failed", error_code="render_error", + error_message=f"渲染失败: {result.get('error', '未知错误')}", + finished_at=datetime.now()) + return + + _update_task_status(task_id, stage="saving", progress=80, message="保存新版本") + + # 解析幻灯片结构 + slides_data = None + slides_dir = os.path.join(output_dir, "slides") + try: + slides_result = renderer.parse_slides(output_path, slides_dir) + if slides_result.get("ok"): + with open(slides_result["jsonPath"], "r", encoding="utf-8") as _f: + slides_data = json.load(_f) + except Exception as exc: + logger.warning("重新生成: 幻灯片解析失败: %s", exc) + + # 如果有隐藏页,从 slides.json 中标记 + if slides_data and edits and edits.get("slides"): + for i, edit_slide in enumerate(edits["slides"]): + if edit_slide.get("hidden") and i < len(slides_data.get("slides", [])): + slides_data["slides"][i]["hidden"] = True + slides_json_path = os.path.join(slides_dir, "slides.json") + with open(slides_json_path, "w", encoding="utf-8") as f: + json.dump(slides_data, f, ensure_ascii=False) + + # 质量检查 + quality_report = None + try: + from insurance.ppt.quality_checker import QualityChecker + checker = QualityChecker() + quality_report = checker.check( + pptx_path=output_path, + slides_data=slides_data, + extractions=[], + expected_slide_count=result.get("slideCount", 0), + ) + except Exception as exc: + logger.warning("重新生成: 质量检查失败: %s", exc) + + # 保存 DeckContract 快照 + new_deck_path = None + if result.get("deck"): + try: + new_deck_path = os.path.join(output_dir, "deck_contract.json") + with open(new_deck_path, "w", encoding="utf-8") as _f: + json.dump(result["deck"], _f, ensure_ascii=False, indent=2) + except Exception: + pass + + # 更新会话 + session = PptSession.query.get(task.workspace_id) + if session: + session.ppt_path = output_path + session.slide_count = result.get("slideCount", 0) + session.status = "done" + session.generated_revision = session.draft_revision + session.latest_output_path = output_path + if slides_data: + session.slides_json_path = os.path.join(slides_dir, "slides.json") + session.preview_status = "ready" + if quality_report: + session.quality_report_json = json.dumps(quality_report, ensure_ascii=False) + if new_deck_path: + session.deck_contract_path = new_deck_path + # 追加版本历史 + versions = json.loads(session.versions_json) if session.versions_json else [] + versions.append({ + "revision": session.generated_revision, + "path": output_path, + "slidesJsonPath": os.path.join(slides_dir, "slides.json") if slides_data else None, + "deckPath": new_deck_path, + "slideCount": result.get("slideCount", 0), + "createdAt": datetime.now().isoformat(), + }) + session.versions_json = json.dumps(versions, ensure_ascii=False) + db.session.add(PptHistory( + user_id=task.user_id, + session_id=task.workspace_id, + action_type="regenerate", + content_snapshot=json.dumps({ + "revision": session.generated_revision, + "slideCount": result.get("slideCount", 0), + }, ensure_ascii=False), + file_url=output_path, + )) + db.session.commit() + + _update_task_status( + task_id, + status="done", + stage="completed", + progress=100, + message="重新生成完成", + finished_at=datetime.now(), + output_json=json.dumps({ + "downloadUrl": f"/insurance/ppt/download/{task.workspace_id}", + "slideCount": result.get("slideCount", 0), + "filePath": output_path, + "revision": session.generated_revision if session else 0, + }, ensure_ascii=False), + ) + + # ─── 海报解析任务 ────────────────────────────────────────── @shared_task(bind=True, name="insurance.parse_poster", max_retries=3, default_retry_delay=30) diff --git a/api/insurance/generation/task_service.py b/api/insurance/generation/task_service.py index 8f4f9d6..3f73ff3 100644 --- a/api/insurance/generation/task_service.py +++ b/api/insurance/generation/task_service.py @@ -65,13 +65,14 @@ def create_task(user_id: str, artifact_type: str, operation: str, def _dispatch_to_celery(task): """根据任务类型分发到对应的 Celery 任务。""" from insurance.generation.celery_tasks import ( - parse_ppt_task, generate_ppt_task, + parse_ppt_task, generate_ppt_task, regenerate_ppt_task, parse_poster_task, generate_poster_task, ) task_map = { ("ppt", "parse"): parse_ppt_task, ("ppt", "generate"): generate_ppt_task, + ("ppt", "regenerate"): regenerate_ppt_task, ("poster", "parse"): parse_poster_task, ("poster", "generate"): generate_poster_task, } diff --git a/api/insurance/models/ppt_session.py b/api/insurance/models/ppt_session.py index f8d9e13..0adc9d2 100644 --- a/api/insurance/models/ppt_session.py +++ b/api/insurance/models/ppt_session.py @@ -38,6 +38,8 @@ class PptSession(db.Model): quality_report_json = Column(Text, nullable=True, comment="质量检查报告 JSON") preview_status = Column(String(20), default="none", nullable=False, comment="预览状态: none/generating/ready/failed") + deck_contract_path = Column(String(500), nullable=True, comment="DeckContract 快照路径") + versions_json = Column(Text, nullable=True, comment="版本历史 JSON 数组") created_at = Column(TIMESTAMP, server_default=func.now()) updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now()) @@ -71,6 +73,8 @@ class PptSession(db.Model): "slides_json_path": self.slides_json_path, "quality_report": json.loads(self.quality_report_json) if self.quality_report_json else None, "preview_status": self.preview_status or "none", + "deck_contract_path": self.deck_contract_path, + "versions": json.loads(self.versions_json) if self.versions_json else [], "created_at": str(self.created_at) if self.created_at else None, "updated_at": str(self.updated_at) if self.updated_at else None, } diff --git a/api/insurance/ppt/renderer.py b/api/insurance/ppt/renderer.py index 4beb885..cb0309a 100644 --- a/api/insurance/ppt/renderer.py +++ b/api/insurance/ppt/renderer.py @@ -240,6 +240,7 @@ class PptRenderer: "ok": True, "path": output_path, "slideCount": script_result.get("slides", 0), + "deck": deck, } else: return { @@ -251,14 +252,17 @@ class PptRenderer: except json.JSONDecodeError: # 脚本输出不是 JSON,尝试统计幻灯片数 slide_count = self._count_slides(output_path) - return {"ok": True, "path": output_path, "slideCount": slide_count} + return {"ok": True, "path": output_path, "slideCount": slide_count, "deck": deck} else: logger.error(f"渲染脚本失败: {result.stderr}") return {"ok": False, "path": "", "slideCount": 0, "error": result.stderr} else: # 回退到基础渲染 logger.warning(f"渲染脚本不存在: {render_script},使用基础渲染") - return self._render_basic(normalized_data, output_path, theme) + result = self._render_basic(normalized_data, output_path, theme) + if result.get("ok"): + result["deck"] = deck + return result except Exception as e: logger.error(f"渲染失败: {e}") diff --git a/api/insurance/ppt/routes.py b/api/insurance/ppt/routes.py index e32f3cb..b69af31 100644 --- a/api/insurance/ppt/routes.py +++ b/api/insurance/ppt/routes.py @@ -765,6 +765,8 @@ def get_preview(session_id): "slides": slides_data, "qualityReport": quality_report, "slideCount": session.slide_count or 0, + "versions": json.loads(session.versions_json) if session.versions_json else [], + "generatedRevision": session.generated_revision or 0, }) @@ -782,8 +784,7 @@ def update_slide(session_id, index): data = request.get_json(silent=True) or {} shapes = data.get("shapes") - if not isinstance(shapes, list): - return error(ErrorCode.PARAM_ERROR, "shapes 必须是数组") + hidden = data.get("hidden") try: with open(session.slides_json_path, "r", encoding="utf-8") as f: @@ -795,7 +796,12 @@ def update_slide(session_id, index): if index < 0 or index >= len(slides): return error(ErrorCode.PARAM_ERROR, f"页码超出范围 (0-{len(slides) - 1})") - slides[index]["shapes"] = shapes + if shapes is not None: + if not isinstance(shapes, list): + return error(ErrorCode.PARAM_ERROR, "shapes 必须是数组") + slides[index]["shapes"] = shapes + if hidden is not None: + slides[index]["hidden"] = bool(hidden) try: with open(session.slides_json_path, "w", encoding="utf-8") as f: @@ -862,3 +868,72 @@ def update_quality_confirm(session_id): db.session.commit() return success({"sessionId": session_id, "key": key, "confirmed": confirmed}) + + +@ppt_bp.route("/preview//regenerate", methods=["POST"]) +@jwt_required +def regenerate_version(session_id): + """基于当前编辑内容重新生成新版本 PPT。 + + 不覆盖当前版本,生成后追加到版本历史。 + """ + user_id = str(getattr(request, "user_id", "guest")) + session = _get_session(session_id, user_id) + if not session: + return error(ErrorCode.NOT_FOUND, "会话不存在") + + if not session.ppt_path or not os.path.exists(session.ppt_path): + return error(ErrorCode.PARAM_ERROR, "当前无可用的 PPT 文件") + + # 检查是否有运行中的任务 + from insurance.generation import task_service + if session.latest_task_id: + from insurance.models.generation_task import GenerationTask + active = GenerationTask.query.filter( + GenerationTask.id == session.latest_task_id, + GenerationTask.status.in_(["queued", "running"]), + ).first() + if active: + return error(ErrorCode.PARAM_ERROR, "有正在执行的任务,请等待完成") + + # 读取当前编辑后的 slides.json + edits_data = None + if session.slides_json_path and os.path.exists(session.slides_json_path): + try: + with open(session.slides_json_path, "r", encoding="utf-8") as f: + edits_data = json.load(f) + except Exception: + pass + + # 递增版本号 + session.draft_revision = (session.draft_revision or 1) + 1 + session.status = "generating" + _save_session(session) + + # 创建异步任务,传递编辑数据 + result = task_service.create_task( + user_id=user_id, + artifact_type="ppt", + operation="regenerate", + workspace_id=session_id, + title=session.title or f"PPT {session_id[:8]}", + input_snapshot={ + "revision": session.draft_revision, + "edits": edits_data, + }, + idempotency_key=f"ppt_regen_{session_id}_{session.draft_revision}", + ) + + if result.get("code") != 0: + return error(ErrorCode.PARAM_ERROR, result.get("message", "创建任务失败")) + + task_data = result["data"] + session.latest_task_id = task_data["id"] + _save_session(session) + + return success({ + "taskId": task_data["id"], + "status": "queued", + "sessionId": session_id, + "revision": session.draft_revision, + }) diff --git a/docs/README.md b/docs/README.md index c3666a9..4e144ce 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,6 +47,7 @@ | 文档 | 内容摘要 | |------|---------| +| [海报生成工作台完整重构修复计划.md](海报生成工作台完整重构修复计划.md) | 海报二期重构执行基线:自由上传、来源校验、AI 视觉候选、可编辑画布、多格式导出、后台同步、测试与上线 | | [PPT与海报功能问题整改计划.md](PPT与海报功能问题整改计划.md) | 当前问题审计、根因、文件级修复方案、测试、发布与回滚计划 | | [PPT与海报功能完整解决方案.md](PPT与海报功能完整解决方案.md) | PPT 增强 + 海报新模块:数据库设计、API、前端、AI 生图方案(已确认) | | [PPT与海报功能开发任务清单.md](PPT与海报功能开发任务清单.md) | 120 项可勾选任务,分 7 个 Phase,预估 11.5 天 | diff --git a/docs/海报生成工作台完整重构修复计划.md b/docs/海报生成工作台完整重构修复计划.md new file mode 100644 index 0000000..4b7e9fc --- /dev/null +++ b/docs/海报生成工作台完整重构修复计划.md @@ -0,0 +1,1889 @@ +# 海报生成工作台完整重构修复计划 + +> **文档版本**:v1.0 +> **编制日期**:2026-07-29 +> **适用范围**:`api/insurance/poster/`、`api/insurance/generation/`、`frontend/src/pages/Poster*`、`frontend/src/components/poster/` +> **实施状态**:待开发 +> **优先级**:P0/P1 +> **预计工作量**:约 24.5 人日,建议预留 20% 风险缓冲 + +--- + +## 一、文档目的 + +本计划用于把现有“选择后台产品和模板、生成文案、输出单张 PNG”的海报功能,重构为完整的保险营销内容生产工作台: + +```text +创建项目 + → 选择后台产品或上传产品小册子 + → 上传客户计划书和参考素材 + → 异步解析 + → 数据底表 / 来源审计 / QA 校验 + → 人工确认 + → AI 生成视觉方案和文案 + → 生成可编辑画布 + → 人工编辑与版本保存 + → PNG / JPG / PDF / 项目源文件导出 + → 任务、历史、配置版本和审计记录同步到后台 +``` + +本文是海报模块二期重构的执行基线。当本文与以下旧文档的“海报”部分冲突时,以本文为准;旧文档中的 PPT 整改内容继续有效: + +- `PPT与海报功能完整解决方案.md` +- `PPT与海报功能开发任务清单.md` +- `PPT与海报功能问题整改计划.md` + +--- + +## 二、目标、边界与关键假设 + +### 2.1 建设目标 + +| 编号 | 目标 | 成功标准 | +|------|------|----------| +| G-01 | 用户可自由提供本次创作资料 | 计划书 PDF、产品小册子 PDF、参考图片均可直接上传 | +| G-02 | 后台产品库不再是唯一入口 | 用户可从产品库选择,也可仅在当前项目使用临时小册子 | +| G-03 | AI 生成与内容相符的视觉素材 | AI 根据客户、产品、场景、品牌约束生成 2~4 个真实视觉候选 | +| G-04 | 文字和数字不烘焙进 AI 图片 | 标题、卖点、数字、图表、Logo、免责声明均为独立画布图层 | +| G-05 | 生成前完成真实数据校验 | 每个关键事实可追溯到文件、页码和原文;错误项阻断生成 | +| G-06 | 生成结果可继续编辑 | 支持选择、移动、缩放、改字、换图、图层、撤销重做和自动保存 | +| G-07 | 支持多格式交付 | 首期支持 PNG、JPG、PDF、项目源文件 | +| G-08 | 全链路与后台同步 | 项目、素材、校验、任务、画布版本、导出物和配置快照可恢复、可审计 | +| G-09 | 保持 BaoDan 升级安全 | 自研实现全部位于 `api/insurance/` 和独立 `frontend/` 项目 | + +### 2.2 不在本期范围 + +以下能力不进入首期 P0/P1,避免海报编辑器无限扩张: + +- 多人同时在线协作编辑; +- 类 Photoshop 的像素级蒙版、钢笔和滤镜系统; +- 视频海报、GIF、动画海报; +- 任意第三方字体商城; +- 用户自定义脚本或 HTML; +- 社交平台自动发布; +- SVG 正式交付;完成兼容性验证后作为 P2; +- 手机端进行精细拖拽排版。 + +### 2.3 默认产品假设 + +| 决策 | 默认方案 | 原因 | +|------|----------|------| +| 编辑器主要设备 | 桌面端和大屏平板 | 精细排版不适合手机单手操作 | +| 手机端范围 | 上传、校验、任务查看、预览、基础文字/图片替换 | 保证移动业务连续性,不承诺精细排版 | +| 画布技术 | Fabric.js | 具备文本编辑、对象模型、图层、JSON 序列化和图片导出能力 | +| AI 图片职责 | 生成背景、人物、场景、装饰素材 | 避免 AI 中文和保险数字错误 | +| 画布职责 | 组合图片、文字、数字、图表、Logo、免责声明 | 保证可编辑、可审计、可重复导出 | +| 数据主对象 | 继续使用 `PosterRecord` | 已具备工作区、版本和任务字段,避免新建重复项目表 | +| 任务系统 | 继续使用 `GenerationTask` + BaoDan Celery | 不重复建设队列和状态系统 | +| 首期导出 | PNG、JPG、PDF、项目 JSON | 覆盖朋友圈、私聊、打印和继续编辑 | + +--- + +## 三、当前基线与必须修复的问题 + +### 3.1 已有能力 + +| 能力 | 现状 | 处理策略 | +|------|------|----------| +| 计划书 PDF 上传 | 已实现真实 multipart 上传 | 保留并迁移到项目素材模型 | +| PDF 基础安全检查 | 已检查大小、魔数、加密和页数 | 保留并扩展到所有项目素材 | +| 异步解析 | 已接入 Celery 和任务状态 | 保留,统一为项目级解析任务 | +| 人工修正解析结果 | 前端已有表单 | 重构为事实表和来源审计 | +| AI 文案 | 已支持模板/AI 双模式 | 保留,限制只能引用已确认事实 | +| AI 图片 | 已调用图片模型 | 改为“视觉素材生成”,不直接生成带字成品 | +| 工作区恢复 | 已支持 URL 恢复部分状态 | 扩展为完整项目恢复 | +| 统一任务中心 | 已有 `GenerationTask` | 增加海报细分操作类型和任务快照 | +| 海报历史 | 已有分页、预览和下载 | 重构为项目历史和导出物列表 | +| 后台模板和模型设置 | 已使用数据库配置 | 增加版本号和任务提交时快照 | + +### 3.2 P0 阻断问题 + +| 编号 | 问题 | 影响 | 修复阶段 | +|------|------|------|----------| +| POSTER2-P0-01 | Worker 保存到 `outputs/posters`,下载接口只允许 `uploads/posters` | 生成成功仍可能无法预览或下载 | Phase 0 | +| POSTER2-P0-02 | 用户不能上传产品小册子 | 新产品、临时资料无法开始创作 | Phase 2 | +| POSTER2-P0-03 | `confirmedData` 缺少后端 Schema 和业务校验 | 错误保险数字可进入生成 | Phase 2 | +| POSTER2-P0-04 | 生成结果不可编辑 | 无法满足修正文案、版式和图片要求 | Phase 4 | +| POSTER2-P0-05 | 只有 PNG 导出 | 无法满足打印、图片质量和继续编辑 | Phase 5 | + +### 3.3 P1 主要问题 + +| 编号 | 问题 | 影响 | 修复阶段 | +|------|------|------|----------| +| POSTER2-P1-01 | AI 图片仍受固定背景模板思维支配 | 与客户、产品和场景的语义匹配不足 | Phase 3 | +| POSTER2-P1-02 | 前后端尺寸值不一致 | 横版、方图可能回退为竖版 | Phase 0 | +| POSTER2-P1-03 | 生成失败时工作区和任务状态可能不一致 | 页面可能永久停留在生成中 | Phase 0 | +| POSTER2-P1-04 | 参考图以 URL/字符串传递 | 无真实上传、权限和生命周期管理 | Phase 2 | +| POSTER2-P1-05 | 生成前绿色核对项不是实际校验 | 制造虚假合规感 | Phase 2 | +| POSTER2-P1-06 | 任务中心和历史记录职责重叠 | 用户无法区分“运行任务”和“可继续编辑项目” | Phase 6 | +| POSTER2-P1-07 | 后台配置没有提交时版本快照 | 无法证明成品使用了哪一版配置 | Phase 1、6 | +| POSTER2-P1-08 | 重新生成会覆盖用户心智中的当前结果 | 缺少候选方案和版本回退 | Phase 3、4 | + +--- + +## 四、目标产品流程与信息架构 + +### 4.1 页面结构 + +```text +/poster +├── 新建项目 +├── 最近项目 +└── 运行中的任务摘要 + +/poster/:projectId +├── 1. 项目资料 +├── 2. 数据校验 +├── 3. 创意方案 +├── 4. 画布编辑 +└── 5. 导出发布 + +/poster/:projectId/editor +└── 全屏画布编辑器 + +/poster/history +├── 进行中 +├── 已完成 +├── 已归档 +└── 项目版本 / 导出物 + +/tasks?artifact_type=poster +└── 只展示解析、AI 生成、合成、导出等运行任务 +``` + +### 4.2 五阶段业务流程 + +#### 阶段 1:项目资料 + +用户可以任选一种产品资料来源: + +1. 从后台已审核产品库选择; +2. 上传本次使用的产品小册子 PDF; +3. 选择后台产品后,再上传补充小册子覆盖本次项目。 + +本阶段素材区域包括: + +- 客户计划书 PDF:必需; +- 产品小册子 PDF:选择后台产品时可选,否则必需; +- 参考图片:可选,最多 6 张; +- Logo/品牌素材:默认从后台保司配置读取,可临时覆盖; +- PDF 密码:仅用于本次解密,不落库。 + +每个素材显示: + +- 原始文件名; +- 文件类型和大小; +- 页数或图片尺寸; +- 上传时间; +- 解析状态; +- 来源:后台库 / 用户上传 / AI 生成; +- 替换、删除和重新解析操作。 + +#### 阶段 2:数据校验 + +本阶段使用三个标签页: + +| 标签页 | 内容 | 目的 | +|--------|------|------| +| 数据底表 | 规范化字段、解析值、确认值、单位 | 集中编辑事实 | +| 来源审计 | 文件、页码、原文片段、置信度 | 证明数据来自哪里 | +| QA 校验 | 错误、警告、通过项 | 决定是否允许生成 | + +状态规则: + +```text +unreviewed → passed + → warning → accepted + → error → fixed → passed +``` + +- 存在 `error`:禁止进入创意方案; +- 存在 `warning`:用户必须逐项接受或修正; +- 所有必填事实 `passed/accepted`:记录确认人和时间,允许生成; +- 源文件替换或重新解析:原确认版本失效,必须重新校验。 + +#### 阶段 3:创意方案 + +用户填写或选择视觉简报: + +- 使用场景:朋友圈、客户私聊、讲座邀请、产品提案、长图说明; +- 目标客群:年龄、家庭阶段、职业、关注点; +- 核心主题:保障、增值、传承、教育、退休等; +- 情绪方向:稳健、温暖、高端、现代、自然; +- 视觉主体:人物、家庭、住宅、城市、自然、抽象金融意象; +- 摄影/插画风格; +- 品牌颜色; +- 禁用元素; +- 参考图片; +- 输出尺寸和画布类型。 + +生成过程: + +1. 系统从已确认事实生成受约束文案; +2. 系统生成 2~4 个不含文字、Logo 和数字的视觉候选; +3. 候选图展示生成理由、风格标签和使用的简报; +4. 用户选中一个候选,或只重新生成某个候选; +5. 系统使用版式预设生成初始画布 JSON。 + +#### 阶段 4:画布编辑 + +全屏工作台布局: + +```text +┌──────────────── 顶部工具栏 ────────────────┐ +│ 返回 / 保存状态 / 撤销 / 重做 / 缩放 / 预览 / 导出 │ +├──────────┬──────────────────────┬───────────┤ +│ 页面与素材 │ 可编辑画布 │ 图层与属性 │ +│ AI 候选图 │ │ 数据来源 │ +│ 品牌素材 │ │ QA 状态 │ +└──────────┴──────────────────────┴───────────┘ +``` + +首期必须支持: + +- 文字直接编辑; +- 字体、字号、行高、字间距、颜色、对齐; +- 图片替换、裁剪、缩放、定位; +- 矩形、线条、背景色; +- 数据卡、图标、Logo、免责声明; +- 图层显示、隐藏、锁定、排序; +- 多选、对齐、吸附和安全区; +- 撤销、重做; +- 自动保存; +- 版本历史和恢复; +- 单独重新生成选中的 AI 图片; +- 根据事实数据重新生成文案; +- 事实字段变更后标记关联图层“数据已过期”。 + +#### 阶段 5:导出发布 + +导出面板提供: + +| 格式 | 用途 | 首期要求 | +|------|------|----------| +| PNG | 朋友圈、企微、网页 | 支持 1x/2x,透明背景按画布能力决定 | +| JPG | 体积较小的图片分发 | 可选质量 80/90/100 | +| PDF | 打印、客户存档 | 单页海报或多页长图分页 | +| 项目 JSON | 继续编辑、备份迁移 | 包含画布 Schema 版本,不嵌入密钥和临时 URL | + +每个导出物记录: + +- 使用的画布版本; +- 使用的事实确认版本; +- 配置快照版本; +- 文件格式、尺寸、大小和 MIME; +- 导出人和导出时间; +- 下载次数; +- 是否为正式交付版。 + +--- + +## 五、总体技术架构 + +### 5.1 架构原则 + +1. `PosterRecord` 继续作为项目/工作区根对象,不重命名数据库表; +2. `GenerationTask` 继续作为一次异步执行记录; +3. 文件统一保存到 `INSURANCE_STORAGE_ROOT`; +4. 数据库保存对象键,不保存依赖工作目录的绝对路径; +5. AI 生成图片与确定性文字排版分离; +6. 画布 JSON 是可编辑成品的唯一事实来源; +7. PNG/JPG/PDF 是画布某一版本的不可变导出物; +8. 所有模型、Prompt、品牌和模板配置在任务提交时生成快照。 + +### 5.2 数据流 + +```mermaid +flowchart LR + U["用户上传 / 后台产品库"] --> A["项目素材"] + A --> P["Celery 解析任务"] + P --> F["事实与来源证据"] + F --> V["规则校验 + 人工确认"] + V -->|通过| B["创意简报"] + B --> C["文案生成"] + B --> I["AI 视觉候选"] + C --> K["初始画布构建"] + I --> K + K --> E["Fabric.js 编辑器"] + E --> S["画布版本保存"] + S --> X["PNG / JPG / PDF / 项目文件"] + X --> H["项目历史和后台审计"] +``` + +### 5.3 AI 与画布职责边界 + +| 内容 | AI 图片模型 | LLM | 画布引擎 | +|------|:-----------:|:---:|:--------:| +| 人物、家庭、建筑、自然场景 | ✅ | | 组合 | +| 装饰纹理、氛围背景 | ✅ | | 组合 | +| 标题和正文文案 | | ✅ | ✅ 可编辑 | +| 保费、保额、缴费期等数字 | | 只能引用事实 | ✅ 可编辑 | +| 图表 | | 生成结构建议 | ✅ 确定性绘制 | +| Logo | 禁止生成 | | ✅ 使用真实素材 | +| 免责声明 | 禁止生成 | 规则选择 | ✅ 固定图层 | + +--- + +## 六、数据库与迁移设计 + +### 6.1 迁移策略 + +- 新迁移编号从 `migrate_026.py` 开始; +- 不删除现有字段和表; +- 旧 `PosterRecord` 自动视为一个项目; +- 旧 PNG 作为历史导出物回填; +- 旧 `PosterCaseUpload` 保留兼容读取; +- 新流程稳定两个版本后再评估旧字段清理; +- 所有迁移必须幂等并支持 PostgreSQL/MySQL 的现有兼容策略。 + +### 6.2 扩展 `poster_records` + +继续作为海报项目根对象,新增字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `validation_status` | VARCHAR(20) | pending/running/blocked/warning/passed | +| `validation_summary_json` | TEXT | 错误、警告、通过数量和确认版本 | +| `creative_brief_json` | TEXT | 用户创意简报 | +| `selected_visual_asset_id` | BIGINT | 选中的 AI 视觉素材 | +| `canvas_schema_version` | VARCHAR(20) | 画布 Schema 版本 | +| `current_canvas_version` | INTEGER | 当前画布版本号 | +| `config_snapshot_json` | TEXT | 模型、Prompt、模板、品牌配置快照 | +| `fact_revision` | INTEGER | 事实数据版本 | +| `confirmed_fact_revision` | INTEGER | 最近确认的事实版本 | +| `updated_at` | TIMESTAMP | 项目最后更新时间 | + +保留但调整语义: + +- `template_id`:从固定背景模板改为版式/品牌预设; +- `copy_content`:当前文案图层内容; +- `export_url/export_format`:兼容旧接口,指向最近正式导出物; +- `workflow_step`:改用 `sources/validation/creative/editor/export`; +- `draft_revision/generated_revision`:继续用于乐观锁和生成版本判断。 + +### 6.3 新建 `poster_project_assets` + +用途:统一保存本项目所有输入、AI 候选和品牌素材。 + +| 字段 | 类型 | 约束/说明 | +|------|------|-----------| +| `id` | BIGINT | 主键 | +| `project_id` | BIGINT | INDEX,关联 `poster_records.id` | +| `user_id` | VARCHAR(64) | INDEX,所有权校验 | +| `asset_type` | VARCHAR(30) | case_pdf/product_manual/reference/brand/generated_visual | +| `source_type` | VARCHAR(20) | upload/library/generated | +| `source_ref_id` | VARCHAR(64) | 后台产品或保司素材 ID | +| `file_key` | VARCHAR(500) | storage 对象键 | +| `original_name` | VARCHAR(200) | 原始文件名 | +| `mime_type` | VARCHAR(80) | MIME | +| `file_size` | BIGINT | 字节 | +| `sha256` | VARCHAR(64) | 去重和审计 | +| `page_count` | INTEGER | PDF 页数 | +| `width` / `height` | INTEGER | 图片尺寸 | +| `parse_status` | VARCHAR(20) | pending/queued/parsing/parsed/failed | +| `parse_task_id` | VARCHAR(36) | 关联 `GenerationTask` | +| `metadata_json` | TEXT | EXIF 清理、候选理由、模型信息等 | +| `created_at` | TIMESTAMP | 创建时间 | +| `deleted_at` | TIMESTAMP | 软删除 | + +索引: + +- `INDEX(project_id, asset_type)` +- `INDEX(user_id, created_at)` +- `INDEX(sha256)` + +### 6.4 新建 `poster_fact_items` + +用途:保存结构化事实、来源证据和人工确认结果。 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | BIGINT | 主键 | +| `project_id` | BIGINT | INDEX | +| `fact_revision` | INTEGER | 所属事实版本 | +| `fact_path` | VARCHAR(120) | 如 `customer.age`、`policy.annual_premium` | +| `label` | VARCHAR(100) | 用户可读字段名 | +| `value_json` | TEXT | 系统解析值 | +| `confirmed_value_json` | TEXT | 人工确认值 | +| `unit` | VARCHAR(30) | USD、HKD、年、岁等 | +| `source_asset_id` | BIGINT | 来源素材 | +| `source_page` | INTEGER | PDF 页码 | +| `source_quote` | TEXT | 原文片段,限制长度 | +| `confidence` | DECIMAL(5,4) | 解析置信度 | +| `validation_status` | VARCHAR(20) | unreviewed/passed/warning/error/accepted | +| `validation_code` | VARCHAR(50) | 结构化规则码 | +| `validation_message` | VARCHAR(500) | 用户可读说明 | +| `confirmed_by` | VARCHAR(64) | 确认人 | +| `confirmed_at` | TIMESTAMP | 确认时间 | +| `created_at` / `updated_at` | TIMESTAMP | 时间字段 | + +唯一约束: + +- `UNIQUE(project_id, fact_revision, fact_path)` + +### 6.5 新建 `poster_canvas_versions` + +用途:保存可编辑画布版本,支持自动保存、版本恢复和审计。 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | BIGINT | 主键 | +| `project_id` | BIGINT | INDEX | +| `version` | INTEGER | 项目内递增版本 | +| `canvas_schema_version` | VARCHAR(20) | 如 `1.0` | +| `canvas_json` | TEXT | Fabric 画布文档 | +| `preview_file_key` | VARCHAR(500) | 低分辨率预览 | +| `fact_revision` | INTEGER | 使用的事实版本 | +| `config_snapshot_json` | TEXT | 使用的配置快照 | +| `source_task_id` | VARCHAR(36) | 初始合成任务 ID | +| `change_summary` | VARCHAR(500) | 自动保存/人工保存/恢复等 | +| `created_by` | VARCHAR(64) | 操作人 | +| `created_at` | TIMESTAMP | 创建时间 | + +唯一约束: + +- `UNIQUE(project_id, version)` + +版本策略: + +- 自动保存更新当前草稿,不为每次按键创建永久版本; +- 用户点击“保存版本”、生成初稿、导出正式版时创建永久版本; +- 每个项目默认保留最近 30 个永久版本; +- 被导出物引用的版本不得自动清理。 + +### 6.6 新建 `poster_export_artifacts` + +用途:保存同一画布版本的多格式导出物。 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | BIGINT | 主键 | +| `project_id` | BIGINT | INDEX | +| `canvas_version_id` | BIGINT | 关联画布版本 | +| `task_id` | VARCHAR(36) | 导出任务,可为空 | +| `format` | VARCHAR(20) | png/jpg/pdf/project_json | +| `file_key` | VARCHAR(500) | storage 对象键 | +| `mime_type` | VARCHAR(80) | MIME | +| `width` / `height` | INTEGER | 像素尺寸 | +| `dpi` | INTEGER | PDF/打印参数 | +| `file_size` | BIGINT | 文件大小 | +| `status` | VARCHAR(20) | queued/ready/failed | +| `is_official` | BOOLEAN | 是否正式交付版本 | +| `download_count` | INTEGER | 下载次数 | +| `created_by` | VARCHAR(64) | 导出人 | +| `created_at` | TIMESTAMP | 创建时间 | + +### 6.7 扩展 `insurance_generation_tasks` + +修改: + +- `operation` 从 `VARCHAR(10)` 扩展到 `VARCHAR(30)`; +- 支持以下操作值: + - `parse_sources` + - `validate_facts` + - `generate_copy` + - `generate_visuals` + - `compose_canvas` + - `export_artifact` +- `input_snapshot_json` 必须包含配置版本和输入修订号; +- `output_json` 保存生成素材 ID、画布版本或导出物 ID; +- 失败时同步更新项目或素材状态; +- 增加结构化 `error_code`,禁止只保存异常字符串。 + +### 6.8 旧数据回填 + +迁移 026 完成: + +1. 为现有 `poster_records` 填充: + - `validation_status='passed'`,并标记 `legacy=true`; + - `canvas_schema_version=NULL`; + - `current_canvas_version=0`。 +2. 将现有 `case_upload_id` 对应文件注册为 `case_pdf` 素材; +3. 将现有 `reference_image_used` 注册为参考素材;文件不存在时只记录迁移警告; +4. 将现有 `export_url` 注册为 `poster_export_artifacts`; +5. 旧项目仍可预览和下载,但只有点击“转换为可编辑项目”后才生成画布; +6. 回填过程输出成功、跳过、文件缺失和异常数量。 + +--- + +## 七、后端 API 设计 + +### 7.1 兼容策略 + +- 保留现有 `/poster/products`、`/poster/records`、`/poster/download/{id}`; +- 新页面优先调用工作区接口; +- 旧生成向导通过功能开关保留一个发布周期; +- 新接口统一返回: + +```json +{ + "code": 0, + "message": "success", + "data": {} +} +``` + +### 7.2 项目接口 + +| 方法 | URL | 用途 | +|------|-----|------| +| POST | `/insurance/poster/workspaces` | 创建空项目 | +| GET | `/insurance/poster/workspaces` | 查询本人项目 | +| GET | `/insurance/poster/workspaces/{id}` | 获取完整项目摘要 | +| PATCH | `/insurance/poster/workspaces/{id}/draft` | 自动保存步骤、简报和草稿 | +| PUT | `/insurance/poster/workspaces/{id}/rename` | 重命名 | +| POST | `/insurance/poster/workspaces/{id}/copy` | 复制项目 | +| PUT | `/insurance/poster/workspaces/{id}/archive` | 归档 | +| PUT | `/insurance/poster/workspaces/{id}/unarchive` | 取消归档 | + +创建项目请求示例: + +```json +{ + "title": "宏挚传承保障计划|客户 A", + "useMaskedData": true +} +``` + +### 7.3 素材接口 + +| 方法 | URL | 用途 | +|------|-----|------| +| POST | `/poster/workspaces/{id}/assets` | 上传 PDF/图片 | +| GET | `/poster/workspaces/{id}/assets` | 素材列表 | +| DELETE | `/poster/workspaces/{id}/assets/{assetId}` | 软删除素材 | +| POST | `/poster/workspaces/{id}/products/attach` | 关联后台产品 | +| POST | `/poster/workspaces/{id}/parse` | 投递项目解析任务 | +| POST | `/poster/workspaces/{id}/assets/{assetId}/retry` | 重试单个素材解析 | + +上传表单字段: + +| 字段 | 必填 | 说明 | +|------|:----:|------| +| `assetType` | 是 | case_pdf/product_manual/reference/brand | +| `file` | 是 | 文件 | +| `password` | 否 | PDF 密码,不保存 | +| `replaceAssetId` | 否 | 替换指定素材 | + +### 7.4 事实与校验接口 + +| 方法 | URL | 用途 | +|------|-----|------| +| GET | `/poster/workspaces/{id}/facts` | 获取当前事实版本 | +| PATCH | `/poster/workspaces/{id}/facts/{factId}` | 修改单个事实 | +| PUT | `/poster/workspaces/{id}/facts` | 批量保存事实 | +| POST | `/poster/workspaces/{id}/validate` | 执行服务端校验 | +| POST | `/poster/workspaces/{id}/warnings/{factId}/accept` | 接受警告 | +| POST | `/poster/workspaces/{id}/validation/confirm` | 确认当前事实版本 | +| GET | `/poster/workspaces/{id}/source-audit` | 获取来源审计视图 | + +确认接口必须检查: + +- 请求事实版本等于项目当前版本; +- 无 `error`; +- 所有必填事实存在来源或有人工补录原因; +- 所有 `warning` 已处理; +- 请求用户拥有项目; +- 确认动作写审计日志。 + +### 7.5 创意与 AI 接口 + +| 方法 | URL | 用途 | +|------|-----|------| +| PUT | `/poster/workspaces/{id}/creative-brief` | 保存创意简报 | +| POST | `/poster/workspaces/{id}/copy/generate` | 生成受约束文案 | +| POST | `/poster/workspaces/{id}/visuals/generate` | 生成 2~4 个视觉候选 | +| GET | `/poster/workspaces/{id}/visuals` | 获取候选素材 | +| POST | `/poster/workspaces/{id}/visuals/{assetId}/select` | 选中候选 | +| POST | `/poster/workspaces/{id}/visuals/{assetId}/regenerate` | 重新生成指定候选 | +| POST | `/poster/workspaces/{id}/canvas/compose` | 生成初始画布 | + +所有生成接口必须: + +- 校验 `validation_status='passed'`; +- 固化事实版本、配置版本和画布输入版本; +- 使用幂等键; +- 3 秒内返回任务 ID; +- 不等待图片模型完成; +- 可通过统一任务接口恢复状态。 + +### 7.6 画布接口 + +| 方法 | URL | 用途 | +|------|-----|------| +| GET | `/poster/workspaces/{id}/canvas` | 获取当前画布 | +| PATCH | `/poster/workspaces/{id}/canvas` | 自动保存草稿,带乐观锁 | +| POST | `/poster/workspaces/{id}/canvas/versions` | 创建永久版本 | +| GET | `/poster/workspaces/{id}/canvas/versions` | 版本列表 | +| POST | `/poster/workspaces/{id}/canvas/versions/{version}/restore` | 恢复版本 | +| POST | `/poster/workspaces/{id}/canvas/preview` | 上传低清预览 | + +自动保存请求: + +```json +{ + "expectedRevision": 12, + "canvasSchemaVersion": "1.0", + "canvas": { + "width": 1080, + "height": 1920, + "objects": [] + } +} +``` + +版本冲突返回明确错误和服务器最新版本,不静默覆盖。 + +### 7.7 导出接口 + +| 方法 | URL | 用途 | +|------|-----|------| +| POST | `/poster/workspaces/{id}/exports` | 创建导出物记录或导出任务 | +| GET | `/poster/workspaces/{id}/exports` | 导出物列表 | +| GET | `/poster/exports/{artifactId}/download` | 鉴权下载 | +| PUT | `/poster/exports/{artifactId}/official` | 标记正式交付版本 | +| DELETE | `/poster/exports/{artifactId}` | 删除非正式导出物 | + +首期采用浏览器高分辨率画布导出,再通过鉴权接口上传成品: + +1. 前端加载已保存画布版本; +2. 在隐藏高分辨率画布渲染; +3. PNG/JPG 使用 `toBlob`; +4. PDF 将高分辨率画布按页面尺寸嵌入 PDF; +5. 上传到后端; +6. 后端校验项目所有权、格式、MIME、大小和画布版本; +7. 文件写入统一 storage; +8. 创建不可变导出物记录。 + +后续若需要服务端批量导出,再增加独立渲染 Worker,不在首期重复建设。 + +--- + +## 八、后端模块改造 + +### 8.1 建议目录 + +```text +api/insurance/poster/ +├── routes.py # 兼容旧接口 +├── service.py # 兼容旧业务 +├── project_routes.py # 项目、素材、画布、导出 +├── project_service.py # 项目聚合服务 +├── asset_service.py # 文件与素材生命周期 +├── extraction_service.py # 多资料解析编排 +├── fact_schema.py # Pydantic/Schema +├── validation_service.py # 事实与业务规则校验 +├── source_audit_service.py # 来源证据 +├── creative_service.py # 创意简报和文案约束 +├── visual_generator.py # AI 视觉候选 +├── canvas_builder.py # 确定性初始画布 JSON +├── export_service.py # 导出物接收、校验和存储 +├── storage_service.py # 项目文件路径安全封装 +└── schemas/ + ├── canvas_v1.py + ├── creative_brief.py + └── insurance_facts.py +``` + +说明: + +- 不一次性拆分现有所有函数; +- 旧 `routes.py/service.py` 保持兼容; +- 新逻辑放独立文件; +- 迁移完成后逐个旧接口转调新服务; +- 不修改 BaoDan 核心模块。 + +### 8.2 解析编排 + +解析任务输入: + +- 项目 ID; +- case PDF 素材 ID; +- product manual 素材 ID 或后台产品快照; +- 当前事实版本; +- 模型配置快照。 + +解析任务输出: + +- 标准事实列表; +- 每个事实的来源文件、页码、原文和置信度; +- 未识别字段; +- 冲突字段; +- 解析错误。 + +PDF 文本必须作为“不可信数据”传给模型,防止文档中的提示注入覆盖系统指令。 + +### 8.3 事实 Schema + +首期核心事实: + +```text +customer.age +customer.gender +customer.smoking_status +policy.product_name +policy.company_name +policy.currency +policy.sum_assured +policy.annual_premium +policy.payment_period +policy.coverage_period +policy.total_basic_premium +policy.cash_value.* +policy.death_benefit.* +product.features[] +product.exclusions[] +product.disclaimer +``` + +不同险种通过 Schema 配置决定必填字段,不强行让所有险种共用完全相同的字段。 + +### 8.4 校验规则 + +| 规则码 | 类型 | 规则 | +|--------|------|------| +| REQUIRED_FIELD_MISSING | error | 必填字段缺失 | +| SOURCE_EVIDENCE_MISSING | error | 关键数字无文件、页码或人工补录说明 | +| PRODUCT_NAME_CONFLICT | error | 计划书与产品册产品名无法匹配 | +| CURRENCY_CONFLICT | error | 同一关键金额存在不同币种 | +| PAYMENT_PERIOD_CONFLICT | error | 缴费期在资料间冲突 | +| PREMIUM_OUT_OF_RANGE | warning/error | 保费超出合理数值范围 | +| AGE_OUT_OF_RANGE | error | 年龄不在 0~120 | +| LOW_CONFIDENCE | warning | 置信度低于阈值 | +| MANUAL_OVERRIDE | warning | 用户值与解析值不同 | +| DISCLAIMER_MISSING | error | 无可用免责声明 | +| FACT_REVISION_STALE | error | 校验/生成使用过期事实版本 | + +业务规则由后台配置阈值,但规则代码和安全下限保留在服务端。 + +### 8.5 配置快照 + +每次 `generate_copy`、`generate_visuals`、`compose_canvas`、`export_artifact` 提交时保存: + +- 文案模型 provider/model; +- 图片模型 provider/model; +- Prompt 模板 ID、版本和内容哈希; +- 版式预设 ID、版本和内容哈希; +- 保司品牌配置; +- Logo 素材 ID 和哈希; +- 配色方案; +- 免责声明版本; +- 校验规则版本; +- fallback 是否允许; +- 提交时间。 + +任务执行期间后台设置变化,不影响已提交任务。 + +--- + +## 九、前端重构计划 + +### 9.1 路由 + +```text +/poster PosterHomePage +/poster/:projectId PosterProjectPage +/poster/:projectId/editor PosterEditorPage +/poster/history PosterProjectsPage +/tasks TasksPage(继续复用) +``` + +### 9.2 页面和组件 + +```text +frontend/src/ +├── pages/ +│ ├── PosterHomePage.vue +│ ├── PosterProjectPage.vue +│ ├── PosterEditorPage.vue +│ └── PosterProjectsPage.vue +├── components/poster/ +│ ├── sources/ +│ │ ├── PosterSourceStep.vue +│ │ ├── ProductSourcePicker.vue +│ │ ├── ProjectAssetUploader.vue +│ │ └── AssetStatusCard.vue +│ ├── validation/ +│ │ ├── PosterValidationStep.vue +│ │ ├── FactTable.vue +│ │ ├── SourceAuditPanel.vue +│ │ └── ValidationIssues.vue +│ ├── creative/ +│ │ ├── PosterCreativeStep.vue +│ │ ├── CreativeBriefForm.vue +│ │ ├── VisualCandidateGrid.vue +│ │ └── CopyReviewPanel.vue +│ ├── editor/ +│ │ ├── PosterCanvas.vue +│ │ ├── EditorToolbar.vue +│ │ ├── AssetLibraryPanel.vue +│ │ ├── LayerPanel.vue +│ │ ├── PropertyPanel.vue +│ │ ├── DataBindingPanel.vue +│ │ └── VersionHistoryDrawer.vue +│ └── export/ +│ ├── PosterExportStep.vue +│ ├── ExportSettings.vue +│ └── ExportArtifactList.vue +├── composables/ +│ ├── usePosterProject.ts +│ ├── usePosterCanvas.ts +│ ├── usePosterAutosave.ts +│ ├── usePosterTasks.ts +│ └── useUndoRedo.ts +├── types/ +│ ├── poster-project.ts +│ ├── poster-canvas.ts +│ └── poster-validation.ts +└── utils/ + └── poster-api.ts +``` + +### 9.3 渐进迁移 + +1. 保留当前 `PosterPage.vue` 和四个 `PosterStep*` 组件; +2. 增加 `POSTER_WORKBENCH_V2_ENABLED`; +3. 测试账号进入 V2; +4. V2 稳定后 `/poster` 指向新首页; +5. 旧项目继续用旧详情查看; +6. 提供“转换为可编辑项目”; +7. 一个发布周期后移除旧创建入口; +8. 最后再删除确定不再引用的旧组件。 + +### 9.4 画布文档 Schema + +画布对象除 Fabric 原生字段外,增加业务元数据: + +```json +{ + "schemaVersion": "1.0", + "width": 1080, + "height": 1920, + "layoutPresetId": 12, + "objects": [ + { + "id": "headline", + "type": "textbox", + "role": "headline", + "text": "真正的安全感,是未雨绸缪的从容", + "dataBinding": null, + "locked": false + }, + { + "id": "premium-value", + "type": "textbox", + "role": "fact", + "dataBinding": { + "factPath": "policy.annual_premium", + "factRevision": 3, + "format": "currency" + } + } + ] +} +``` + +业务要求: + +- 每个对象必须有稳定 `id`; +- 关键数字图层必须有 `dataBinding`; +- Logo、免责声明和事实图层可配置最小字号和锁定规则; +- 加载时检测事实版本是否过期; +- 画布 JSON 不保存 Blob URL; +- 图片对象只保存后端素材 ID 和鉴权加载地址。 + +### 9.5 自动保存 + +- 用户停止操作 1.5 秒后保存; +- 最长每 10 秒保存一次; +- 路由离开前刷新未提交保存; +- 使用 `expectedRevision` 乐观锁; +- 网络失败保留本地待提交队列; +- 恢复网络后重试; +- 版本冲突不自动覆盖,显示“使用服务器版本 / 另存副本”; +- 页面顶部始终显示:保存中、已保存、离线、保存失败。 + +### 9.6 撤销与版本 + +- 前端撤销栈:最近 100 次操作; +- 重载页面后撤销栈不保证保留; +- 永久版本:生成初稿、用户手动保存、正式导出、恢复旧版时创建; +- 恢复旧版本会创建新版本,不重写历史; +- AI 局部重生成前自动创建版本。 + +### 9.7 响应式和可访问性 + +桌面端: + +- 完整三栏编辑器; +- 快捷键: + - `Ctrl/Cmd+Z` 撤销; + - `Ctrl/Cmd+Shift+Z` 重做; + - `Ctrl/Cmd+S` 保存版本; + - `Delete` 删除; + - 方向键微调; + - `Shift+方向键` 大步移动。 + +手机端: + +- 五阶段流程可查看; +- 支持上传、校验、选择视觉方案; +- 编辑器进入简化模式,只允许改字、换图、切换预设; +- 提示“精细排版请使用桌面端”,但不阻断查看和导出。 + +无障碍要求: + +- 所有按钮有文字或 `aria-label`; +- 画布对象在图层面板中可用键盘选择; +- 焦点样式可见; +- 状态变化通过 `aria-live` 播报; +- 错误不只使用颜色表达; +- 文字与背景对比度满足 WCAG AA; +- 放大到 200% 时校验和导出流程可操作。 + +--- + +## 十、AI 生成方案 + +### 10.1 文案生成 + +输入仅包括: + +- 已确认事实; +- 产品卖点; +- 使用场景; +- 目标客群; +- 品牌语气; +- 文案长度约束; +- 免责声明。 + +输出使用结构化 Schema: + +```json +{ + "headline": "", + "subheadline": "", + "sellingPoints": [ + { + "title": "", + "description": "", + "factRefs": ["policy.payment_period"] + } + ], + "cta": "", + "disclaimer": "", + "unsupportedClaims": [] +} +``` + +规则: + +- 关键数字必须附 `factRefs`; +- 找不到事实时不得编造; +- `unsupportedClaims` 非空时阻断进入正式画布; +- 文案更改事实数字时立即标记错误; +- AI 原始响应保留审计,但前端默认展示规范化结果。 + +### 10.2 视觉生成 + +Prompt 只描述: + +- 场景、人物、环境和情绪; +- 画面构图和安全留白; +- 摄影/插画风格; +- 品牌颜色倾向; +- 输出比例; +- 禁止元素。 + +必须加入负向约束: + +```text +不要生成任何文字、字母、数字、Logo、品牌标识、表格、图表、水印; +不要生成可被误认为真实保险合同或官方证明的文件; +为后续文字排版保留明确安全区域。 +``` + +候选数默认 3,允许管理员配置 2~4。每个候选保存: + +- 完整 Prompt; +- provider/model; +- seed 或供应商返回标识; +- 参考素材; +- 配置快照; +- 生成耗时和费用元数据; +- 失败代码; +- 是否 fallback。 + +### 10.3 初始画布构建 + +`canvas_builder.py` 根据: + +- 输出尺寸; +- 版式预设; +- 已确认文案; +- 已确认事实; +- 选中视觉素材; +- Logo 和品牌配置; +- 免责声明; + +生成确定性的画布 JSON。相同输入和版式预设应得到相同的元素位置,避免每次重新生成版式随机漂移。 + +### 10.4 局部重新生成 + +| 操作 | 影响范围 | +|------|----------| +| 重新生成视觉图 | 只创建新的图片素材,不改文字和数据 | +| 重新生成标题 | 只更新标题候选,用户确认后替换 | +| 重新生成卖点 | 只更新选中卖点卡 | +| 更换版式预设 | 重排图层,原画布自动保存为版本 | +| 事实数据变更 | 标记绑定图层过期,不自动覆盖人工排版 | + +--- + +## 十一、后台配置与同步 + +### 11.1 后台配置分类 + +| 配置 | 后台来源 | 前端用途 | +|------|----------|----------| +| 文案模型 | `SystemSetting` | AI 文案 | +| 图片模型 | `SystemSetting` | AI 视觉素材 | +| Prompt 模板 | 现有设置扩展 | 文案、视觉和 QA | +| 海报模板 | `PosterTemplate` | 改为版式/品牌预设 | +| 文案模板 | `PosterCopyTemplate` | 快速文案和降级路径 | +| 保司品牌 | `PptCompany` + Logo 表 | Logo、品牌色、名称 | +| 产品资料 | `PptProduct` | 后台审核产品快捷选择 | +| 校验规则 | 系统设置/规则文件 | 字段、阈值和阻断规则 | +| 导出默认值 | 系统设置 | 默认格式、质量和 DPI | + +### 11.2 `PosterTemplate` 重解释 + +现有模板不再表示“一张成品背景图”,而表示: + +- 版式类别; +- 安全区; +- 文字层级; +- 数据卡样式; +- Logo 和免责声明位置; +- 推荐场景; +- 品牌色; +- AI 视觉 Prompt 片段; +- 负向 Prompt; +- 画布比例。 + +旧 `reference_image` 只作为风格参考,不直接作为最终背景。 + +### 11.3 配置版本策略 + +- 每次管理员保存配置,版本号加一; +- 项目页显示“当前项目使用 v3,后台最新 v4”; +- 用户选择: + - 继续使用项目快照; + - 同步最新配置; +- 同步最新配置只更新简报和预设,不静默覆盖画布; +- 新任务永远使用提交时快照; +- 历史项目可查看当时配置,但不显示密钥。 + +### 11.4 任务中心和历史记录职责 + +任务中心: + +- 展示正在运行或最近完成的操作; +- 解析、生成视觉、合成、导出各是一条任务; +- 支持取消可取消任务、重试和查看错误; +- 完成后跳转对应项目。 + +项目历史: + +- 展示可继续编辑的项目; +- 显示资料、校验状态、当前画布、版本和导出物; +- 支持复制、归档、恢复、继续编辑; +- 不重复承担任务进度明细。 + +--- + +## 十二、安全、隐私与合规 + +### 12.1 文件安全 + +- PDF:扩展名、MIME、魔数、大小、页数、加密、损坏检查; +- 图片:仅允许 PNG/JPG/WebP,使用 Pillow 解码并重编码; +- 删除 EXIF 和地理位置; +- 默认限制: + - 单个 PDF 50 MB; + - 单个图片 15 MB; + - 单项目 PDF 合计 100 MB; + - 参考图最多 6 张; +- 使用 SHA-256 审计; +- 所有下载通过鉴权接口; +- 路径校验使用 `os.path.commonpath`; +- API 和 Worker 共享同一持久化 volume。 + +### 12.2 权限 + +- 普通用户只能访问本人项目、素材、任务、版本和导出物; +- 主管是否可查看团队项目复用现有数据权限; +- 管理员默认只能看配置,不自动获得客户原始 PDF 权限; +- 需要查看业务数据时必须经过现有角色权限和审计; +- 素材 ID、画布 ID、导出物 ID 均执行所有权校验。 + +### 12.3 隐私 + +- PDF 密码不落库、不写日志; +- Prompt 默认脱敏; +- 模型请求根据 `useMaskedData` 替换客户和产品敏感名称; +- 日志不保存完整保费、保额和客户姓名; +- 参考人物照片上传前提示用户确认授权; +- 数据保留期限到期后删除源文件、临时图和未引用候选; +- 正式导出物和审计记录按后台策略保留。 + +### 12.4 合规 + +- 生成前强制校验; +- AI 文案只能引用已确认事实; +- Logo 使用真实素材,不允许图片模型生成; +- 免责声明是受保护图层; +- 正式导出前再次检查事实版本; +- 事实已变化时禁止导出旧画布为“正式版”; +- 每份正式导出物关联确认人、确认时间和配置快照; +- fallback 成品必须明确标记生成方式,不伪装成 AI 成功。 + +--- + +## 十三、分阶段实施计划 + +### Phase 0:紧急闭环修复与回归基线 + +**预计工作量**:1.5 人日 +**目标**:先修复当前功能的确定性故障,建立后续重构基线。 + +任务: + +- [ ] POSTER2-0001 修复输出目录和下载白名单不一致; +- [ ] POSTER2-0002 抽取统一 `PosterStorageService`; +- [ ] POSTER2-0003 修复前后端尺寸枚举和映射; +- [ ] POSTER2-0004 生成异常时同步 `PosterRecord` 和 `GenerationTask`; +- [ ] POSTER2-0005 轮询连续失败时显示可恢复错误; +- [ ] POSTER2-0006 增加竖版、横版、方图生成回归测试; +- [ ] POSTER2-0007 增加“生成成功后可预览和下载”集成测试; +- [ ] POSTER2-0008 准备脱敏计划书、小册子和预期事实样例; +- [ ] POSTER2-0009 记录现有接口和数据库快照。 + +完成标准: + +- 三种尺寸均生成正确方向; +- Worker 生成文件可由 API 下载; +- 容器重启后文件仍存在; +- 生成失败不会永久显示进行中; +- 同一组样例可重复执行。 + +### Phase 1:项目数据模型、迁移和基础 API + +**预计工作量**:2.5 人日 +**目标**:建立项目素材、事实、画布版本和导出物的数据基础。 + +任务: + +- [ ] POSTER2-0101 编写 `migrate_026.py`; +- [ ] POSTER2-0102 扩展 `PosterRecord`; +- [ ] POSTER2-0103 新增四个 SQLAlchemy 模型; +- [ ] POSTER2-0104 扩展 `GenerationTask.operation`; +- [ ] POSTER2-0105 添加索引和唯一约束; +- [ ] POSTER2-0106 实现旧记录回填; +- [ ] POSTER2-0107 实现创建空项目 API; +- [ ] POSTER2-0108 扩展工作区详情和自动保存; +- [ ] POSTER2-0109 实现配置快照服务; +- [ ] POSTER2-0110 增加迁移幂等、空库和旧数据测试。 + +完成标准: + +- 空数据库迁移一次成功; +- 迁移重复运行无副作用; +- 旧记录仍可查询和下载; +- 可以在上传前创建项目; +- 项目草稿支持乐观锁; +- 任务可保存大于 10 字符的操作类型。 + +### Phase 2:自由上传、解析、来源审计和校验门 + +**预计工作量**:3 人日 +**目标**:用户可上传两类 PDF,且错误数据不能进入生成。 + +任务: + +- [ ] POSTER2-0201 实现项目素材上传 API; +- [ ] POSTER2-0202 支持计划书和小册子独立上传/替换; +- [ ] POSTER2-0203 支持后台产品关联; +- [ ] POSTER2-0204 支持参考图片和临时 Logo 上传; +- [ ] POSTER2-0205 图片重编码、EXIF 清理和安全限制; +- [ ] POSTER2-0206 项目级解析任务; +- [ ] POSTER2-0207 标准事实 Schema; +- [ ] POSTER2-0208 事实来源文件、页码和原文记录; +- [ ] POSTER2-0209 校验规则引擎; +- [ ] POSTER2-0210 错误阻断、警告接受和确认接口; +- [ ] POSTER2-0211 前端项目资料页; +- [ ] POSTER2-0212 前端数据底表、来源审计和 QA; +- [ ] POSTER2-0213 替换源文件后确认失效; +- [ ] POSTER2-0214 上传、解析和校验自动化测试。 + +完成标准: + +- 不选择后台产品也可上传两份 PDF; +- 每个关键数字可定位到文件和页码; +- 任一错误项存在时无法生成; +- 警告必须显式接受; +- 确认记录包含用户、时间和事实版本; +- 替换资料后旧确认自动失效。 + +### Phase 3:AI 文案、视觉候选和初始画布 + +**预计工作量**:3.5 人日 +**目标**:AI 生成与资料语义匹配的视觉素材,而不是固定背景模板成品。 + +任务: + +- [ ] POSTER2-0301 定义创意简报 Schema; +- [ ] POSTER2-0302 重构文案 Prompt,只允许引用确认事实; +- [ ] POSTER2-0303 增加文案结构化输出校验; +- [ ] POSTER2-0304 增加 unsupported claim 检查; +- [ ] POSTER2-0305 重构图片 Prompt,禁止文字、数字和 Logo; +- [ ] POSTER2-0306 生成 2~4 个视觉候选; +- [ ] POSTER2-0307 候选图记录 Prompt、模型、参考图和费用元数据; +- [ ] POSTER2-0308 支持选择和局部重新生成; +- [ ] POSTER2-0309 将 `PosterTemplate` 改造为版式/品牌预设; +- [ ] POSTER2-0310 实现 `canvas_builder.py`; +- [ ] POSTER2-0311 创建初始画布版本; +- [ ] POSTER2-0312 前端创意简报和候选选择页面; +- [ ] POSTER2-0313 AI 失败、限流和 fallback 契约测试。 + +完成标准: + +- AI 候选图不包含可见文字和伪造 Logo; +- 视觉候选明确对应客户、产品和场景简报; +- 用户可单独重生成一张候选; +- 重新生成图片不改变已确认事实; +- 文案中的数字全部有事实引用; +- 可从选中候选生成初始画布 JSON。 + +### Phase 4:可编辑画布 + +**预计工作量**:5.5 人日 +**目标**:生成结果可以在浏览器内进行专业、可恢复编辑。 + +任务: + +- [ ] POSTER2-0401 Fabric.js 技术验证:中文字体、长图、序列化和 2x 导出; +- [ ] POSTER2-0402 定义 Canvas Schema v1; +- [ ] POSTER2-0403 实现画布加载和保存; +- [ ] POSTER2-0404 实现文字编辑; +- [ ] POSTER2-0405 实现图片替换和裁剪; +- [ ] POSTER2-0406 实现基础形状和背景; +- [ ] POSTER2-0407 实现数据卡、图标、Logo、免责声明组件; +- [ ] POSTER2-0408 实现图层面板; +- [ ] POSTER2-0409 实现属性面板; +- [ ] POSTER2-0410 实现多选、对齐、吸附和安全区; +- [ ] POSTER2-0411 实现撤销、重做和快捷键; +- [ ] POSTER2-0412 实现自动保存和离线待提交; +- [ ] POSTER2-0413 实现版本创建、列表和恢复; +- [ ] POSTER2-0414 实现事实绑定和过期提醒; +- [ ] POSTER2-0415 实现局部 AI 图片替换; +- [ ] POSTER2-0416 实现桌面/平板布局和手机简化模式; +- [ ] POSTER2-0417 画布 Schema、版本冲突和恢复测试。 + +完成标准: + +- 用户可以修改示例海报全部文字; +- 用户可以移动、缩放、替换图片和调整图层; +- 刷新页面后画布恢复; +- 撤销、重做至少覆盖最近 100 次操作; +- 两个标签页同时修改时不会静默覆盖; +- 关键数字图层可追溯到事实; +- 恢复旧版本不会删除新版本。 + +### Phase 5:多格式导出 + +**预计工作量**:2.5 人日 +**目标**:输出可管理、可追溯的多格式交付物。 + +任务: + +- [ ] POSTER2-0501 高分辨率隐藏画布渲染; +- [ ] POSTER2-0502 PNG 1x/2x 导出; +- [ ] POSTER2-0503 JPG 质量选项; +- [ ] POSTER2-0504 PDF 页面尺寸和分页; +- [ ] POSTER2-0505 项目 JSON 导出与导入校验; +- [ ] POSTER2-0506 导出物上传、MIME 和所有权校验; +- [ ] POSTER2-0507 导出物列表和鉴权下载; +- [ ] POSTER2-0508 正式交付版标记; +- [ ] POSTER2-0509 字体缺失和图片跨域检查; +- [ ] POSTER2-0510 各格式视觉快照测试。 + +完成标准: + +- 同一画布可生成 PNG、JPG、PDF 和项目 JSON; +- 四种文件均可重新下载; +- 导出物与画布版本、事实版本和配置快照关联; +- 导出前发现事实过期时禁止标记正式版; +- 图片尺寸、方向和文件 MIME 正确; +- 导入项目 JSON 后版式不明显漂移。 + +### Phase 6:项目历史、任务中心和后台同步 + +**预计工作量**:2.5 人日 +**目标**:所有内容与后台形成可恢复、可审计闭环。 + +任务: + +- [ ] POSTER2-0601 重构海报历史为项目列表; +- [ ] POSTER2-0602 增加项目继续编辑、复制、归档和恢复; +- [ ] POSTER2-0603 项目详情展示素材、校验、版本和导出物; +- [ ] POSTER2-0604 任务中心展示细分操作; +- [ ] POSTER2-0605 任务完成跳转项目; +- [ ] POSTER2-0606 后台模板增加版式 Schema 和版本; +- [ ] POSTER2-0607 后台 Prompt、品牌和校验规则版本化; +- [ ] POSTER2-0608 前端配置版本差异提示; +- [ ] POSTER2-0609 同步最新配置但不覆盖画布; +- [ ] POSTER2-0610 旧项目“转换为可编辑项目”; +- [ ] POSTER2-0611 数据权限和审计日志测试。 + +完成标准: + +- 用户离开页面后任务继续运行; +- 从历史项目可以恢复到上次步骤; +- 任务中心不再承担项目历史职责; +- 后台配置变化不会改变已提交任务; +- 用户可以看见项目配置版本与最新版本差异; +- 普通用户不能访问他人项目和导出物。 + +### Phase 7:安全、性能、灰度和上线 + +**预计工作量**:3.5 人日 +**目标**:达到生产上线标准。 + +任务: + +- [ ] POSTER2-0701 上传安全和资源限制测试; +- [ ] POSTER2-0702 PDF Prompt 注入测试; +- [ ] POSTER2-0703 跨用户访问测试; +- [ ] POSTER2-0704 Prompt、日志和错误脱敏; +- [ ] POSTER2-0705 数据清理和保留策略; +- [ ] POSTER2-0706 项目、任务、存储、模型 readiness; +- [ ] POSTER2-0707 关键指标和结构化日志; +- [ ] POSTER2-0708 前端性能和大画布测试; +- [ ] POSTER2-0709 无障碍和键盘流程测试; +- [ ] POSTER2-0710 迁移演练; +- [ ] POSTER2-0711 灰度开关和回滚演练; +- [ ] POSTER2-0712 更新需求、API、测试、部署和变更日志; +- [ ] POSTER2-0713 业务方使用真实脱敏案例验收。 + +完成标准: + +- P0/P1 自动化用例全部通过; +- 无跨用户数据访问; +- 日志不含 PDF 密码、密钥和完整客户敏感数据; +- 迁移可重复执行; +- 关闭 V2 开关可回到旧流程; +- 旧项目和新项目均可查询; +- 回滚演练成功。 + +--- + +## 十四、测试计划 + +### 14.1 单元测试 + +后端: + +- 文件类型、大小、页数和路径安全; +- 事实 Schema; +- 各险种必填字段; +- 来源证据; +- 数值、币种和缴费期交叉校验; +- warning 接受和 error 阻断; +- 配置快照; +- AI 结构化响应; +- Canvas Schema; +- storage 对象键; +- 旧数据回填; +- 幂等键和任务状态同步。 + +前端: + +- 项目状态恢复; +- 素材上传状态; +- 校验门; +- 创意简报; +- 画布对象序列化; +- 撤销重做; +- 自动保存; +- 乐观锁冲突; +- 导出参数。 + +建议补充: + +- Vitest; +- Vue Test Utils; +- Canvas 相关函数尽量抽成无 DOM 的纯函数。 + +### 14.2 API 集成测试 + +| 场景 | 预期 | +|------|------| +| 只上传计划书,不选产品也不传小册子 | 阻断并提示缺少产品资料 | +| 选择后台产品 + 上传计划书 | 可解析 | +| 上传计划书 + 小册子 | 可解析 | +| 跨用户读取项目 | 404/无权限 | +| 修改事实后未重新确认就生成 | 拒绝 | +| warning 未接受 | 拒绝 | +| 重复点击生成 | 返回同一活跃任务 | +| Worker 失败 | 项目和任务均为 failed | +| 旧项目下载 | 保持可用 | +| 导出不存在的画布版本 | 拒绝 | + +### 14.3 端到端测试 + +主路径: + +```text +创建项目 +→ 上传两份 PDF +→ 等待解析 +→ 修正低置信度字段 +→ 查看来源页码 +→ 确认 +→ 填写创意简报 +→ 生成 3 个视觉候选 +→ 选择候选 +→ 进入画布 +→ 修改标题和图片 +→ 保存版本 +→ 导出 PNG、JPG、PDF +→ 离开页面 +→ 从历史恢复 +→ 再次下载 +``` + +异常路径: + +- 加密 PDF 密码错误; +- PDF 损坏; +- 产品名冲突; +- 币种冲突; +- 图片模型超时; +- 文案模型返回非 JSON; +- 上传中断; +- 页面刷新; +- 两个标签页并发编辑; +- storage 暂时不可用; +- 字体加载失败; +- 超长标题; +- 100 个画布对象; +- 800×12000 长图。 + +### 14.4 视觉回归 + +固定三套脱敏基准: + +1. 高端传承竖版; +2. 产品利益长图; +3. 朋友圈方图。 + +对以下内容进行截图差异检查: + +- 初始画布; +- 编辑后画布; +- 1x PNG; +- 2x PNG; +- PDF 首屏; +- 恢复版本后的画布。 + +字体渲染允许小范围像素差异,但不能出现: + +- 文字截断; +- 数字错位; +- 图片拉伸; +- Logo 变形; +- 免责声明缺失; +- 横竖版方向错误。 + +### 14.5 性能标准 + +| 指标 | 目标 | +|------|------| +| 创建项目 | P95 < 1 秒 | +| 上传接口返回 | 保存和投递任务后 P95 < 3 秒 | +| 项目详情 | P95 < 1.5 秒 | +| 自动保存 | P95 < 1 秒 | +| 画布首次可操作 | 桌面宽带 < 3 秒,不含大图后台加载 | +| 画布拖拽 | 常见 50 个对象保持接近 60 FPS | +| 导出 1080×1920 PNG | 常见设备 < 10 秒 | +| 任务状态刷新 | 2 秒轮询,错误后指数退避 | + +AI 解析和生成耗时不设置绝对秒数 SLA,但必须持续显示状态、允许离开页面并可恢复。 + +--- + +## 十五、监控与可观测性 + +### 15.1 指标 + +- 项目创建成功率; +- PDF 上传/解析成功率; +- 平均解析耗时; +- 必填字段缺失率; +- 低置信度字段比例; +- 生成前校验阻断率; +- 文案生成成功率; +- 图片生成成功率; +- fallback 使用率; +- 平均每项目候选图数量; +- 画布保存失败率; +- 版本冲突率; +- PNG/JPG/PDF 导出成功率; +- 文件下载失败率; +- 单项目模型费用。 + +### 15.2 结构化日志字段 + +```text +request_id +user_id_masked +project_id +asset_id +task_id +operation +fact_revision +canvas_version +config_version +provider +model +status +error_code +latency_ms +fallback_reason +``` + +禁止记录: + +- API Key; +- PDF 密码; +- 完整客户姓名; +- 完整身份证件; +- 未脱敏的保费/保额组合; +- 完整 PDF 原文; +- Base64 图片。 + +### 15.3 健康检查 + +readiness 应检查: + +- 数据库; +- Redis; +- Celery Worker 心跳; +- storage 可读写; +- 至少一个可用文案模型; +- 至少一个可用图片模型或明确允许 fallback; +- 至少一个启用版式预设; +- 必需字体是否存在; +- 磁盘剩余空间。 + +--- + +## 十六、发布、灰度与回滚 + +### 16.1 功能开关 + +```text +POSTER_WORKBENCH_V2_ENABLED +POSTER_USER_MANUAL_UPLOAD_ENABLED +POSTER_AI_VISUAL_CANDIDATES_ENABLED +POSTER_CANVAS_EDITOR_ENABLED +POSTER_MULTI_EXPORT_ENABLED +POSTER_LEGACY_CREATE_ENABLED +``` + +### 16.2 上线顺序 + +1. 部署 Phase 0 修复; +2. 部署迁移 026 和新 API,但不开新入口; +3. 管理员测试自由上传和校验; +4. 开放 AI 视觉候选; +5. 开放画布编辑器; +6. 开放多格式导出; +7. 5% 销售用户灰度; +8. 25% 用户; +9. 全量; +10. 保留旧创建入口一个发布周期; +11. 关闭旧创建入口。 + +### 16.3 上线前检查 + +- [ ] 数据库和 storage 备份; +- [ ] 导出迁移历史; +- [ ] 在生产数据副本执行迁移 026; +- [ ] 统计旧海报记录、文件存在率和回填结果; +- [ ] 验证旧海报预览下载; +- [ ] 验证 API/Worker 共享 volume; +- [ ] 验证字体; +- [ ] 验证模型连接; +- [ ] 准备灰度账号; +- [ ] 准备回滚版本; +- [ ] 完成 P0/P1 回归。 + +### 16.4 回滚原则 + +- 优先关闭 V2 功能开关,不执行破坏性数据库回滚; +- 新表和新增字段保留; +- 旧接口和旧记录继续工作; +- 新项目在回滚期间保持只读和可下载; +- storage 不删除新文件; +- 代码回滚不回滚已确认事实和审计记录; +- 修复后重新开启 V2。 + +### 16.5 回滚触发条件 + +- 出现跨用户项目或文件访问; +- 事实校验失效仍可生成; +- 正式导出缺少关键数字或免责声明; +- 新迁移导致旧项目不可读; +- 文件下载失败率持续高于 5%; +- 任务重复计费; +- 画布自动保存持续失败; +- 导出物与画布版本不一致; +- 模型密钥或客户敏感信息泄露。 + +--- + +## 十七、工作量、人员和里程碑 + +### 17.1 工作量 + +| 阶段 | 人日 | +|------|:----:| +| Phase 0:紧急闭环 | 1.5 | +| Phase 1:数据模型与 API | 2.5 | +| Phase 2:上传、解析与校验 | 3.0 | +| Phase 3:AI 视觉与初始画布 | 3.5 | +| Phase 4:画布编辑器 | 5.5 | +| Phase 5:多格式导出 | 2.5 | +| Phase 6:历史、任务与后台同步 | 2.5 | +| Phase 7:测试、灰度和上线 | 3.5 | +| **基础合计** | **24.5** | +| 建议风险缓冲(约 20%) | 4.5~5.0 | +| **计划预算** | **约 29 人日** | + +不包含: + +- 外部模型账号、额度和网络审批等待; +- 业务方准备真实脱敏样例; +- 法务/合规确认时间; +- 新字体商业授权; +- SVG、多人协作、视频海报。 + +### 17.2 人员建议 + +单人开发: + +- 预计 5~6 周; +- Phase 4 画布编辑器风险集中; +- 必须严格按阶段验收,禁止并行铺开全部功能。 + +双人开发: + +- 后端:Phase 0~3、数据、任务、校验和导出接口; +- 前端:Phase 2 页面、Phase 4 编辑器、Phase 5 导出; +- 预计 3~4 周,集成阶段保留至少 4 人日。 + +### 17.3 里程碑 + +| 里程碑 | 包含阶段 | 可交付结果 | +|--------|----------|------------| +| M0 基础可用 | Phase 0 | 当前 PNG 流程稳定 | +| M1 事实可信 | Phase 1~2 | 自由上传 + 来源审计 + 校验门 | +| M2 AI 视觉正确 | Phase 3 | AI 候选图 + 结构化初始画布 | +| M3 可编辑交付 | Phase 4~5 | 画布编辑 + 多格式导出 | +| M4 生产上线 | Phase 6~7 | 后台闭环 + 灰度 + 回滚 | + +每个里程碑完成后再进入下一里程碑,不以“页面已出现”代替端到端验收。 + +--- + +## 十八、风险与应对 + +| 风险 | 概率 | 影响 | 应对 | +|------|:----:|:----:|------| +| Fabric.js 中文字体和长图导出差异 | 中 | 高 | Phase 4 第一项先做技术验证,不通过则切换 SVG/HTML 方案 | +| 图片模型生成文字或伪 Logo | 高 | 高 | 负向 Prompt、结果人工选择、Logo 独立图层 | +| PDF 解析事实不稳定 | 中 | 高 | Schema、来源证据、置信度、人工确认和阻断规则 | +| 画布 JSON 版本升级困难 | 中 | 高 | 明确 Schema 版本和迁移函数,不直接暴露 Fabric 原始格式为永久契约 | +| 大图导致浏览器内存高 | 中 | 中 | 素材缩略图、按需加载、隐藏高分辨率画布只在导出时创建 | +| 自动保存覆盖冲突 | 中 | 高 | 乐观锁、本地待提交、冲突另存副本 | +| 模型费用增加 | 中 | 中 | 候选数上限、幂等、局部重生成、费用指标 | +| 配置修改影响排队任务 | 中 | 高 | 提交时配置快照 | +| 旧项目无法转为可编辑 | 中 | 中 | 保持旧下载;转换时用旧图作为锁定背景并叠加可编辑文字 | +| 手机端画布难用 | 高 | 中 | 手机只做简化编辑,桌面完成精细排版 | +| PDF 导出字体缺失 | 中 | 高 | 预加载授权字体、导出前字体检查、失败阻断而非静默替换 | +| 参考照片存在隐私风险 | 中 | 高 | 上传提示授权、EXIF 清理、保留期限、权限和审计 | + +--- + +## 十九、文件级修改清单 + +### 19.1 后端新增 + +| 文件 | 用途 | +|------|------| +| `api/insurance/db/migrate_026.py` | 新表、字段、索引和旧数据回填 | +| `api/insurance/models/poster_project_asset.py` | 项目素材 | +| `api/insurance/models/poster_fact_item.py` | 事实与来源 | +| `api/insurance/models/poster_canvas_version.py` | 画布版本 | +| `api/insurance/models/poster_export_artifact.py` | 导出物 | +| `api/insurance/poster/project_routes.py` | 新工作区接口 | +| `api/insurance/poster/project_service.py` | 项目聚合服务 | +| `api/insurance/poster/asset_service.py` | 素材生命周期 | +| `api/insurance/poster/extraction_service.py` | 项目解析编排 | +| `api/insurance/poster/validation_service.py` | 规则校验 | +| `api/insurance/poster/source_audit_service.py` | 来源审计 | +| `api/insurance/poster/creative_service.py` | 创意简报和文案 | +| `api/insurance/poster/visual_generator.py` | AI 视觉候选 | +| `api/insurance/poster/canvas_builder.py` | 初始画布 | +| `api/insurance/poster/export_service.py` | 导出物 | +| `api/insurance/poster/storage_service.py` | 安全文件访问 | +| `api/insurance/poster/schemas/*` | 事实、简报和画布 Schema | + +### 19.2 后端修改 + +| 文件 | 修改 | +|------|------| +| `api/insurance/models/poster_record.py` | 项目、校验、画布、配置快照字段 | +| `api/insurance/models/generation_task.py` | 操作类型和任务输出 | +| `api/insurance/poster/routes.py` | 旧接口兼容和下载转调 | +| `api/insurance/poster/service.py` | 旧流程转调新项目服务 | +| `api/insurance/poster/image_generator.py` | 视觉素材 Prompt 和尺寸统一 | +| `api/insurance/poster/copy_generator.py` | 事实引用和结构化输出 | +| `api/insurance/poster/tasks.py` | 项目解析任务 | +| `api/insurance/generation/celery_tasks.py` | 新任务操作、状态同步和错误码 | +| `api/insurance/generation/routes.py` | 工作区详情和任务恢复 | +| `api/insurance/routes.py` | 注册新 Blueprint | +| `api/insurance/config.py` | 文件和功能开关 | +| `api/insurance/models/__init__.py` | 注册模型 | +| `api/insurance/requirements.txt` | 仅增加实际使用的导出/校验依赖 | +| `docker-compose.dify.yml` | API/Worker storage 和开关 | + +### 19.3 前端新增和修改 + +| 文件 | 修改 | +|------|------| +| `frontend/package.json` | Fabric.js、PDF 导出和测试依赖 | +| `frontend/src/router/*` | 新项目和编辑器路由 | +| `frontend/src/pages/PosterPage.vue` | 切换到 V2 首页/兼容入口 | +| `frontend/src/pages/PosterHistoryPage.vue` | 改为项目历史 | +| `frontend/src/utils/poster-api.ts` | 项目、素材、校验、画布和导出 API | +| `frontend/src/composables/useWorkspace.ts` | 完整项目状态恢复 | +| `frontend/src/components/GenerationTaskDock.vue` | 海报细分任务和跳转 | +| `frontend/src/pages/admin/PosterTemplatesAdmin.vue` | 版式预设、版本和 Prompt | +| `frontend/src/pages/admin/PptSettingsAdmin.vue` | 校验、导出和配置版本 | +| `frontend/src/pages/PosterEditorPage.vue` | 全屏编辑器 | +| `frontend/src/components/poster/**` | 新五阶段组件 | + +### 19.4 测试 + +建议新增: + +```text +tests/poster_storage_test.py +tests/poster_project_migration_test.py +tests/poster_asset_api_test.py +tests/poster_fact_validation_test.py +tests/poster_source_audit_test.py +tests/poster_visual_generation_test.py +tests/poster_canvas_api_test.py +tests/poster_export_test.py +tests/poster_permissions_test.py +tests/poster_task_lifecycle_test.py +frontend/src/**/__tests__/* +frontend/e2e/poster-workbench.spec.ts +``` + +--- + +## 二十、文档同步清单 + +实施过程中必须同步: + +| 文档 | 更新内容 | +|------|----------| +| `保险智能客服系统_需求文档.md` | 海报项目、校验、画布、导出业务规则 | +| `保险智能客服系统_API接口文档.md` | 新接口、错误码、请求响应 | +| `保险智能客服系统_测试用例.md` | P0/P1 和端到端用例 | +| `PPT与海报功能开发任务清单.md` | 增加二期任务编号和状态 | +| `部署文档_完整版.md` | 依赖、字体、storage、开关和 Worker | +| `API_curl示例.md` | 项目、素材、校验、生成和导出示例 | +| `CHANGELOG.md` | 每个发布阶段的新增、修复和变更 | +| `docs/README.md` | 本计划索引 | + +--- + +## 二十一、完成定义 + +只有全部满足以下条件,海报工作台二期才可标记为完成: + +- [ ] 用户可直接上传计划书和产品小册子; +- [ ] 用户也可选择后台审核产品; +- [ ] 参考图和 Logo 通过真实文件上传管理; +- [ ] 所有关键事实有来源或人工补录说明; +- [ ] 错误数据无法进入生成; +- [ ] 警告已显式处理; +- [ ] AI 生成视觉素材,不在图片中生成正式文字和数字; +- [ ] 文案数字全部绑定已确认事实; +- [ ] 生成结果是可编辑画布; +- [ ] 画布支持撤销、重做、自动保存和版本恢复; +- [ ] 页面刷新和任务完成后可恢复项目; +- [ ] 支持 PNG、JPG、PDF 和项目源文件; +- [ ] 导出物关联画布、事实和配置版本; +- [ ] 任务中心和项目历史职责清晰; +- [ ] 后台配置提交时快照可审计; +- [ ] 旧海报仍可预览下载; +- [ ] API/Worker 共享持久化 storage; +- [ ] 普通用户不能访问他人数据; +- [ ] P0/P1 自动化测试全部通过; +- [ ] 迁移、灰度和回滚演练完成; +- [ ] 相关需求、API、测试、部署和变更日志已同步。 + +--- + +## 二十二、开发启动前待确认项 + +以下决策不阻塞计划编写,但必须在对应 Phase 开发前确认: + +| 编号 | 决策 | 推荐默认值 | 最晚确认阶段 | +|------|------|------------|--------------| +| D-01 | 手机是否要求完整画布编辑 | 否,手机使用简化编辑 | Phase 4 | +| D-02 | 首期 PDF 是否接受栅格化单页 | 接受,保证视觉一致 | Phase 5 | +| D-03 | 项目源文件是否允许用户重新导入 | 允许,但严格校验 Schema | Phase 5 | +| D-04 | 用户上传小册子是否可进入公共产品库 | 仅显式提交审核后进入 | Phase 2 | +| D-05 | 每次默认生成几个视觉候选 | 3 个 | Phase 3 | +| D-06 | AI 图片失败是否允许 fallback | 默认允许,但必须清晰标记 | Phase 3 | +| D-07 | 正式导出是否必须二次人工确认 | 建议必须 | Phase 5 | +| D-08 | 项目和原始 PDF 默认保留时间 | 沿用后台当前保留策略 | Phase 7 | +| D-09 | 首期支持哪些授权中文字体 | 至少 2 套品牌字体 | Phase 4 | +| D-10 | SVG 是否进入首发 | 不进入,作为 P2 | Phase 5 | + +--- + +## 二十三、建议的第一批实施内容 + +第一批开发只做 Phase 0 和 Phase 1,不立即进入画布编辑器: + +```text +修复下载目录 +→ 修复尺寸映射 +→ 修复失败状态同步 +→ 建立回归样例 +→ 创建迁移 026 +→ 建立项目素材、事实、画布版本、导出物模型 +→ 创建空项目和工作区 API +→ 完成旧数据回填测试 +``` + +完成这一批后,数据库和文件模型稳定,后续上传、校验、AI 和画布可以并行推进;如果基础模型未稳定就直接开发编辑器,极易出现画布、历史、导出和任务各自保存一套数据的问题。 diff --git a/frontend/src/pages/PptPage.vue b/frontend/src/pages/PptPage.vue index 8afca52..0ca171e 100644 --- a/frontend/src/pages/PptPage.vue +++ b/frontend/src/pages/PptPage.vue @@ -153,11 +153,11 @@ function resetAll() { } .generator-page { - --generator-accent: #2563eb; - --generator-accent-soft: #eff6ff; - --generator-border: #d9e2ef; - --generator-text: #172033; - --generator-muted: #667085; + --generator-accent: #3B7A57; + --generator-accent-soft: #f0f7f3; + --generator-border: #d9e5df; + --generator-text: #1a2e23; + --generator-muted: #5f7a69; } .generator-header { diff --git a/frontend/src/pages/components/ppt/PptGenerate.vue b/frontend/src/pages/components/ppt/PptGenerate.vue index f4c3aa4..6fe5955 100644 --- a/frontend/src/pages/components/ppt/PptGenerate.vue +++ b/frontend/src/pages/components/ppt/PptGenerate.vue @@ -46,6 +46,33 @@ + +
+

系统决策

+
    +
  • + 检测场景 + {{ scenarioLabel(currentScenario) || '通用方案' }} +
  • +
  • + 选用模板 + {{ selectedTemplate.name || selectedTemplate.id }} +
  • +
  • + 计划书数量 + {{ sessionFiles.length }} 份 +
  • +
  • + 险种类型 + {{ selectedPlanTypes.map(planTypeLabel).join(' + ') || '未识别' }} +
  • +
  • + 保险公司 + {{ companies.find(c => c.id === companyId)?.displayName || companyId }} +
  • +
+
+

选择保险公司

@@ -138,6 +165,9 @@ const selectedProductIds = computed(() => const selectedPlanTypes = computed(() => [...new Set(sessionFiles.value.map(file => file.type).filter(Boolean))] ) +const selectedTemplate = computed(() => + templates.value.find(t => t.id === selectedTemplateId.value) || null +) const currentScenario = computed(() => { const types = sessionFiles.value.map(file => String(file.type || '').toLowerCase()).filter(Boolean) const savingsCount = types.filter(type => type === 'savings').length @@ -381,6 +411,47 @@ function startPolling(taskId: string) { color: #909399; } +.decision-section { + margin-bottom: 24px; + padding: 14px 16px; + background: #f6f8fa; + border-radius: 6px; + border: 1px solid #e8ece8; +} + +.decision-section h4 { + margin: 0 0 10px; + font-size: 13px; + color: #3B7A57; +} + +.decision-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.decision-list li { + display: flex; + align-items: center; + gap: 12px; + font-size: 13px; +} + +.decision-label { + color: #909399; + min-width: 70px; + flex-shrink: 0; +} + +.decision-value { + color: #303133; + font-weight: 500; +} + .style-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); @@ -402,12 +473,12 @@ function startPolling(taskId: string) { } .style-item:hover { - border-color: #409eff; + border-color: #3B7A57; } .style-item.active { - border-color: #409eff; - background: #ecf5ff; + border-color: #3B7A57; + background: #f0f7f3; } .style-preview { diff --git a/frontend/src/pages/components/ppt/PptResult.vue b/frontend/src/pages/components/ppt/PptResult.vue index 3338925..5d697d2 100644 --- a/frontend/src/pages/components/ppt/PptResult.vue +++ b/frontend/src/pages/components/ppt/PptResult.vue @@ -5,8 +5,31 @@
+ +
+ +
+ +
+

PPT 已生成

+

正在准备预览,请稍候...

+
+
+
+ +
+
+ + + {{ downloading ? `下载中 ${downloadProgress}%` : '直接下载 PPT' }} + + 新建材料 +
+
+
+ -
+