更新一版海报和PPT生成优化

This commit is contained in:
wsb1224 2026-07-31 20:05:34 +08:00
parent 24667199cc
commit 11f57487fa
19 changed files with 1698 additions and 82 deletions

View File

@ -660,6 +660,7 @@ class PptAdminService:
delete_stored_template,
parse_template_pptx,
save_uploaded_template,
template_asset_sha256,
)
resolved_id = template_id or str(data.get("id") or "").strip()
@ -676,11 +677,13 @@ class PptAdminService:
try:
new_asset_id, file_path = save_uploaded_template(file, resolved_id)
parsed = parse_template_pptx(file_path)
asset_sha256 = template_asset_sha256(file_path)
except ValueError as exc:
delete_stored_template(new_asset_id)
return {"code": 1001, "message": str(exc), "data": None}
old_asset_id = template.source_template_asset_id if template else None
old_asset_version = int(template.asset_version or 0) if template else 0
if template is None:
template = PptTemplate(id=resolved_id)
db.session.add(template)
@ -690,8 +693,10 @@ class PptAdminService:
template.name = data.get("name") or template.name or file.filename
template.scenario_tag = data.get("scenarioTag") or template.scenario_tag
template.source_template_asset_id = new_asset_id
template.asset_sha256 = asset_sha256
template.asset_version = old_asset_version + 1
template.clone_ready = True
template.clone_renderer = "python-pptx-theme-v1"
template.clone_renderer = "clone-edit-v2"
template.required_page_types_json = json.dumps(
parsed["requiredPageTypes"], ensure_ascii=False
)
@ -705,7 +710,8 @@ class PptAdminService:
db.session.rollback()
delete_stored_template(new_asset_id)
raise
delete_stored_template(old_asset_id)
# 旧资产可能仍被排队任务的不可变快照引用,不能在切换当前版本时删除。
# 物理清理由保留期任务统一处理。
result = template.to_dict()
result["slideCount"] = parsed["slideCount"]
@ -713,6 +719,7 @@ class PptAdminService:
"width": parsed["width"],
"height": parsed["height"],
}
result["previousAssetId"] = old_asset_id
return {"code": 0, "data": result}
def update_template(self, template_id: str, data: dict) -> dict:

View File

@ -52,7 +52,7 @@ def migrate():
).first()
values = {
**seed,
"clone_renderer": "python-pptx-theme-v1",
"clone_renderer": "clone-edit-v2",
"required_page_types_json": json.dumps(
parsed["requiredPageTypes"], ensure_ascii=False
),

View File

@ -0,0 +1,68 @@
"""迁移 029扩容 PPT 模板资产标识并增加版本指纹。"""
import logging
import os
from sqlalchemy import inspect, text
logger = logging.getLogger(__name__)
def migrate():
from insurance.db.compat import db
columns = {
item["name"]: item
for item in inspect(db.engine).get_columns("insurance_ppt_templates")
}
dialect = db.engine.dialect.name
if dialect == "postgresql":
db.session.execute(text(
"ALTER TABLE insurance_ppt_templates "
"ALTER COLUMN source_template_asset_id TYPE VARCHAR(255)"
))
elif dialect in {"mysql", "mariadb"}:
db.session.execute(text(
"ALTER TABLE insurance_ppt_templates "
"MODIFY source_template_asset_id VARCHAR(255)"
))
if "asset_sha256" not in columns:
db.session.execute(text(
"ALTER TABLE insurance_ppt_templates ADD COLUMN asset_sha256 VARCHAR(64)"
))
if "asset_version" not in columns:
db.session.execute(text(
"ALTER TABLE insurance_ppt_templates "
"ADD COLUMN asset_version INTEGER NOT NULL DEFAULT 1"
))
from insurance.ppt.template_asset_service import (
resolve_template_asset,
template_asset_sha256,
)
rows = db.session.execute(text(
"SELECT id, source_template_asset_id FROM insurance_ppt_templates "
"WHERE source_template_asset_id IS NOT NULL"
)).mappings().all()
for row in rows:
try:
path = resolve_template_asset(row["source_template_asset_id"])
digest = template_asset_sha256(path) if path and os.path.isfile(path) else None
except (OSError, ValueError):
digest = None
if digest:
db.session.execute(text(
"UPDATE insurance_ppt_templates "
"SET asset_sha256 = :digest, asset_version = COALESCE(asset_version, 1) "
"WHERE id = :id"
), {"digest": digest, "id": row["id"]})
db.session.execute(text(
"UPDATE insurance_ppt_templates SET clone_renderer = 'clone-edit-v2' "
"WHERE source_template_asset_id IS NOT NULL"
))
db.session.commit()
logger.info("[migrate_029] PPT 模板资产字段与指纹迁移完成")

View File

@ -0,0 +1,15 @@
"""统一真实模板渲染模式标识。"""
from sqlalchemy import text
from insurance.db.compat import db
def migrate():
"""将过渡期名称统一为对外约定的 clone-edit-v2。"""
db.session.execute(text(
"UPDATE insurance_ppt_templates "
"SET clone_renderer = 'clone-edit-v2' "
"WHERE clone_renderer = 'frame-clone-v2'"
))
db.session.commit()

View File

@ -320,6 +320,7 @@ def _execute_ppt_generate(task_id: str):
snapshot = json.loads(task.input_snapshot_json) if task.input_snapshot_json else {}
theme = snapshot.get("theme", "broker")
template_id = snapshot.get("templateId", "")
template_asset_snapshot = snapshot.get("templateAsset") or {}
company_id = snapshot.get("companyId", "")
brand_policy = snapshot.get("brandPolicy") or {}
requested_scenario = snapshot.get("scenario", "")
@ -452,9 +453,38 @@ def _execute_ppt_generate(task_id: str):
).first()
template_config = template.to_dict() if template else None
if template_config and template.source_template_asset_id:
from insurance.ppt.template_asset_service import resolve_template_asset
template_config["sourceTemplatePath"] = resolve_template_asset(
template.source_template_asset_id
from insurance.ppt.template_asset_service import (
resolve_template_asset,
template_asset_sha256,
)
asset_id = template_asset_snapshot.get("assetId") or template.source_template_asset_id
source_template_path = resolve_template_asset(asset_id)
if not source_template_path or not os.path.isfile(source_template_path):
_update_task_status(
task_id, status="failed", error_code="template_asset_missing",
error_message="生成任务引用的模板文件不存在,请重新提交",
finished_at=datetime.now(),
)
return
actual_sha256 = template_asset_sha256(source_template_path)
expected_sha256 = template_asset_snapshot.get("sha256")
if expected_sha256 and actual_sha256 != expected_sha256:
_update_task_status(
task_id, status="failed", error_code="template_asset_changed",
error_message="模板文件版本校验失败,请重新提交生成任务",
finished_at=datetime.now(),
)
return
template_config["sourceTemplateAssetId"] = asset_id
template_config["sourceTemplatePath"] = source_template_path
template_config["assetSha256"] = actual_sha256
template_config["assetVersion"] = (
template_asset_snapshot.get("version") or template.asset_version or 1
)
template_config["cloneRenderer"] = (
template_asset_snapshot.get("rendererMode")
or template.clone_renderer
or "clone-edit-v2"
)
company_info = None
@ -576,6 +606,12 @@ def _execute_ppt_generate(task_id: str):
"templateId": template.id if template else None,
"templateName": template.name if template else None,
"stylePreset": template.style_preset if template else theme,
"templateAsset": ({
"assetId": template_config.get("sourceTemplateAssetId"),
"version": template_config.get("assetVersion"),
"sha256": template_config.get("assetSha256"),
"rendererMode": result.get("rendererMode", "generic-builder-v1"),
} if template_config and template_config.get("sourceTemplateAssetId") else None),
"createdAt": datetime.now().isoformat(),
})
session.versions_json = json.dumps(versions, ensure_ascii=False)
@ -614,6 +650,11 @@ def _execute_ppt_generate(task_id: str):
"appliedTemplateId": template.id if template else None,
"appliedTemplateName": template.name if template else None,
"appliedStylePreset": template.style_preset if template else theme,
"templateAssetId": template_config.get("sourceTemplateAssetId") if template_config else None,
"templateAssetVersion": template_config.get("assetVersion") if template_config else None,
"templateAssetSha256": template_config.get("assetSha256") if template_config else None,
"rendererMode": result.get("rendererMode", "generic-builder-v1"),
"templateFrameMap": result.get("templateFrameMap", []),
"templateFallback": False,
"revision": session.generated_revision if session else 0,
}, ensure_ascii=False),

View File

@ -141,7 +141,9 @@ class PptTemplate(db.Model):
id = Column(String(50), primary_key=True, comment="模板 ID")
plan_type = Column(String(10), nullable=False, comment="产品类型: savings/ci/iul")
style_preset = Column(String(20), nullable=False, comment="风格: broker/business/minimal/chinese/ink")
source_template_asset_id = Column(String(50), nullable=True, comment="源模板资产 ID")
source_template_asset_id = Column(String(255), nullable=True, comment="源模板资产 ID")
asset_sha256 = Column(String(64), nullable=True, comment="当前模板资产 SHA-256")
asset_version = Column(Integer, default=1, comment="当前模板资产版本")
clone_ready = Column(Boolean, default=False, comment="是否克隆就绪")
clone_renderer = Column(String(100), nullable=True, comment="克隆渲染器 ID")
required_page_types_json = Column(Text, nullable=True, comment="必需页面类型 JSON")
@ -179,6 +181,8 @@ class PptTemplate(db.Model):
"planType": self.plan_type,
"stylePreset": self.style_preset,
"sourceTemplateAssetId": self.source_template_asset_id,
"assetSha256": self.asset_sha256,
"assetVersion": self.asset_version or 1,
"sourceFileUrl": (
f"/insurance/admin/ppt/templates/{self.id}/file"
if self.source_template_asset_id else ""

View File

@ -6,6 +6,7 @@ import os
import re
import uuid
import logging
from datetime import datetime, timedelta
from flask import current_app
from insurance.db.compat import db
from insurance.models.ppt_config import PptProduct, PptCompany
@ -299,14 +300,24 @@ class PosterService:
if case.confirmed_data is None:
return {"code": 1002, "message": "请先确认解析数据", "data": None}
# 幂等检查TASK-P1-02相同参数的未完成任务直接返回
if not force_regenerate and case_upload_id:
existing = PosterRecord.query.filter(
# 幂等检查TASK-P1-02拦截同一页面的连续重复提交。
if not force_regenerate:
active_query = PosterRecord.query.filter(
PosterRecord.user_id == user_id,
PosterRecord.case_upload_id == case_upload_id,
PosterRecord.template_id == template_id,
PosterRecord.task_status.in_(["pending", "queued", "generating"]),
).order_by(PosterRecord.created_at.desc()).first()
PosterRecord.created_at >= datetime.now() - timedelta(seconds=15),
)
if case_upload_id:
active_query = active_query.filter(
PosterRecord.case_upload_id == case_upload_id,
)
else:
active_query = active_query.filter(
PosterRecord.product_source_type == context["sourceType"],
PosterRecord.product_source_id == context["sourceId"],
)
existing = active_query.order_by(PosterRecord.created_at.desc()).first()
if existing:
return {"code": 0, "data": existing.to_dict()}

View File

@ -61,6 +61,7 @@ def _build_deck_contract(
"business": "business",
"minimal": "minimal",
"ink": "ink",
"sage": "sage",
}
def _extract_product(data):
@ -219,6 +220,7 @@ class PptRenderer:
"business": "business",
"minimal": "minimal",
"ink": "ink",
"sage": "sage",
}
script_theme = theme_map.get(theme, "deepblue")
@ -240,6 +242,8 @@ class PptRenderer:
"ok": True,
"path": output_path,
"slideCount": script_result.get("slides", 0),
"rendererMode": script_result.get("rendererMode", "generic-builder-v1"),
"templateFrameMap": script_result.get("templateFrameMap", []),
"deck": deck,
}
else:

View File

@ -462,6 +462,7 @@ def generate_ppt(session_id):
)
template_data = None
template_asset_snapshot = None
if template_id:
from insurance.models.ppt_config import PptTemplate
from insurance.ppt.comparison import detect_generation_scenario
@ -471,6 +472,27 @@ def generate_ppt(session_id):
if not template:
return error(ErrorCode.PARAM_ERROR, "所选 PPT 模板不存在或已停用")
template_data = template.to_dict()
if template.source_template_asset_id:
from insurance.ppt.template_asset_service import (
resolve_template_asset,
template_asset_sha256,
)
try:
asset_path = resolve_template_asset(template.source_template_asset_id)
asset_sha256 = template.asset_sha256 or (
template_asset_sha256(asset_path) if asset_path and os.path.isfile(asset_path) else None
)
except (OSError, ValueError):
asset_path = None
asset_sha256 = None
if not asset_path or not asset_sha256:
return error(ErrorCode.PARAM_ERROR, "所选 PPT 模板文件不存在或校验失败")
template_asset_snapshot = {
"assetId": template.source_template_asset_id,
"version": template.asset_version or 1,
"sha256": asset_sha256,
"rendererMode": template.clone_renderer or "clone-edit-v2",
}
file_kinds = [
{"kind": item.get("type")}
for item in files
@ -546,6 +568,7 @@ def generate_ppt(session_id):
"productIds": product_ids,
"brandPolicy": brand_policy,
"scenario": scenario,
"templateAsset": template_asset_snapshot,
},
input_revision=session.draft_revision or 1,
idempotency_key=f"ppt_gen_{session_id}_{session.draft_revision}",

View File

@ -30,7 +30,7 @@ from pptx import Presentation
from pptx.chart.data import CategoryChartData
from pptx.dml.color import RGBColor
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE
from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE, MSO_SHAPE_TYPE
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.util import Inches, Pt
@ -122,6 +122,20 @@ THEMES = {
"good_light": RGBColor(236, 252, 203),
"alert": RGBColor(153, 27, 27),
},
"sage": {
"bg": RGBColor(248, 246, 239),
"panel": RGBColor(255, 255, 255),
"primary": RGBColor(31, 82, 67),
"text_dark": RGBColor(34, 63, 55),
"accent": RGBColor(47, 112, 91),
"accent_light": RGBColor(224, 239, 232),
"gold": RGBColor(204, 151, 57),
"muted": RGBColor(92, 112, 104),
"line": RGBColor(218, 224, 215),
"good": RGBColor(47, 112, 91),
"good_light": RGBColor(211, 233, 221),
"alert": RGBColor(173, 68, 52),
},
}
FONT_CN = "Microsoft YaHei"
@ -212,6 +226,9 @@ def add_bg(slide, colors):
def add_blank_slide(prs):
"""兼容只有少量自定义版式的源 PPTX。"""
template_queue = getattr(prs, "_insurance_template_slide_queue", None)
if template_queue:
return template_queue.pop(0)
layouts = list(prs.slide_layouts)
if not layouts:
raise ValueError("源模板没有可用幻灯片版式")
@ -1634,6 +1651,156 @@ DEFAULT_PAGE_TYPES = {
}
_BUILTIN_FRAME_MAPS = {
# 这些映射只定义“当前场景页使用哪一张源模板页作为可编辑底稿”。
# 模板中的示例文本、图表和表格会在绘制真实数据前清空。
"single_savings.pptx": {
12: [1, 2, 3, 6, 8, 9, 10, 11, 18, 13, 17, 20],
14: [1, 2, 3, 6, 8, 9, 10, 11, 18, 14, 19, 13, 17, 20],
},
"multi_savings_comparison.pptx": {
10: [1, 2, 3, 4, 15, 16, 11, 13, 14, 17],
},
"savings_iul_comprehensive.pptx": {
15: [1, 2, 3, 4, 8, 9, 11, 12, 13, 14, 17, 18, 6, 16, 15],
},
}
_TEMPLATE_THEME_BY_ASSET = {
"single_savings.pptx": "caramel",
"multi_savings_comparison.pptx": "caramel",
"savings_iul_comprehensive.pptx": "sage",
}
_PAGE_TYPE_ALIASES = {
"comparison_chart": {"chart"},
"comparison_table": {"compare", "policy_summary", "table"},
"table": {"policy_summary", "chart"},
"launch_paths": {"guidance", "timeline"},
"alignment_table": {"combined_summary", "policy_summary", "closing"},
"conclusion": {"guidance", "synergy", "closing"},
}
def _auto_frame_map(page_types: list[str], template_config: dict, slide_count: int) -> list[int]:
"""为上传模板建立确定性的逐页映射;页面不足时明确失败。"""
if slide_count < len(page_types):
raise ValueError(
f"所选模板只有 {slide_count} 页,当前方案需要 {len(page_types)} 页,"
"请上传页数充足的模板或调整模板页面配置"
)
source_slides = template_config.get("slidesConfig") or []
source_types = [str(item.get("pageType") or "") for item in source_slides]
if len(source_types) < slide_count:
source_types.extend([""] * (slide_count - len(source_types)))
available = set(range(slide_count))
selected = []
for page_type in page_types:
candidates = [
index for index in sorted(available)
if source_types[index] == page_type
]
if not candidates:
aliases = _PAGE_TYPE_ALIASES.get(page_type, set())
candidates = [
index for index in sorted(available)
if source_types[index] in aliases
]
index = candidates[0] if candidates else min(available)
available.remove(index)
selected.append(index + 1)
return selected
def _resolve_template_frame_map(
source_template_path: str,
page_types: list[str],
template_config: dict,
slide_count: int,
) -> list[int]:
configured = template_config.get("frameMap")
if configured:
frame_map = [int(value) for value in configured]
if len(frame_map) != len(page_types):
raise ValueError("模板 frameMap 页数与当前方案页数不一致")
if any(value < 1 or value > slide_count for value in frame_map):
raise ValueError("模板 frameMap 包含无效源页码")
if len(set(frame_map)) != len(frame_map):
raise ValueError("模板 frameMap 不允许重复使用同一源页面")
return frame_map
asset_name = os.path.basename(source_template_path).lower()
builtin = _BUILTIN_FRAME_MAPS.get(asset_name, {}).get(len(page_types))
if builtin:
return list(builtin)
return _auto_frame_map(page_types, template_config, slide_count)
def _remove_shape(shape):
element = shape._element
element.getparent().remove(element)
def _group_contains_picture(shape) -> bool:
for child in shape.shapes:
if child.shape_type == MSO_SHAPE_TYPE.PICTURE:
return True
if child.shape_type == MSO_SHAPE_TYPE.GROUP and _group_contains_picture(child):
return True
return False
def _sanitize_template_shape(shape, slide_width: int, slide_height: int):
"""移除示例数据,保留背景、图片和形状几何作为模板底稿。"""
if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
if not _group_contains_picture(shape):
_remove_shape(shape)
return
for child in list(shape.shapes):
_sanitize_template_shape(child, slide_width, slide_height)
return
if getattr(shape, "has_chart", False) or getattr(shape, "has_table", False):
_remove_shape(shape)
return
if getattr(shape, "has_text_frame", False) and shape.text.strip():
_remove_shape(shape)
return
if shape.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE:
is_edge_or_background = (
shape.width >= slide_width * 0.9
or shape.height >= slide_height * 0.9
or shape.width <= slide_width * 0.02
or shape.height <= slide_height * 0.02
)
if not is_edge_or_background:
_remove_shape(shape)
def _prepare_template_frames(prs, frame_map: list[int]):
"""保留并重排选中的源页面,随后让现有 builder 原位写入真实数据。"""
slide_ids = list(prs.slides._sldIdLst)
selected_ids = [slide_ids[index - 1] for index in frame_map]
selected_identity = {id(item) for item in selected_ids}
for slide_id in list(slide_ids):
if id(slide_id) in selected_identity:
continue
prs.part.drop_rel(slide_id.rId)
prs.slides._sldIdLst.remove(slide_id)
for slide_id in selected_ids:
prs.slides._sldIdLst.remove(slide_id)
prs.slides._sldIdLst.append(slide_id)
slides = list(prs.slides)
for slide in slides:
for shape in list(slide.shapes):
_sanitize_template_shape(shape, prs.slide_width, prs.slide_height)
prs._insurance_template_slide_queue = slides
def _resolve_page_types(deck):
"""从 DeckContract 中解析要渲染的页面类型列表。"""
scenario_slides = deck.get("scenarioSlides", [])
@ -1659,25 +1826,32 @@ def _resolve_page_types(deck):
def render_deck(deck: dict, output_path: str, theme: str = "deepblue") -> dict:
"""渲染 DeckContract 为 PPTX。"""
colors = THEMES.get(theme, THEMES["deepblue"])
source_template_path = (
deck.get("templateConfig", {}).get("sourceTemplatePath")
)
if source_template_path and os.path.isfile(source_template_path):
prs = Presentation(source_template_path)
while len(prs.slides):
slide_id = prs.slides._sldIdLst[0]
prs.part.drop_rel(slide_id.rId)
del prs.slides._sldIdLst[0]
else:
prs = Presentation()
prs.slide_width = Inches(13.33)
prs.slide_height = Inches(7.5)
template_config = deck.get("templateConfig", {})
slides_config = deck.get("scenarioSlides") or template_config.get("slidesConfig", [])
page_types = _resolve_page_types(deck)
source_template_path = (
template_config.get("sourceTemplatePath")
)
renderer_mode = "generic-builder-v1"
if source_template_path and os.path.isfile(source_template_path):
prs = Presentation(source_template_path)
frame_map = _resolve_template_frame_map(
source_template_path,
page_types,
template_config,
len(prs.slides),
)
_prepare_template_frames(prs, frame_map)
renderer_mode = "clone-edit-v2"
asset_name = os.path.basename(source_template_path).lower()
theme = template_config.get("renderTheme") or _TEMPLATE_THEME_BY_ASSET.get(
asset_name, theme
)
else:
prs = Presentation()
prs.slide_width = Inches(13.33)
prs.slide_height = Inches(7.5)
colors = THEMES.get(theme, THEMES["deepblue"])
slide_errors = []
for i, page_type in enumerate(page_types):
@ -1705,6 +1879,8 @@ def render_deck(deck: dict, output_path: str, theme: str = "deepblue") -> dict:
"path": output_path,
"size": file_size,
"slides": slide_count,
"rendererMode": renderer_mode,
"templateFrameMap": frame_map if renderer_mode == "clone-edit-v2" else [],
}
@ -1715,7 +1891,7 @@ def main():
parser.add_argument("--deck-json", required=True, help="DeckContract JSON 文件路径")
parser.add_argument("--output", required=True, help="输出 PPTX 文件路径")
parser.add_argument("--theme", default="deepblue",
choices=["deepblue", "caramel", "chinese", "business", "minimal", "ink"],
choices=["deepblue", "caramel", "chinese", "business", "minimal", "ink", "sage"],
help="主题配色")
args = parser.parse_args()

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import os
import re
import uuid
import hashlib
BUILTIN_SCHEME = "builtin://"
@ -12,6 +13,15 @@ MAX_TEMPLATE_BYTES = 30 * 1024 * 1024
BUILTIN_TEMPLATE_ROOT = os.path.join(os.path.dirname(__file__), "template_assets")
def template_asset_sha256(path: str) -> str:
"""计算模板资产指纹,供任务快照和 Worker 二次校验。"""
digest = hashlib.sha256()
with open(path, "rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def resolve_template_asset(asset_id: str | None) -> str | None:
"""将数据库中的模板资产标识解析为受控的本地绝对路径。"""
value = str(asset_id or "")

View File

@ -1261,3 +1261,23 @@ slide_count, manifest_json, validation_status, created_at
"templateFallback": false
}
```
## 23. 专项修复实施记录2026-07-31
本轮已完成以下修复:
- 三个内置模板改为 `clone-edit-v2`:按模板清单选择原始页面并保留背景、图片和版式,不再清空源幻灯片后生成统一深蓝基础模板。
- 源模板中的样例业务文本、表格和图表会在动态内容写入前清理;模板页数不足或页面映射非法时明确失败,不再静默回退。
- 模板上传后记录 `assetVersion``assetSha256`;创建任务时固化资产 ID、版本、SHA-256 和渲染模式Worker 严格消费该快照并复核文件哈希。
- `source_template_asset_id` 已扩容为 `VARCHAR(255)`;旧模板文件在仍可能被排队任务引用时不再立即物理删除。
- 海报工作区改为真实缩放画布并从顶部开始滚动;长图不再因垂直居中而无法查看顶部。
- 海报完成状态增加资源加载阶段:背景、图片和字体全部可用且最终合成保存成功后,任务才显示完成;加载失败会展示可重试错误。
- 前端生成按钮与后端 15 秒活动任务复用共同拦截快速重复提交,避免一次点击创建多条生成记录。
已完成验证:
- 三个内置模板均使用真实历史生成契约渲染,输出模式均为 `clone-edit-v2`,且页面映射与模板分别对应。
- PowerPoint 可直接打开三个生成文件,无修复提示;源模板的样例金额未出现在成品文本中。
- IUL 模板的源图片资产在成品中保留;三套模板的最终视觉不再相同。
- 迁移 `migrate_029`、`migrate_030` 已在当前 BaoDan 数据库实际执行模板资产字段、版本、SHA-256 和 `clone-edit-v2` 模式标识已回填。
- Python 编译、Vue 生产构建及 PPT/海报相关自动化回归均通过。

View File

@ -0,0 +1,289 @@
# 海报生成尺寸、模板与导出问题修复报告
日期2026-07-31
范围:海报单图/长图画布、模板体系、AI 背景生成、浏览器合成、服务端保存与下载
结论性质:代码静态审计 + 7 张样图像素核验 + 现有测试核验;本报告不包含代码修改
## 1. 结论
当前问题不是单个尺寸参数错误,而是三套概念被混成了一个 `exportSize`
1. **最终成品尺寸**:用户实际下载图片的像素宽高;
2. **HTML 排版画布尺寸**:文字、图表和模块布局使用的 DOM 尺寸;
3. **AI 背景素材尺寸**:图片模型实际支持并返回的尺寸。
这三层目前没有统一、可验证的尺寸契约,导致:
- 前端展示了后端不支持的尺寸,后端静默回退成 `1024x1792`
- 长图声明固定高度,但正文又按内容自适应,容易产生大段空白或尺寸失真;
- 单图并非独立模板,而是把长图的六段内容放进固定比例容器后隐藏溢出;
- 模板只定义 AI prompt、配色和参考图没有定义真正的版式骨架
- 最终 PNG 依赖用户浏览器再次合成并上传,用户离开页面、浏览器内存不足或上传超限时,后台任务虽然显示完成,下载文件仍可能不存在。
综合判断:当前海报生成链路处于“可演示,但不可稳定交付”的状态。需要先修正尺寸和成品生成架构,再继续增加模板。
## 2. 样图尺寸核验
| 样图 | 实际像素 | 宽高比 | 判断 |
|---|---:|---:|---|
| 图 1 | 1242×9530 | 1:7.67 | 典型多段内容长图,宽度固定、高度随内容增长 |
| 图 2 | 1024×1536 | 2:3 | 文件本身不是长图,而是多个页面被排成 2 列拼版;可作为多章节风格参考,不能作为长图尺寸参考 |
| 图 3 | 1242×9449 | 1:7.61 | 典型多段内容长图 |
| 图 4 | 1242×12440 | 1:10.02 | 内容结束后存在明显空白尾部,是错误高度/错误截取范围的直接表现,不应作为目标尺寸 |
| 图 5 | 900×2209 | 约 9:22 | 超高竖版单图 |
| 图 6 | 992×1586 | 约 5:8 | 竖版单图 |
| 图 7 | 1024×1536 | 2:3 | 标准竖版单图 |
因此,“长图”和“单图”不能只按一个固定比例区分:
- **长图**的核心定义是固定宽度、内容驱动高度,建议标准成品宽度为 `1242``1080`,高度取真实内容高度;
- **单图**的核心定义是固定画幅和单屏信息预算,建议主规格为 `1024×15362:3`,辅规格为 `1080×19209:16`;如确需图 5 风格,可单独增加“超高单页”规格,不能混入普通单图。
## 3. 审计健康度
| 维度 | 分数 | 核心问题 |
|---|---:|---|
| 可访问性 | 2/4 | 可编辑区域依赖 `contenteditable`,缺少明确标签和键盘状态提示 |
| 性能 | 1/4 | 超长 DOM 使用固定 `pixelRatio: 2` 转 base64 PNG内存和文件体积不可控 |
| 响应式/尺寸适配 | 1/4 | 预览缩放有所改善,但成品尺寸、内容高度和 AI 素材尺寸没有统一 |
| 主题系统 | 1/4 | 模板色只影响少数组件,正文大量写死白底和绿色 |
| 实现完整性 | 1/4 | 单图/长图共用骨架,模板没有版式能力,最终成品依赖前端在线合成 |
| **总分** | **6/20Poor** | **发布前需要结构性修复** |
## 4. 详细问题
### P0-1前端尺寸预设与图片模型尺寸映射完全不一致
证据:
- 前端长图提供 `1080x2160`、`1080x3240`、`1080x4320`;单图还提供 `1792x1024`、`1080x1440`、`2480x3508` 等规格:`frontend/src/components/poster/workspace/PosterCreativePanel.vue:215-231`。
- 后端 `SIZE_MAP` 只识别 `1080x1920`、`900x500`、`1080x1080`、`800x1200``api/insurance/poster/image_generator.py:9-15`。
- 所有未命中的规格都会静默回退到 `1024x1792``api/insurance/poster/image_generator.py:146-160`。
影响用户选择“超长图”“A4”“横版”等规格时AI 实际仍可能生成 `1024x1792` 背景。数据库却继续记录用户原始选择,形成“记录尺寸”和“真实文件尺寸”不一致。
修复要求:建立唯一尺寸注册表,前后端共享同一组规格;对不支持尺寸必须返回参数错误,禁止静默回退。
### P0-2最终成品依赖浏览器合成后台任务完成不等于可下载
证据:
- Celery 任务只保存 AI 背景到 `background_file_url`,不会生成带文案和图表的最终海报:`api/insurance/generation/celery_tasks.py:1178-1200`。
- 最终海报由浏览器执行 `html-to-image`,随后再上传到 `/rendered``frontend/src/pages/PosterPage.vue:438-467`。
- `/download/<id>` 只接受 `export_url`;如果浏览器没有成功上传最终图,即使 `background_file_url` 存在,下载仍返回“文件不存在”:`api/insurance/poster/routes.py:309-327`。
- 服务端限制最终 PNG 不超过 30 MB`api/insurance/poster/service.py:422-452`。超长图或高像素图很容易触发此限制。
影响:用户关闭页面、切换任务、浏览器崩溃、合成失败或上传超限后,任务仍可能显示完成,但历史记录无法下载。这与目前反馈的“生成出来但导出有问题”高度吻合。
修复要求:最终成品必须由服务端任务确定性生成并落盘。浏览器预览可保留,但不应承担唯一成品生成职责。短期内至少应增加独立的 `render_status`,只有最终 PNG 已落盘才显示“完成/可下载”。
### P1-1单图不是独立版式只是长图容器的裁剪版本
证据:
- 单图和长图均渲染同一个 `PosterHtmlCanvas`,同一组 hero、summary、benefits、features、CTA、disclaimer`frontend/src/components/poster/long/PosterHtmlCanvas.vue:1-46`。
- 单图仅设置 `aspect-ratio`,根节点同时设置 `overflow: hidden``frontend/src/components/poster/long/PosterHtmlCanvas.vue:92-101,126-140`。
影响:内容多时会被裁掉或挤压;内容少时会留下空白。图 57 那种“一张图内完成主视觉、核心利益和数据条”的单图构图,当前骨架无法稳定实现。
修复要求:拆分为至少两个成品组件:
- `PosterSingleCanvas`:固定画幅,严格信息预算,主视觉与数据一体化;
- `PosterLongCanvas`:固定宽度,章节按内容自然增长。
### P1-2长图同时使用“预设固定高度”和“内容自适应高度”
证据:
- 长图画布设置 `minHeight: h`,实际高度又由所有章节内容撑开:`frontend/src/components/poster/long/PosterHtmlCanvas.vue:92-98`。
- 长图尺寸预设给出 2160、3240、4320 三个固定高度:`PosterCreativePanel.vue:215-222`。
影响:当内容不足时产生空白尾部,图 4 已经直观展示这一问题;当内容超过预设时,最终高度又不再等于选择值。`exportSize` 因而既不是目标尺寸,也不是真实尺寸。
修复要求:长图取消固定高度预设,改为“成品宽度 + 自动高度”。如果业务确实需要高度档位,应把它定义为内容预算或最大高度,并在超出时分片,而不是 `min-height`
### P1-3模板只是“背景提示词”不是版式模板
证据:
- `PosterTemplate` 只有名称、场景标签、风格描述、配色、参考图和预览图,没有 `layout_key`、支持模式、支持比例或章节规则:`api/insurance/models/poster_template_model.py:6-39`。
- 模板列表不按 `single/long` 过滤,同一模板可直接用于所有输出模式:`frontend/src/components/poster/workspace/PosterCreativePanel.vue:256-315`。
- HTML 正文布局始终是同一套组件。
影响:选择不同模板时,主要变化只是 AI 背景和少量颜色,无法产生样例中真正不同的章节节奏、数据板式、主视觉占比和信息密度。
修复要求:模板模型至少新增:
- `layout_key`:对应真实前端/服务端布局实现;
- `supported_modes``single`、`long` 或两者;
- `supported_aspect_ratios`
- `section_schema`:允许的章节、顺序、是否必选、内容上限;
- `background_strategy`hero、full-bleed、section、none
- `version`:保证旧海报可重复渲染。
### P1-4模板配色没有贯穿整张海报
证据:
- hero 和 CTA 会读取模板主色/强调色;
- summary、benefit chart、feature cards 和 disclaimer 大量写死 `#1a3a2a`、`#2d6a4f`、白色和固定灰色:`frontend/src/components/poster/long/PosterSummaryCards.vue:57-93`、`PosterBenefitChart.vue:122-169`、`PosterFeatureCards.vue:35-108`、`PosterDisclaimer.vue:27-52`。
影响:暖色、深蓝、深绿等模板最终正文仍呈现同一套白底绿色组件,无法还原图 14 的整页主题一致性。
修复要求:用画布级 CSS variables/tokens 统一控制背景、正文、弱文本、卡片、边框、主色和强调色;每个布局只消费 token不再写死品牌颜色。
### P1-5AI 背景尺寸与最终画布职责混乱
证据:
- prompt 把最终 `size` 直接作为 AI 图片尺寸要求:`api/insurance/poster/image_generator.py:117-123`。
- 图片 API 实际只接受有限尺寸,长图尺寸无法原生生成;返回图随后仅作为 hero 的 `background-size: cover` 背景:`frontend/src/components/poster/long/PosterHero.vue:30-39,49-56`。
影响:系统一方面要求模型生成整张长图尺寸,另一方面只把结果裁进顶部 360px hero。长图模板信息没有真正作用于正文参考图若含完整排版还可能诱导模型生成拼版、伪文字或多页面画面。图 2 的“多页拼成一张”正是需要防止的结果类型。
修复要求AI 只生成明确用途的素材,例如 `hero_background_1536x1024`最终海报尺寸完全由布局渲染器负责。prompt 必须明确安全文字区、人物位置、焦点和裁切策略,而不是传最终长图高度。
### P1-6导出像素比固定为 2真实尺寸与选择值无关
证据:
- `html-to-image` 固定使用 `pixelRatio: 2``frontend/src/pages/PosterPage.vue:438-449`。
- HTML 画布宽度又被限制为最多 1080px`frontend/src/components/poster/long/PosterHtmlCanvas.vue:92-100`。
影响:选择 1024 宽时实际导出可能为 2048 宽;选择 2480 宽时 DOM 仍只有 1080 宽,实际导出约 2160 宽;数据库记录的 `exportSize` 均不能代表真实输出。长图还会产生数千万像素的中间 canvas 和 base64 字符串,明显增加浏览器崩溃、黑图、空图或上传超限概率。
修复要求:以目标成品宽度反推导出 scale导出后解码 PNG 校验真实宽高。长图使用 Blob 流程,避免 data URL设置最大像素总量超过阈值自动降低 scale 或按章节分片。
### P2-1服务端只检查 PNG 文件头,不校验真实尺寸和完整性
`save_rendered` 只检查 30 MB 上限和 PNG signature没有用 Pillow 解码,也没有对比期望尺寸:`api/insurance/poster/service.py:422-452`。
修复要求:解码并验证图片完整性,记录 `actual_width`、`actual_height`、`byte_size`、`sha256`;单图必须符合比例容差,长图必须符合固定宽度和最大像素限制。
### P2-2尺寸变更没有进入文档自动保存监听
`buildPosterDocument()` 包含 `exportSize`,但自动保存 watcher 没有监听 `exportSize``frontend/src/pages/PosterPage.vue:366-400`。
影响:用户生成后修改尺寸但没有再次下载时,服务端文档可能保持旧尺寸。
### P2-3现有测试没有覆盖尺寸和导出链路
本次执行 `tests/ppt_poster_optimization_test.py`13 项全部通过;但现有测试只覆盖字段画像、内容裁剪、合规和基础生成前置条件,没有覆盖:
- 所有前端规格能否被后端识别;
- AI 返回尺寸与请求尺寸是否一致;
- 单图是否裁切内容;
- 长图是否存在空白尾部;
- 最终 PNG 的真实像素;
- 关闭页面后历史任务是否仍可下载;
- 30 MB、超长 canvas、图片加载失败和图表未完成渲染。
### P2-4当前工作区的 `PosterStage.vue` 尾部存在游离 CSS
`</style>` 后仍残留一段没有选择器的 CSS 声明。`vue-tsc --noEmit` 当前可以通过,但该内容属于无效/无归属实现,说明最近预览缩放调整尚未完成清理。它不是本次尺寸问题的主因,但修复时应一并做构建级校验。
## 5. 推荐目标架构
### 5.1 尺寸契约
建议定义统一的 `PosterFormatSpec`
```text
id single_2_3 | single_9_16 | long_auto_1242
mode single | long
canvasWidth 1024 | 1080 | 1242
canvasHeight 固定值或 auto
aspectRatio 单图必填,长图为空
maxPixelCount 浏览器/服务端安全阈值
assetSpecs hero 背景等 AI 素材的独立尺寸
allowedLayouts 可使用的 layout_key
```
前端选项、后端校验、任务快照和导出器必须读取同一份规格。数据库同时保存请求规格 ID 和最终真实像素。
### 5.2 两类独立布局
**单图模板**
- 主视觉占 50%70%
- 标题、35 个核心数据、最多 3 个卖点、免责声明;
- 禁止完整收益图表和六段长文;
- 默认 `1024×1536`,可选 `1080×1920`
**长图模板**
- 固定宽度 `1242``1080`
- hero、方案摘要、收益趋势、里程碑卡片、优势、适配人群、CTA、免责声明分章节
- 高度按真实内容计算;
- 超过最大像素总量时输出多张连续图片或服务端分片后再拼接。
### 5.3 成品生成
推荐优先级:
1. **服务端 Headless Chromium 渲染最终 HTML 海报**,确保任务完成后一定存在最终 PNG
2. 浏览器只负责可编辑预览和低分辨率即时预览;
3. AI 只生成背景素材不生成文字、数字、Logo、表格或整张海报
4. 最终文件保存前执行像素、体积和解码校验;
5. 下载接口只返回通过校验的最终文件,并明确区分背景素材和最终成品。
## 6. 分阶段修复计划
### 第一阶段止血P0预计 12 天)
1. 删除前端所有后端不支持的伪规格,或补齐严格映射并拒绝未知规格;
2. 单图默认改为 `1024×1536`;长图改为固定宽度 + 自动高度;
3. 去掉固定 `pixelRatio: 2`,按目标宽度计算输出 scale
4. 导出后校验真实 PNG 宽高和文件体积;
5. 任务状态拆成“背景生成完成”和“最终成品完成”,没有 `export_url` 不得显示可下载;
6. 下载失败时返回明确原因,禁止用“文件不存在”掩盖合成未完成。
### 第二阶段版式重构P1预计 35 天)
1. 拆分 `PosterSingleCanvas``PosterLongCanvas`
2. 为模板增加 `layout_key`、支持模式、比例和章节 schema
3. 模板列表按输出模式和比例过滤;
4. 建立完整主题 token移除正文硬编码绿色
5. AI 背景改成独立 asset spec不再接收最终长图高度。
### 第三阶段可靠导出P1预计 24 天)
1. 将最终海报迁移到服务端 Headless Chromium 渲染;
2. 增加超长图最大像素和分片策略;
3. 保存真实宽高、字节数、哈希、渲染器版本和模板版本;
4. 历史记录支持重新渲染,不依赖原浏览器会话。
### 第四阶段回归验证P1/P2预计 12 天)
至少建立以下金丝雀用例:
1. `single_2_3` 输出严格为 `1024×1536`
2. `single_9_16` 输出严格为 `1080×1920`
3. `long_auto_1242` 输出宽度严格为 `1242`,底部空白不超过设计 footer
4. 同一份数据在单图中不出现完整收益表,在长图中章节齐全;
5. 所有模板只出现在兼容模式中;
6. 用户提交后立即关闭页面,任务仍能生成并下载最终海报;
7. 超大长图不会产生黑图、空图、截断或超过服务端限制;
8. 下载文件实际尺寸、数据库记录和 UI 显示三者完全一致。
## 7. 验收标准
修复完成必须同时满足:
- 单图和长图具有不同布局组件、不同内容预算和不同模板白名单;
- 任何尺寸都没有静默回退;
- 长图底部不出现由固定 `min-height` 造成的大段空白;
- 导出 PNG 的真实像素与规格一致,并被服务端解码校验;
- 后台显示“完成”时,即使原页面已关闭,历史记录也能直接下载最终成品;
- 模板色彩和版式覆盖整张海报,而不是只改变 hero/CTA
- 自动化测试覆盖尺寸注册表、真实像素、超长图和无浏览器会话下载。
## 8. 正向发现
- 已经把 AI 图片定位为“无文字背景”,方向是正确的;
- `PosterHtmlCanvas` 已按章节组件化,拆分单图/长图时可以复用数据和部分章节;
- 预览缩放已经使用独立 shell + transform思路合理
- 导出前已经等待字体和背景资源加载,比直接截图稳定;
- 字段画像已区分单图/长图的信息预算,后续可直接用于布局白名单。
这些基础可以保留,但必须先补齐尺寸契约、独立布局和服务端最终渲染三个核心环节。

View File

@ -0,0 +1,753 @@
# 海报生成问题报告复核与详细修复计划书
日期2026-07-31
复核对象:`docs/海报生成尺寸模板与导出问题修复报告_20260731.md`
实施范围:`api/insurance/poster/`、`api/insurance/generation/` 中的海报任务、`frontend/src/components/poster/`、`frontend/src/pages/PosterPage.vue` 及对应测试
约束:不修改 BaoDan/Dify 基座文件;所有后端自研改动继续位于 `api/insurance/`;本计划不直接修改业务代码
## 1. 执行摘要
上一份报告对以下主问题判断正确:
- 单图和长图共用一套排版骨架;
- 前端尺寸选项与图片模型支持尺寸不一致;
- 模板只有风格提示能力,没有真正的版式能力;
- `pixelRatio: 2` 使实际导出尺寸失控;
- 最终文件依赖浏览器合成,任务状态与可下载状态存在错位。
但上一份报告不能直接作为开发计划使用,原因是:
1. 它虽然提出了三层尺寸概念,后续方案却再次把 **CSS 逻辑排版宽度****最终 PNG 像素宽度** 混在一起;
2. 它把服务端 Headless Chromium 当成必选修复,没有先验证较小、较快的浏览器导出修复能否满足当前业务;
3. 部分结论属于推断而不是已复现事实,例如图 4 空白一定来自当前系统、长图很容易超过 30 MB
4. 它漏掉了两个重要根因:**渲染数据来源分裂**,以及 **fallback 背景仍会绘制文字**
5. 缺少文件级任务、接口契约、兼容策略、灰度方案、回滚方案和可执行验收用例。
本计划采用“先建立可测基线,再做最小闭环修复,最后决定是否引入服务端浏览器”的顺序。推荐目标工期为 **1319 人日**;若确认必须支持“用户关闭页面后仍自动产出最终海报”,再增加服务端渲染阶段,预计额外 **47 人日**
## 2. 本计划的工作假设
在没有新的产品确认前,按以下假设推进,实施前第 0 阶段必须由产品负责人确认:
- 图 14 表示“完整方案/高信息密度”的设计语言,其中图 2 是多面板拼版参考,不直接作为连续长图的物理尺寸标准;
- 图 57 表示单屏竖版海报的设计语言;
- 单图首发只保留 `2:3``9:16` 两种规格;
- 连续长图使用固定逻辑宽度、内容自动高度,首发只保留一个导出宽度;
- 先保留当前 AI“只生成背景素材”的路线文字、数字、图表和 Logo 由确定性布局渲染;
- 第一版先修好当前浏览器导出链路,服务端 Headless Chromium 是否上线由可靠性门槛决定;
- 当前工作区已经存在其他未提交修改,实施时不得覆盖或重排无关改动。
## 3. 对上一份报告的问题复核
### 3.1 尺寸分层提出正确,但目标模型仍不够准确
报告区分了最终尺寸、HTML 画布尺寸和 AI 素材尺寸,这是正确的。但它随后建议把长图 `canvasWidth` 直接设为 `1242``1080`,仍然混淆了两种宽度。
当前海报组件使用 `28px` 标题、`1218px` 正文、`2032px` padding这套字号更像为约 `390540 CSS px` 的逻辑画布设计。如果直接在 `1080 CSS px` 画布上排版,预览缩小后文字和间距会显得过小;这正是目前视觉密度不足的重要原因之一。
正确模型应为:
```text
逻辑排版尺寸:决定文字、间距、网格和组件构图,例如 414 CSS px 宽
导出缩放倍率:例如 3
最终成品尺寸414 × 3 = 1242 px 宽
AI 素材尺寸:由 hero/full-bleed 素材槽位和供应商能力单独决定
```
因此修复中必须同时存在 `layoutWidth``outputWidth`,禁止继续使用一个 `exportSize` 表达全部含义。
### 3.2 `1242px` 只能作为候选导出宽度,不能直接当成产品标准
图 1、3、4 都是 `1242px` 宽,但这也可能来自手机截图或三倍屏导出。没有渠道规范、文件上传限制和目标设备信息,不能仅凭样图认定所有长图必须输出 `1242px`
计划中将 `1242px` 作为首选候选,同时保留在第 0 阶段改为 `1080px` 的决策点。两者不应同时首发,避免继续扩大测试矩阵。
### 3.3 图 2 和图 4 的结论过于确定
- 图 2 是 `1024×1536` 的多面板拼版。它可能是设计参考,也可能是模型错误输出;没有生成上下文时不能直接判定为失败。
- 图 4 的空白尾部确实符合错误截取范围,但尚未证明它由当前 `min-height` 直接生成。
修复时应把这两项变成复现用例,而不是把推断当成已确认根因。
### 3.4 30 MB 风险没有数据支撑
7 张样图实际文件大小约为 `0.204.60 MB`,均远低于服务端的 30 MB 限制。固定 `pixelRatio: 2` 和 data URL 的确存在浏览器内存风险,但“很容易超过 30 MB”没有被样本证明。
计划应分别记录:
- PNG 压缩后文件大小;
- 解码后的像素总量;
- 浏览器合成峰值内存;
- 上传限制。
文件大小和内存占用不是同一个问题,不能混为一谈。
### 3.5 服务端 Headless Chromium 是可选架构决策,不是第一步
服务端 Chromium 会引入浏览器二进制、字体、worker 内存、Docker 镜像体积、内部渲染鉴权和版本一致性问题。当前项目没有 Playwright/Puppeteer 依赖,直接迁移会扩大改动面。
更稳妥的顺序是:
1. 先修正布局、尺寸契约和浏览器 Blob 导出;
2. 记录真实失败率和关闭页面后的业务要求;
3. 如果必须支持无页面会话生成,或浏览器导出失败率超过门槛,再引入服务端渲染。
### 3.6 审计健康分数对本次决策帮助有限
`6/20` 混入了可访问性、主题和响应式等维度,但本次核心是成品尺寸、版式和导出可靠性。没有运行时页面和用户任务数据,分数存在伪精确感。
本计划改用可验证门槛:真实像素是否一致、内容是否裁切、空白是否超标、关闭页面后是否需要完成、下载成功率和视觉基准是否通过。
### 3.7 报告漏掉了渲染数据来源分裂
当前至少存在三份内容来源:
- 前端画布直接使用 `draft.parsedFields`
- Celery 的 `build_poster_content()` 使用 `product_rules.get("facts") or product_rules`
- 最终 `document_json` 又保存 case 的 `confirmed_data`
`poster_content` 只用于 AI prompt并没有成为 HTML 画布的统一输入。产品小册子的 features、计划书确认数据、字段画像和前端 sections 因而可能不一致。
这会造成“AI 背景理解的是一组卖点,画布展示的是另一组数据”。修复必须先建立唯一 `renderDocument`,不能只改 CSS。
### 3.8 报告漏掉了 fallback 图片会绘制文字
正常 prompt 要求 AI 只生成无文字背景,但 `generate_fallback()` 会在图片中绘制 headline、body 和 CTA。该图片随后又作为 hero 背景,前端 HTML 会再次绘制同样文案,可能产生重复文字、裁切文字或乱码。
fallback 必须改为无文字的纯背景素材,或者在背景生成失败时直接使用模板渐变,不能再生成“半成品海报”。
### 3.9 报告缺少落地约束
原报告没有明确:
- 首发到底支持哪些规格;
- 哪些旧记录继续使用 v1
- 模板数据如何迁移;
- 模式切换后 sections 和 template 如何处理;
- 如何验证 ECharts 已完成渲染;
- 如何灰度、监控和回滚;
- 每个阶段改哪些文件以及完成定义。
以下计划补齐这些内容。
## 4. 目标产品规格
### 4.1 输出模式与规格
首发建议只支持三种格式,避免继续开放不可验证的“自定义尺寸”:
| formatId | 用途 | 逻辑尺寸 | 最终像素 | 高度策略 |
|---|---|---:|---:|---|
| `single_2_3` | 朋友圈/私聊单图 | 512×768 CSS px | 1024×1536 | 固定 |
| `single_9_16` | 竖屏故事/企微 H5 | 540×960 CSS px | 1080×1920 | 固定 |
| `long_1242_auto` | 完整方案长图 | 414×auto CSS px | 1242×auto | 内容驱动 |
第 0 阶段如果确认渠道统一要求 `1080px` 长图,则把最后一项改为 `long_1080_auto`,逻辑宽度仍保持独立,不改变组件排版原则。
首发明确不支持横版、方图、A4、自定义宽高、固定 2160/3240/4320 高度。以后增加格式时,每增加一个格式都必须同时增加视觉基准和导出测试。
### 4.2 内容预算
**单图:**
- 1 个品牌区;
- 1 个主标题,建议不超过 24 个中文字符;
- 1 个副标题,最多 23 行;
- 35 个核心数据;
- 最多 3 个卖点;
- 1 个 CTA
- 1 条精简免责声明;
- 不展示完整收益折线图和多年度卡片矩阵。
**长图:**
- hero
- 计划摘要;
- 收益趋势图(有至少 3 个有效节点时);
- 里程碑数据卡;
- 最多 6 个产品卖点;
- 适配/不适配说明;
- CTA
- 完整免责声明;
- 高度完全由实际可见章节决定,不设置人为 `min-height`
### 4.3 模板定义
首发不做无限可配置模板系统,只增加解决当前问题所需的最少字段:
```text
layoutKey single_hero_data | single_story | long_editorial
supportedModes [single] | [long]
supportedFormats [single_2_3, ...]
themeTokens 背景、文字、卡片、边框、主色、强调色
backgroundSlot hero | full_bleed
version 整数
```
`section_schema`、任意拖拽布局和管理员可视化模板编辑器不进入本次范围。
## 5. 目标技术模型
### 5.1 格式注册表
在后端建立唯一格式注册表,例如:
`api/insurance/poster/format_registry.py`
职责:
- 返回首发格式列表;
- 校验 `formatId``outputMode`
- 提供逻辑尺寸、最终尺寸、最大高度和 AI asset slot
- 拒绝未知格式,不再静默回退。
新增只读接口:
```http
GET /insurance/poster/formats
```
前端从该接口加载选项,避免 TypeScript 与 Python 各维护一份尺寸清单。接口不可用时只回退到内置的三项安全规格,不开放自定义尺寸。
为了兼容旧记录,保留数据库 `export_size` 字段;新记录同时在 `document_json` 中保存 `formatId`、`requestedOutput` 和 `actualOutput`。第一期不为了实际宽高额外增加数据库列,避免不必要迁移。
### 5.2 统一渲染文档
新增后端构建器,例如:
`api/insurance/poster/render_document_builder.py`
统一输出:
```json
{
"schemaVersion": 2,
"formatId": "single_2_3",
"outputMode": "single",
"layoutKey": "single_hero_data",
"copy": {},
"facts": {},
"features": [],
"benefits": [],
"theme": {},
"sections": [],
"background": {},
"complianceRevision": ""
}
```
数据优先级固定为:
1. 人工确认的 case `confirmed_data`
2. 产品小册子规则中的产品卖点和保障规则;
3. 模板默认值;
4. 安全空状态。
Celery prompt、前端预览、最终导出和历史恢复都读取同一份文档不再分别拼装。
### 5.3 画布路由
新增轻量路由组件:
```text
PosterCanvasRouter
├── PosterSingleCanvas
└── PosterLongCanvas
```
章节组件可以复用,但容器、信息预算和排版规则必须独立。`PosterHtmlCanvas` 不再通过 `aspect-ratio + overflow:hidden` 模拟单图。
模式或格式切换时必须执行:
1. 重新计算兼容 layout
2. 过滤不兼容 template
3. 按内容预算规范化 sections
4. 告知用户哪些内容被隐藏,而不是静默裁切;
5. 标记当前最终成品为过期,需要重新导出。
### 5.4 AI 背景素材
图片模型只接收素材槽位规格,不接收最终长图高度:
```text
最终长图1242×auto
逻辑 hero414×360 CSS px
AI 素材:由 provider capability 映射到最接近的竖版或横版背景尺寸
```
图片供应商尺寸解析必须严格:
- 已支持规格明确映射;
- 未支持规格返回可诊断错误;
- 不得无提示回退到 `1024x1792`
- 记录 requested asset size 和 actual asset size
- fallback 只生成无文字渐变/纹理背景。
### 5.5 浏览器导出 v2
第一版继续使用浏览器导出,但必须改成确定性流程:
1. 等待字体完成;
2. 等待全部图片 decode
3. 等待 ECharts `finished` 事件,而不是只等待两个 animation frame
4. 读取未缩放画布的逻辑尺寸;
5. 根据 `outputWidth / layoutWidth` 计算唯一导出倍率;
6. 单图固定输出宽高;长图以真实内容高度计算输出高度;
7. 使用 Blob 导出,不经 base64 data URL 中转;
8. 客户端解码 Blob 核验宽高;
9. 服务端用 Pillow 再次解码并校验;
10. 校验通过后才写入 `export_url` 并显示“可下载”。
长图安全门槛首版建议:
- 最终像素高度超过浏览器验证上限时阻止导出并给出明确提示;
- 不在本次首发中自动降采样,因为静默降低质量会再次造成记录和成品不一致;
- 分片导出作为后续功能,只有真实案例超过上限后再实现。
## 6. 分阶段实施计划
## 阶段 0冻结基线与复现12 人日)
### 目标
把推断变成可重复证据,确认首发格式。
### 任务
1. 保存当前工作区差异清单,标记海报相关未提交改动的负责人;
2. 固定一份脱敏计划书数据、产品规则、模板和文案作为测试 fixture
3. 对当前版本分别导出:
- single 默认规格;
- long 2160
- long 4320
4. 记录选择尺寸、DOM 尺寸、PNG 真实尺寸、像素总量、Blob 大小、上传结果和下载结果;
5. 验证图 4 空白是否可由当前代码稳定复现;
6. 验证图 2 是否属于允许的多面板设计方向;
7. 确认长图首发输出宽度为 1242 或 1080
8. 确认是否存在“用户关闭页面后必须继续完成最终成品”的硬需求。
### 产出
- `tests/fixtures/poster/` 脱敏 fixture
- 当前版本导出基线表;
- 三个首发 formatId 的最终确认;
- 服务端 Headless 是否必须进入本期的决策记录。
### 完成定义
同一 fixture 在同一浏览器连续导出 3 次,能够稳定复现当前尺寸问题,并获得真实 PNG 元数据。
## 阶段 1尺寸契约止血23 人日)
### 后端任务
- 新增 `format_registry.py`
- 新增 `GET /poster/formats`
- `PosterService.generate_poster()` 改为接收 `formatId`
- 旧请求中的 `size` 仅用于兼容映射,无法映射时返回参数错误;
- `PosterImageGenerator` 将最终格式与 AI asset size 分离;
- 删除未知尺寸默认回退;
- `generate_fallback()` 改成无文字背景。
### 前端任务
- `PosterDraft` 新增 `formatId`
- `PosterCreativePanel.vue` 从 formats API 渲染规格;
- 删除 A4、横版、方图和自定义尺寸入口
- outputMode 切换时自动选择第一个兼容格式;
- `buildPosterDocument()` 和自动保存 watcher 纳入 `formatId`
### 主要文件
- `api/insurance/poster/format_registry.py`(新增)
- `api/insurance/poster/routes.py`
- `api/insurance/poster/service.py`
- `api/insurance/poster/image_generator.py`
- `frontend/src/utils/poster-api.ts`
- `frontend/src/composables/usePosterWorkspace.ts`
- `frontend/src/components/poster/workspace/PosterCreativePanel.vue`
- `frontend/src/pages/PosterPage.vue`
### 测试
- 每个 formatId 都能返回唯一逻辑/输出规格;
- 未知 formatId 返回 4xx 业务错误;
- 旧 `1024x1792` 记录可映射到安全兼容格式;
- 所有前端可见格式均被后端接受;
- fallback PNG 不包含 headline/body/CTA 绘制逻辑。
### 完成定义
UI 中不存在任何会被后端静默改成其他尺寸的选项。
## 阶段 2统一渲染文档23 人日)
### 后端任务
- 新增 `render_document_builder.py`
- case confirmed facts、product rules、copy、template、profile 合并为 schema v2
- `build_poster_content()` 明确接收 case facts 与 product rules修复当前来源混用
- record 创建时立即保存完整 renderDocument
- Celery 从 renderDocument 构建背景 prompt
- 历史恢复返回原始 schemaVersion禁止用最新规则偷偷改变旧成品。
### 前端任务
- `PosterDraft` 增加 `renderDocument` 或等价强类型字段;
- 画布从 renderDocument 读取 facts、features、benefits、theme 和 sections
- 删除 `defaultFeatures``parsedFields.key_benefits` 临时拼装的逻辑;
- 编辑操作只修改 renderDocument 对应字段。
### 主要文件
- `api/insurance/poster/render_document_builder.py`(新增)
- `api/insurance/poster/content_builder.py`
- `api/insurance/poster/service.py`
- `api/insurance/generation/celery_tasks.py`
- `frontend/src/composables/usePosterWorkspace.ts`
- `frontend/src/components/poster/workspace/PosterStage.vue`
- `frontend/src/pages/PosterPage.vue`
### 测试
- 人工确认数据覆盖 AI 原始解析数据;
- 产品 features 能进入前端画布;
- 同一 renderDocument 生成的 prompt、预览和导出使用相同事实
- schema v1 旧记录仍能恢复;
- 缺失收益表时不显示空图表。
### 完成定义
任意一个展示字段都能追溯到 renderDocument 的唯一字段,不再从三个不同对象临时拼接。
## 阶段 3拆分单图/长图版式与模板46 人日)
### 数据和模板任务
- 为 `poster_templates` 增加最少兼容字段;迁移编号使用实施时的下一个可用编号,避免与当前未提交迁移冲突;
- 为现有模板补齐默认 `layoutKey`、supportedModes 和 supportedFormats
- 旧模板无法识别时回退到明确的 legacy layout不自动套新布局。
### 前端任务
- 新增 `PosterCanvasRouter.vue`
- 新增 `single/PosterSingleCanvas.vue`
- 将现有 `long/PosterHtmlCanvas.vue` 收敛为真正的 `PosterLongCanvas.vue`
- 抽取共享的事实格式化、免责声明和品牌组件,不抽象视觉结构;
- 建立画布级 theme CSS variables
- summary/chart/features 等组件移除硬编码绿色;
- 单图只渲染允许的信息预算;
- 长图移除 `minHeight: export height`
- 模板选择按 mode/format 过滤;
- 切换模式时显示内容调整提示。
### 单图构图要求
- 主视觉是第一视觉焦点;
- 标题、核心数据条和 3 个卖点在一屏内完整可读;
- footer 和免责声明始终可见;
- 不依赖 `overflow:hidden` 裁掉正文。
### 长图构图要求
- 章节间有明确的色块和节奏变化;
- 背景和 theme 贯穿全部章节;
- 内容结束后立即进入 footer不存在人为尾部留白
- 图表和数据卡具有适合 414 CSS px 逻辑宽度的字号。
### 主要文件
- `api/insurance/models/poster_template_model.py`
- `api/insurance/db/migrate_0xx.py`(使用实际下一个编号)
- `api/insurance/admin/ppt_admin_service.py`
- `frontend/src/components/poster/PosterCanvasRouter.vue`(新增)
- `frontend/src/components/poster/single/PosterSingleCanvas.vue`(新增)
- `frontend/src/components/poster/long/PosterHtmlCanvas.vue`
- `frontend/src/components/poster/long/*.vue`
- `frontend/src/components/poster/workspace/PosterCreativePanel.vue`
- `frontend/src/components/poster/workspace/PosterSectionPanel.vue`
### 视觉基准
至少建立三张基准图:
- 单图 2:3
- 单图 9:16
- 标准长图。
使用同一 fixture 与批准样图逐项对比信息层级、主视觉占比、文字可读性、数据完整性、章节节奏、footer 和空白尾部。
### 完成定义
单图和长图在 DOM 结构、内容预算和模板白名单上均相互独立;隐藏任意章节后不会破坏剩余布局。
## 阶段 4可靠导出 v223 人日)
### 前端任务
- 将 `renderComposite()` 拆到独立 `poster-exporter.ts`
- 导出时使用未应用预览 transform 的画布;
- 根据 format spec 计算导出倍率;
- 单图固定宽高,长图读取真实内容高度;
- 将 ECharts ready 纳入资源屏障;
- 使用 Blob API
- 导出后在客户端校验真实像素;
- 导出状态明确区分 `preparing`、`rendering`、`uploading`、`ready`、`failed`
- 对失败给出具体错误,不直接回退下载旧文件。
### 后端任务
- `save_rendered()` 使用 Pillow `verify()` 和重新打开读取尺寸;
- 校验实际尺寸是否符合 format spec
- 将 actualOutput、byteSize 和校验结果保存到 document/extraData
- 只有验证通过才写 `export_url`
- `/download/<id>` 在最终文件未就绪时返回“成品仍在准备/需要重新导出”,不返回模糊的“文件不存在”。
### 主要文件
- `frontend/src/utils/poster-exporter.ts`(新增)
- `frontend/src/pages/PosterPage.vue`
- `frontend/src/components/poster/long/PosterBenefitChart.vue`
- `frontend/src/components/poster/workspace/PosterStage.vue`
- `api/insurance/poster/service.py`
- `api/insurance/poster/routes.py`
### 测试
- `single_2_3` 必须严格输出 1024×1536
- `single_9_16` 必须严格输出 1080×1920
- `long_1242_auto` 宽度严格为 1242高度等于内容测量值乘导出倍率
- 图表在导出图中非空;
- 服务端拒绝损坏 PNG、错误尺寸和不匹配 formatId 的文件;
- 导出失败不会覆盖上一版有效文件;
- 同一文档连续导出三次尺寸一致。
### 完成定义
UI 规格、renderDocument、PNG 真实尺寸和服务端记录四者一致。
## 阶段 5可靠性决策门0.5 人日评审)
完成阶段 4 后,使用至少 20 次真实或仿真导出评估:
- 浏览器最终合成成功率是否达到 99%
- 长图最大像素下是否出现黑图/空图/截断;
- 是否存在明确业务要求:用户提交后关闭页面,仍必须自动完成最终海报;
- worker/Docker 是否允许增加 Chromium 的资源成本。
只有满足以下任一条件,才进入服务端 Headless 阶段:
- 关闭页面后自动成品是硬需求;
- 浏览器导出成功率低于 99%
- 多端浏览器结果无法保持一致;
- 历史任务必须支持无人值守重新渲染。
## 阶段 6条件性服务端 Headless 渲染(额外 47 人日)
### 任务
- 评估 Playwright/Chromium 进入 worker 镜像的体积和内存;
- 建立只接受短时签名 token 的内部 render route
- 固定字体、浏览器版本、viewport、device scale 和 locale
- Celery 在背景素材完成后打开 render route生成最终 PNG
- 最终任务状态只有在 PNG 校验并落盘后才变为 done
- 增加超时、重试、幂等和旧成品保护;
- 保留浏览器本地导出作为人工应急能力。
### 完成定义
用户生成后立即关闭页面,任务仍能在历史记录中完成,并下载与预览一致的最终海报。
## 7. 测试计划
### 7.1 后端单元测试
新增或扩展:
- format registry 映射和拒绝规则;
- renderDocument 数据优先级;
- single/long 字段预算;
- template 兼容过滤;
- fallback 无文字;
- PNG 解码、尺寸和格式校验;
- schema v1/v2 兼容恢复。
建议文件:
- `tests/poster_format_registry_test.py`
- `tests/poster_render_document_test.py`
- `tests/poster_export_validation_test.py`
### 7.2 前端组件测试
当前前端未配置组件测试框架。阶段 3 开始前决定是否引入 Vitest若不引入至少用构建检查和 E2E 覆盖以下逻辑:
- 模式切换自动修正规格与模板;
- single 不渲染 forbidden sections
- long 高度随内容变化;
- export scale 计算;
- 资源未 ready 时禁止导出;
- 导出失败状态和重试。
### 7.3 端到端与视觉回归
固定浏览器、字体和 fixture验证
1. 新建海报;
2. 选择产品并恢复确认数据;
3. 切换 single/long
4. 选择兼容模板;
5. 生成背景;
6. 导出并下载;
7. 使用 Pillow 校验 PNG 元数据;
8. 与批准基准图做截图差异比较;
9. 刷新页面并重新下载;
10. 导出失败后旧成品仍可用。
### 7.4 性能边界
以像素总量而不是文件 MB 作为主要边界:
- 标准单图;
- 标准长图;
- 2 倍典型长图内容;
- 最大允许长图;
- 低内存移动设备只做预览,不要求在移动端导出最大长图时必须成功;如业务要求移动端导出,则服务端渲染直接升级为硬需求。
## 8. 兼容、灰度与回滚
### 8.1 旧记录
- `schemaVersion` 缺失视为 v1
- v1 记录继续使用 legacy renderer不用 v2 规则重新排版;
- v1 可下载文件保持原路径;
- 用户主动选择“升级并重新生成”时才转为 v2。
### 8.2 功能开关
增加项目级配置 `poster_render_v2`
- 关闭:继续使用旧布局和旧导出;
- 开启:新建记录使用 format registry、renderDocument v2 和新布局;
- 灰度:仅管理员/测试用户开启。
### 8.3 数据迁移
- 模板字段只做增量新增,不删除旧字段;
- 给现有模板补兼容默认值;
- 不重写旧 `document_json`
- 迁移脚本编号必须在实施时检查 `api/insurance/db/` 当前最大编号,避免与未提交的 `migrate_029.py`、`migrate_030.py` 冲突。
### 8.4 回滚
- 关闭 `poster_render_v2` 即可停止新链路;
- 新增字段保持 nullable旧代码可忽略
- 新导出文件使用新 revision 文件名,不覆盖旧文件;
- 任何阶段不得删除用户已有海报或背景素材。
## 9. 监控与诊断信息
每次导出至少记录:
```text
recordId
schemaVersion
formatId
layoutKey
logicalWidth/logicalHeight
requestedWidth/requestedHeight
actualWidth/actualHeight
pixelCount
blobByteSize
backgroundProvider/backgroundModel
backgroundRequestedSize/backgroundActualSize
renderDurationMs
uploadDurationMs
validationResult
failureStage/failureCode
```
首发观察指标:
- 背景生成成功率;
- 最终合成成功率;
- 最终文件验证通过率;
- 下载成功率;
- 各阶段 P50/P95 时长;
- 浏览器导出失败原因分布。
## 10. 文件级修改矩阵
| 文件/目录 | 计划改动 | 阶段 |
|---|---|---:|
| `api/insurance/poster/format_registry.py` | 统一格式规格与校验 | 1 |
| `api/insurance/poster/render_document_builder.py` | 构建唯一 renderDocument | 2 |
| `api/insurance/poster/content_builder.py` | 修正 case facts/product rules 合并 | 2 |
| `api/insurance/poster/image_generator.py` | asset size 严格映射、无文字 fallback | 1/2 |
| `api/insurance/poster/service.py` | format 校验、document v2、PNG 校验 | 1/2/4 |
| `api/insurance/poster/routes.py` | formats API、明确下载错误 | 1/4 |
| `api/insurance/generation/celery_tasks.py` | 使用 renderDocument 生成背景 | 2 |
| `api/insurance/models/poster_template_model.py` | 最少模板兼容字段 | 3 |
| `api/insurance/db/migrate_0xx.py` | 增量模板字段迁移 | 3 |
| `frontend/src/composables/usePosterWorkspace.ts` | formatId、renderDocument、v1/v2 恢复 | 1/2 |
| `frontend/src/utils/poster-api.ts` | formats 和 v2 document API | 1/2 |
| `frontend/src/utils/poster-exporter.ts` | 独立确定性导出器 | 4 |
| `frontend/src/components/poster/PosterCanvasRouter.vue` | 单图/长图布局路由 | 3 |
| `frontend/src/components/poster/single/` | 单图专用布局 | 3 |
| `frontend/src/components/poster/long/` | 长图布局与主题 token | 3 |
| `frontend/src/components/poster/workspace/PosterCreativePanel.vue` | 兼容格式和模板选择 | 1/3 |
| `frontend/src/components/poster/workspace/PosterStage.vue` | 预览与未缩放画布管理 | 3/4 |
| `frontend/src/pages/PosterPage.vue` | 简化为编排、状态与持久化 | 1/2/4 |
| `tests/poster_*` | 单元、契约、导出验证 | 全阶段 |
## 11. 明确不做的事项
为了控制改动范围,本期不做:
- 任意自定义宽高;
- A4/横版/方图;
- 所见即所得自由拖拽编辑器;
- 管理员可视化搭建模板;
- 自动生成多页拼版;
- 无真实案例支撑的长图自动分片;
- 在阶段 5 决策前直接引入 Chromium
- 重构无关 PPT、认证、产品推荐或基座代码。
## 12. 最终验收清单
只有以下项目全部通过,才能认为修复完成:
- [ ] 首发仅显示已确认的三种格式;
- [ ] 未知格式不会静默回退;
- [ ] CSS 逻辑尺寸与 PNG 输出尺寸明确分离;
- [ ] single 和 long 使用不同的画布组件与内容预算;
- [ ] 单图没有被 `overflow:hidden` 静默裁掉的内容;
- [ ] 长图内容结束后没有人为 `min-height` 空白;
- [ ] 模板只出现在兼容 mode/format 中;
- [ ] 模板主题贯穿所有章节,不再固定白底绿色;
- [ ] AI 和 fallback 都不绘制任何文案、数字或 Logo
- [ ] prompt、预览、导出读取同一 renderDocument
- [ ] 单图 PNG 像素严格符合规格;
- [ ] 长图 PNG 宽度严格符合规格,高度来自真实内容;
- [ ] 图表、字体和图片全部 ready 后才导出;
- [ ] 服务端能拒绝损坏或错误尺寸的 PNG
- [ ] 导出失败不会覆盖上一版有效成品;
- [ ] 历史记录能正确恢复 v1/v2 文档;
- [ ] 灰度开关与回滚路径验证通过;
- [ ] 是否引入服务端 Headless Chromium 已通过阶段 5 数据决策。
## 13. 推荐执行顺序
严格按以下顺序实施,不并行修改同一链路:
```text
复现与规格确认
→ 格式契约
→ 统一 renderDocument
→ 单图/长图布局
→ 确定性导出与服务端校验
→ 真实可靠性评估
→ 条件性服务端 Headless
```
前一阶段的完成定义未通过,不进入下一阶段。这样可以避免一边改模板、一边改尺寸、一边换渲染器,最终无法定位回归来源。

View File

@ -75,7 +75,8 @@ defineEmits<{
const isGenerating = computed(() =>
props.draft.taskStatus === 'submitting' ||
props.draft.taskStatus === 'queued' ||
props.draft.taskStatus === 'generating'
props.draft.taskStatus === 'generating' ||
props.draft.taskStatus === 'asset_loading'
)
const canAct = computed(() => {

View File

@ -6,28 +6,36 @@
</div>
<!-- 画布主体 -->
<div class="stage-canvas" :style="canvasStyle">
<!-- 始终保留可编辑 HTML 画布AI 结果只作为主视觉背景 -->
<PosterHtmlCanvas
ref="htmlCanvasRef"
:copy-content="draft.copyContent"
:parsed-fields="draft.parsedFields"
:color-scheme="draft.templateColorScheme"
:company-name="draft.productCompany"
:output-mode="draft.outputMode"
:export-size="draft.exportSize"
:features="defaultFeatures"
:hero-background="draft.backgroundCandidateUrl || draft.posterUrl"
:sections="draft.sections"
:editable="true"
@edit="onCanvasEdit"
/>
<div ref="stageCanvasRef" class="stage-canvas">
<div class="canvas-scale-shell" :style="canvasShellStyle">
<div class="canvas-scale-content" :style="canvasScaleStyle">
<!-- 始终保留可编辑 HTML 画布AI 结果只作为主视觉背景 -->
<PosterHtmlCanvas
ref="htmlCanvasRef"
:copy-content="draft.copyContent"
:parsed-fields="draft.parsedFields"
:color-scheme="draft.templateColorScheme"
:company-name="draft.productCompany"
:output-mode="draft.outputMode"
:export-size="draft.exportSize"
:features="defaultFeatures"
:hero-background="draft.backgroundCandidateUrl || draft.posterUrl"
:sections="draft.sections"
:editable="true"
@edit="onCanvasEdit"
/>
</div>
</div>
<!-- 生成中覆盖提示不卸载画布避免丢失当前编辑状态 -->
<div v-if="draft.taskStatus === 'queued' || draft.taskStatus === 'generating'" class="canvas-loading">
<div v-if="draft.taskStatus === 'queued' || draft.taskStatus === 'generating' || draft.taskStatus === 'asset_loading'" class="canvas-loading">
<el-progress type="circle" :percentage="draft.taskProgress" :width="120" />
<p class="loading-text">
{{ draft.taskStatus === 'queued' ? '任务排队中...' : '海报生成中,预计 10 到 30 秒' }}
{{ draft.taskStatus === 'queued'
? '任务排队中...'
: draft.taskStatus === 'asset_loading'
? '正在加载背景并准备可编辑预览...'
: '海报生成中,预计 10 到 30 秒' }}
</p>
</div>
@ -56,7 +64,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, nextTick, onBeforeUnmount, onMounted, watch } from 'vue'
import { CircleCloseFilled, RefreshRight } from '@element-plus/icons-vue'
import type { PosterDraft } from '@/composables/usePosterWorkspace'
import PosterHtmlCanvas from '@/components/poster/long/PosterHtmlCanvas.vue'
@ -70,8 +78,14 @@ const emit = defineEmits<{
}>()
const stageRef = ref<HTMLElement | null>(null)
const stageCanvasRef = ref<HTMLElement | null>(null)
const htmlCanvasRef = ref<InstanceType<typeof PosterHtmlCanvas> | null>(null)
const zoom = ref('fit')
const fitScale = ref(1)
const canvasWidth = ref(1080)
const canvasHeight = ref(0)
let stageObserver: ResizeObserver | null = null
let canvasObserver: ResizeObserver | null = null
const zoomOptions = [
{ label: '适应', value: 'fit' },
@ -94,19 +108,58 @@ function onCanvasEdit(field: string, value: string) {
emit('edit', field, value)
}
function updateCanvasMetrics() {
const viewport = stageCanvasRef.value
const canvas = htmlCanvasRef.value?.canvasRef
if (!viewport || !canvas) return
canvasWidth.value = canvas.offsetWidth || 1080
canvasHeight.value = canvas.scrollHeight || canvas.offsetHeight
const availableWidth = Math.max(240, viewport.clientWidth - 64)
fitScale.value = Math.min(1, availableWidth / canvasWidth.value)
}
async function bindObservers() {
await nextTick()
updateCanvasMetrics()
if (stageCanvasRef.value) {
stageObserver = new ResizeObserver(updateCanvasMetrics)
stageObserver.observe(stageCanvasRef.value)
}
const canvas = htmlCanvasRef.value?.canvasRef
if (canvas) {
canvasObserver = new ResizeObserver(updateCanvasMetrics)
canvasObserver.observe(canvas)
}
}
onMounted(bindObservers)
onBeforeUnmount(() => {
stageObserver?.disconnect()
canvasObserver?.disconnect()
})
watch(
() => [props.draft.exportSize, props.draft.outputMode, props.draft.sections],
() => nextTick(updateCanvasMetrics),
{ deep: true },
)
watch(zoom, async () => {
await nextTick()
updateCanvasMetrics()
if (stageCanvasRef.value) stageCanvasRef.value.scrollTop = 0
})
defineExpose({ htmlCanvasRef })
const canvasStyle = computed(() => {
const base: Record<string, string> = {}
if (zoom.value === 'fit') {
base.maxHeight = '100%'
base.maxWidth = '100%'
} else if (zoom.value === '50') {
base.maxHeight = '50%'
base.maxWidth = '50%'
}
return base
})
const scale = computed(() => zoom.value === 'fit' ? fitScale.value : Number(zoom.value) / 100)
const canvasShellStyle = computed(() => ({
width: `${Math.max(1, canvasWidth.value * scale.value)}px`,
height: `${Math.max(1, canvasHeight.value * scale.value)}px`,
}))
const canvasScaleStyle = computed(() => ({
transform: `scale(${scale.value})`,
}))
</script>
<style scoped>
@ -115,7 +168,7 @@ const canvasStyle = computed(() => {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
justify-content: flex-start;
overflow: hidden;
position: relative;
border: 1px solid var(--poster-border);
@ -142,12 +195,26 @@ const canvasStyle = computed(() => {
.stage-canvas {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: center;
padding: 32px;
width: 100%;
height: 100%;
overflow: auto;
position: relative;
scrollbar-gutter: stable;
}
.canvas-scale-shell {
position: relative;
flex: 0 0 auto;
}
.canvas-scale-content {
position: absolute;
top: 0;
left: 0;
transform-origin: top left;
}
@media (max-width: 767px) {

View File

@ -50,7 +50,7 @@ export interface PosterDraft {
} | null
aiRawContent: any
// 生成任务
taskStatus: 'idle' | 'submitting' | 'queued' | 'generating' | 'done' | 'failed'
taskStatus: 'idle' | 'submitting' | 'queued' | 'generating' | 'asset_loading' | 'done' | 'failed'
taskProgress: number
taskError: string | null
taskRecordId: number | null

View File

@ -245,6 +245,10 @@ function stopPolling() {
async function onGenerate() {
const d = ws.draft.value
if (['submitting', 'queued', 'generating', 'asset_loading'].includes(d.taskStatus)) {
return
}
if (!d.templateId) {
ElMessage.warning('请选择海报模板')
return
@ -332,7 +336,11 @@ function startPolling(id: number) {
const progress = record?.taskProgress ?? record?.task_progress ?? 0
const error = record?.taskError || record?.task_error || null
ws.draft.value.taskStatus = status === 'running' ? 'generating' : status
if (status === 'running' || status === 'pending') {
ws.draft.value.taskStatus = status === 'pending' ? 'queued' : 'generating'
} else if (status === 'failed') {
ws.draft.value.taskStatus = 'failed'
}
ws.draft.value.taskProgress = progress
ws.draft.value.taskError = error
@ -340,12 +348,16 @@ function startPolling(id: number) {
if (status === 'done' || status === 'failed') {
stopPolling()
if (status === 'done') {
ws.draft.value.taskStatus = 'asset_loading'
try {
const blob = await posterApi.downloadBackground(id)
await applyGeneratedBackground(blob, id)
const ready = await applyGeneratedBackground(blob, id)
if (!ready) throw new Error('海报合成保存失败')
ws.draft.value.taskStatus = 'done'
ws.draft.value.taskProgress = 100
} catch {
ws.draft.value.taskStatus = 'failed'
ws.draft.value.taskError = '图片下载失败'
ws.draft.value.taskError = '背景加载或海报合成失败,请重试'
}
}
}
@ -391,13 +403,48 @@ watch(
{ deep: true },
)
function loadImage(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const image = new Image()
const timeout = window.setTimeout(() => reject(new Error(`图片加载超时: ${url}`)), 15000)
image.onload = () => {
window.clearTimeout(timeout)
resolve()
}
image.onerror = () => {
window.clearTimeout(timeout)
reject(new Error(`图片加载失败: ${url}`))
}
image.src = url
})
}
async function waitForCanvasAssets(canvas: HTMLElement) {
await document.fonts?.ready
const imageElements = Array.from(canvas.querySelectorAll('img'))
await Promise.all(imageElements.map(async image => {
if (!image.complete || image.naturalWidth === 0) await image.decode()
}))
const backgroundUrls = new Set<string>()
const elements = [canvas, ...Array.from(canvas.querySelectorAll<HTMLElement>('*'))]
const pattern = /url\((['"]?)(.*?)\1\)/g
for (const element of elements) {
const background = getComputedStyle(element).backgroundImage
for (const match of background.matchAll(pattern)) {
if (match[2]) backgroundUrls.add(match[2])
}
}
await Promise.all(Array.from(backgroundUrls).map(loadImage))
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))
}
async function renderComposite(): Promise<Blob | null> {
const htmlCanvas = stageRef.value?.htmlCanvasRef
if (htmlCanvas?.canvasRef) {
try {
const { toPng } = await import('html-to-image')
await document.fonts?.ready
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))
await waitForCanvasAssets(htmlCanvas.canvasRef)
const dataUrl = await toPng(htmlCanvas.canvasRef, {
quality: 0.95,
pixelRatio: 2,
@ -424,16 +471,28 @@ async function saveCompositeToServer(recordId: number) {
}
}
async function applyGeneratedBackground(blob: Blob, recordId: number) {
async function applyGeneratedBackground(blob: Blob, recordId: number, persist = true): Promise<boolean> {
const d = ws.draft.value
const nextUrl = URL.createObjectURL(blob)
try {
await loadImage(nextUrl)
} catch {
URL.revokeObjectURL(nextUrl)
return false
}
if (d.posterUrl) {
if (d.backgroundCandidateUrl) URL.revokeObjectURL(d.backgroundCandidateUrl)
d.backgroundCandidateUrl = nextUrl
return
await nextTick()
const canvas = stageRef.value?.htmlCanvasRef?.canvasRef
if (canvas) await waitForCanvasAssets(canvas)
return true
}
d.posterUrl = nextUrl
await saveCompositeToServer(recordId)
await nextTick()
const canvas = stageRef.value?.htmlCanvasRef?.canvasRef
if (canvas) await waitForCanvasAssets(canvas)
return persist ? saveCompositeToServer(recordId) : true
}
async function acceptBackgroundCandidate() {
@ -559,15 +618,18 @@ async function restoreWorkspace() {
//
if (task.status === 'done') {
d.taskStatus = 'done'
d.taskStatus = 'asset_loading'
d.taskRecordId = Number(task.workspaceId) || d.taskRecordId
//
const pollId = d.taskRecordId || ws.recordId.value
if (pollId) {
try {
const blob = await posterApi.downloadBackground(pollId)
d.posterUrl = URL.createObjectURL(blob)
const ready = await applyGeneratedBackground(blob, pollId, false)
d.taskStatus = ready ? 'done' : 'failed'
if (!ready) d.taskError = '海报图片加载失败,请刷新页面重试'
} catch {
d.taskStatus = 'failed'
d.taskError = '海报图片加载失败,请刷新页面重试'
}
}
@ -601,9 +663,12 @@ async function restoreWorkspace() {
d.taskError = record?.taskError || record?.task_error || null
if (realStatus === 'done') {
d.taskStatus = 'asset_loading'
try {
const blob = await posterApi.downloadBackground(pollId)
d.posterUrl = URL.createObjectURL(blob)
const ready = await applyGeneratedBackground(blob, pollId, false)
d.taskStatus = ready ? 'done' : 'failed'
if (!ready) d.taskError = '海报图片加载失败,请刷新页面重试'
} catch {
d.taskStatus = 'failed'
d.taskError = '海报图片加载失败,请刷新页面重试'
@ -642,14 +707,18 @@ function startPollingUnified(taskId: string) {
if (status === 'done') {
stopPolling()
ws.draft.value.taskStatus = 'done'
ws.draft.value.taskStatus = 'asset_loading'
const pollId = ws.draft.value.taskRecordId || ws.recordId.value
if (pollId) {
try {
const blob = await posterApi.downloadBackground(pollId)
await applyGeneratedBackground(blob, pollId)
const ready = await applyGeneratedBackground(blob, pollId)
if (!ready) throw new Error('海报合成保存失败')
ws.draft.value.taskStatus = 'done'
ws.draft.value.taskProgress = 100
} catch {
ws.draft.value.taskError = '图片下载失败'
ws.draft.value.taskStatus = 'failed'
ws.draft.value.taskError = '背景加载或海报合成失败,请重试'
}
}
} else if (status === 'failed' || status === 'cancelled') {

View File

@ -36,11 +36,22 @@ def test_template_asset_resolver_blocks_parent_traversal():
resolve_template_asset("builtin://../outside.pptx")
def test_renderer_can_reuse_builtin_template_master(tmp_path):
@pytest.mark.parametrize(
("asset_id", "expect_picture"),
[
("builtin://single_savings.pptx", False),
("builtin://multi_savings_comparison.pptx", False),
("builtin://savings_iul_comprehensive.pptx", True),
],
)
def test_renderer_uses_builtin_template_frames_without_sample_copy(
tmp_path, asset_id, expect_picture
):
from insurance.ppt.scripts.fast_pptx_renderer import render_deck
from pptx import Presentation
source_path = resolve_template_asset("builtin://single_savings.pptx")
output_path = tmp_path / "rendered.pptx"
source_path = resolve_template_asset(asset_id)
output_path = tmp_path / f"{Path(source_path).stem}-rendered.pptx"
result = render_deck({
"products": [{
"kind": "savings",
@ -59,9 +70,56 @@ def test_renderer_can_reuse_builtin_template_master(tmp_path):
{"pageType": "cover", "title": "测试模板"},
{"pageType": "closing", "title": "下一步"},
],
"templateConfig": {"sourceTemplatePath": source_path},
"templateConfig": {
"sourceTemplatePath": source_path,
"slidesConfig": [
{"pageType": "cover", "sourceSlide": 1},
{"pageType": "closing", "sourceSlide": 2},
],
},
}, str(output_path), "deepblue")
assert result["ok"] is True
assert result["slides"] == 2
assert result["rendererMode"] == "clone-edit-v2"
assert result["templateFrameMap"] == [1, 2]
assert output_path.is_file()
presentation = Presentation(str(output_path))
all_text = "\n".join(
shape.text
for slide in presentation.slides
for shape in slide.shapes
if getattr(shape, "has_text_frame", False)
)
assert "测试模板" in all_text
assert "US$500,000" not in all_text
pictures = sum(
1
for slide in presentation.slides
for shape in slide.shapes
if shape.shape_type == 13
)
assert (pictures > 0) is expect_picture
def test_renderer_rejects_uploaded_template_with_too_few_frames(tmp_path):
from insurance.ppt.scripts.fast_pptx_renderer import render_deck
source_path = resolve_template_asset("builtin://single_savings.pptx")
output_path = tmp_path / "too-many-slides.pptx"
scenario_slides = [
{"pageType": "guidance", "title": f"{index}"}
for index in range(21)
]
with pytest.raises(ValueError, match="只有 20 页"):
render_deck({
"products": [{"kind": "savings"}],
"company": {},
"scenarioSlides": scenario_slides,
"templateConfig": {
"sourceTemplatePath": source_path,
"slidesConfig": [{"pageType": "guidance"}] * 20,
},
}, str(output_path), "deepblue")