From e1770be4cc51497513cd875ff66231efd9eadfde Mon Sep 17 00:00:00 2001 From: wsb1224 Date: Fri, 31 Jul 2026 22:05:57 +0800 Subject: [PATCH] =?UTF-8?q?=E7=8E=B0=E5=9C=A8=E9=80=89=E6=8B=A9=E2=80=9C?= =?UTF-8?q?=E9=95=BF=E5=9B=BE=E6=B5=B7=E6=8A=A5=E2=80=9D=E5=90=8E=EF=BC=8C?= =?UTF-8?q?=E5=9C=A8=E2=80=9C=E6=B5=B7=E6=8A=A5=E5=B0=BA=E5=AF=B8=E2=80=9D?= =?UTF-8?q?=E4=B8=8B=E6=96=B9=E5=8F=AF=E4=BB=A5=E5=88=87=E6=8D=A2=EF=BC=9A?= =?UTF-8?q?=20=E8=87=AA=E5=8A=A8=E9=AB=98=E5=BA=A6=20=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E9=AB=98=E5=BA=A6=EF=BC=9A1920=E2=80=9330000px?= =?UTF-8?q?=EF=BC=8C=E9=BB=98=E8=AE=A4=204500px=20=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E9=AB=98=E5=BA=A6=E4=BC=9A=E5=90=8C=E6=AD=A5=E5=BD=B1?= =?UTF-8?q?=E5=93=8D=E7=94=BB=E5=B8=83=E9=A2=84=E8=A7=88=E3=80=81=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=BF=AB=E7=85=A7=E3=80=81renderDocument=E3=80=81?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E6=81=A2=E5=A4=8D=E5=92=8C=E6=9C=80=E7=BB=88?= =?UTF-8?q?=20PNG=E3=80=82=E8=8B=A5=E5=86=85=E5=AE=B9=E8=B6=85=E8=BF=87?= =?UTF-8?q?=E6=8C=87=E5=AE=9A=E9=AB=98=E5=BA=A6=EF=BC=8C=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E4=BC=9A=E9=98=BB=E6=AD=A2=E5=AF=BC=E5=87=BA=E5=B9=B6=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E6=9C=80=E4=BD=8E=E6=89=80=E9=9C=80=E9=AB=98=E5=BA=A6?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E4=BC=9A=E9=9D=99=E9=BB=98=E8=A3=81=E6=8E=89?= =?UTF-8?q?=E5=BA=95=E9=83=A8=E5=86=85=E5=AE=B9=E3=80=82=20=E5=85=B3?= =?UTF-8?q?=E9=94=AE=E4=BF=AE=E6=94=B9=EF=BC=9A=20[PosterCreativePanel.vue?= =?UTF-8?q?=20(line=2051)](D:/work/code/python/coding/baodanagent/frontend?= =?UTF-8?q?/src/components/poster/workspace/PosterCreativePanel.vue:51)=20?= =?UTF-8?q?[PosterHtmlCanvas.vue=20(line=201)](D:/work/code/python/coding/?= =?UTF-8?q?baodanagent/frontend/src/components/poster/long/PosterHtmlCanva?= =?UTF-8?q?s.vue:1)=20[poster-exporter.ts=20(line=2095)](D:/work/code/pyth?= =?UTF-8?q?on/coding/baodanagent/frontend/src/utils/poster-exporter.ts:95)?= =?UTF-8?q?=20[format=5Fregistry.py=20(line=2017)](D:/work/code/python/cod?= =?UTF-8?q?ing/baodanagent/api/insurance/poster/format=5Fregistry.py:17)?= =?UTF-8?q?=20[render=5Fvalidation.py=20(line=2013)](D:/work/code/python/c?= =?UTF-8?q?oding/baodanagent/api/insurance/poster/render=5Fvalidation.py:1?= =?UTF-8?q?3)=20=E9=AA=8C=E8=AF=81=E7=BB=93=E6=9E=9C=EF=BC=9A33=20?= =?UTF-8?q?=E9=A1=B9=E6=B5=B7=E6=8A=A5=E6=B5=8B=E8=AF=95=E9=80=9A=E8=BF=87?= =?UTF-8?q?=E3=80=81Vue=20=E7=B1=BB=E5=9E=8B=E6=A3=80=E6=9F=A5=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E3=80=81=E7=94=9F=E4=BA=A7=E6=9E=84=E5=BB=BA=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E3=80=81UI=20=E9=9D=99=E6=80=81=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/insurance/poster/format_registry.py | 25 ++++ .../poster/render_document_builder.py | 11 +- api/insurance/poster/render_validation.py | 11 +- api/insurance/poster/service.py | 32 +++++- .../poster/long/PosterHtmlCanvas.vue | 21 +++- .../poster/workspace/PosterConfigRail.vue | 23 +++- .../poster/workspace/PosterCreativePanel.vue | 108 +++++++++++++++++- .../poster/workspace/PosterDeliveryPanel.vue | 6 +- .../poster/workspace/PosterStage.vue | 1 + .../src/composables/usePosterWorkspace.ts | 31 ++++- frontend/src/pages/PosterPage.vue | 43 ++++++- frontend/src/utils/poster-api.ts | 1 + frontend/src/utils/poster-exporter.ts | 22 +++- tests/poster_format_registry_test.py | 11 ++ tests/poster_render_document_test.py | 19 +++ tests/poster_render_validation_test.py | 19 +++ 16 files changed, 364 insertions(+), 20 deletions(-) diff --git a/api/insurance/poster/format_registry.py b/api/insurance/poster/format_registry.py index dbd6a3c..8855086 100644 --- a/api/insurance/poster/format_registry.py +++ b/api/insurance/poster/format_registry.py @@ -10,6 +10,10 @@ class PosterFormatError(ValueError): """海报格式不受支持。""" +CUSTOM_LONG_HEIGHT_MIN = 1920 +CUSTOM_LONG_HEIGHT_MAX = 30000 + + _FORMATS = ( { "id": "single_2_3", @@ -61,6 +65,27 @@ def list_poster_formats() -> list[dict]: return deepcopy(list(_FORMATS)) +def resolve_custom_long_height(value, output_mode: str) -> int | None: + """解析长图自定义输出高度;None 表示内容驱动的自动高度。""" + if value is None or value == "" or value == "auto": + return None + if output_mode != "long": + raise PosterFormatError("自定义高度仅长图模式可用") + if isinstance(value, bool): + raise PosterFormatError("长图高度必须是整数") + try: + height = int(value) + except (TypeError, ValueError) as exc: + raise PosterFormatError("长图高度必须是整数") from exc + if isinstance(value, float) and not value.is_integer(): + raise PosterFormatError("长图高度必须是整数") + if not CUSTOM_LONG_HEIGHT_MIN <= height <= CUSTOM_LONG_HEIGHT_MAX: + raise PosterFormatError( + f"长图高度必须在 {CUSTOM_LONG_HEIGHT_MIN} 到 {CUSTOM_LONG_HEIGHT_MAX}px 之间" + ) + return height + + def resolve_poster_format( format_id: str | None = None, legacy_size: str | None = None, diff --git a/api/insurance/poster/render_document_builder.py b/api/insurance/poster/render_document_builder.py index 0100fcc..90b114c 100644 --- a/api/insurance/poster/render_document_builder.py +++ b/api/insurance/poster/render_document_builder.py @@ -23,6 +23,7 @@ def build_render_document( product_rules: dict, plan_type: str, compliance_revision: str, + custom_height: int | None = None, sections: list[dict] | None = None, brand: dict | None = None, ) -> dict: @@ -45,6 +46,12 @@ def build_render_document( theme = _build_theme(template.get("colorScheme") or {}) normalized_sections = _normalize_sections(sections, format_spec["outputMode"], content) + requested_output = deepcopy(format_spec["output"]) + export_size = format_spec["exportSize"] + if custom_height is not None: + requested_output["height"] = custom_height + export_size = f"{requested_output['width']}x{custom_height}" + return { "schemaVersion": 2, "revision": 1, @@ -55,8 +62,8 @@ def build_render_document( ), "templateId": template.get("id"), "layout": deepcopy(format_spec["layout"]), - "requestedOutput": deepcopy(format_spec["output"]), - "exportSize": format_spec["exportSize"], + "requestedOutput": requested_output, + "exportSize": export_size, "copy": deepcopy(copy_content or {}), "facts": facts, "summary": content["summary"], diff --git a/api/insurance/poster/render_validation.py b/api/insurance/poster/render_validation.py index a801c9f..2e8b64b 100644 --- a/api/insurance/poster/render_validation.py +++ b/api/insurance/poster/render_validation.py @@ -10,7 +10,11 @@ class PosterRenderValidationError(ValueError): """最终海报文件不符合格式契约。""" -def validate_rendered_png(image_bytes: bytes, format_id: str) -> dict: +def validate_rendered_png( + image_bytes: bytes, + format_id: str, + expected_height: int | None = None, +) -> dict: """完整解码 PNG,并验证其真实像素尺寸。""" format_spec = next( (item for item in list_poster_formats() if item["id"] == format_id), @@ -42,6 +46,11 @@ def validate_rendered_png(image_bytes: bytes, format_id: str) -> dict: f"海报尺寸不匹配,应为 {expected['width']}×{expected['height']}," f"实际为 {width}×{height}" ) + if expected["height"] is None and expected_height is not None and height != expected_height: + raise PosterRenderValidationError( + f"长图自定义高度不匹配,应为 {expected['width']}×{expected_height}," + f"实际为 {width}×{height}" + ) if expected["height"] is None and not 1 <= height <= 32767: raise PosterRenderValidationError("长图高度超出浏览器安全范围") diff --git a/api/insurance/poster/service.py b/api/insurance/poster/service.py index 26c0991..2e44cb7 100644 --- a/api/insurance/poster/service.py +++ b/api/insurance/poster/service.py @@ -265,17 +265,26 @@ class PosterService: if not template_id: return {"code": 1001, "message": "请选择海报模板", "data": None} - from insurance.poster.format_registry import PosterFormatError, resolve_poster_format + from insurance.poster.format_registry import ( + PosterFormatError, + resolve_custom_long_height, + resolve_poster_format, + ) try: format_spec = resolve_poster_format( format_id=data.get("formatId"), legacy_size=data.get("size"), output_mode=output_mode, ) + custom_height = resolve_custom_long_height(data.get("customHeight"), output_mode) except PosterFormatError as exc: return {"code": 1002, "message": str(exc), "data": None} format_id = format_spec["id"] - size = format_spec["exportSize"] + size = ( + f"{format_spec['output']['width']}x{custom_height}" + if custom_height is not None + else format_spec["exportSize"] + ) from insurance.poster.compliance import check_copy_compliance compliance_result = check_copy_compliance(copy_content) @@ -329,6 +338,7 @@ class PosterService: product_rules=rules, plan_type=context.get("planType") or "other", compliance_revision=compliance_result["revision"], + custom_height=custom_height, sections=data.get("sections"), brand={ "productName": product_data.get("displayName", ""), @@ -381,6 +391,7 @@ class PosterService: "referenceImages": ref_images_meta, "outputMode": output_mode, "formatId": format_id, + "customHeight": custom_height, "compliance": compliance_result, }, ensure_ascii=False), document_json=json.dumps(render_document, ensure_ascii=False), @@ -495,8 +506,18 @@ class PosterService: PosterRenderValidationError, validate_rendered_png, ) + requested_output = document.get("requestedOutput") or {} + expected_height = ( + requested_output.get("height") + if format_id == "long_1242_auto" + else None + ) try: - actual_output = validate_rendered_png(image_bytes, format_id) + actual_output = validate_rendered_png( + image_bytes, + format_id, + expected_height=expected_height, + ) except PosterRenderValidationError as exc: return {"code": 1002, "message": str(exc), "data": None} @@ -524,6 +545,10 @@ class PosterService: document["validationResult"] = "passed" record.document_json = json.dumps(document, ensure_ascii=False) record.document_revision = revision + if format_id == "long_1242_auto": + record.export_size = ( + f"1242x{expected_height}" if expected_height is not None else "1242xauto" + ) record.export_url = filepath record.export_format = "png" record.task_status = "done" @@ -533,6 +558,7 @@ class PosterService: "actualOutput": actual_output, "byteSize": len(image_bytes), "validationResult": "passed", + "customHeight": expected_height, }) record.extra_data = json.dumps(extra_data, ensure_ascii=False) record.draft_revision = (record.draft_revision or 1) + 1 diff --git a/frontend/src/components/poster/long/PosterHtmlCanvas.vue b/frontend/src/components/poster/long/PosterHtmlCanvas.vue index ad85d61..dd2fbce 100644 --- a/frontend/src/components/poster/long/PosterHtmlCanvas.vue +++ b/frontend/src/components/poster/long/PosterHtmlCanvas.vue @@ -1,5 +1,11 @@