现在选择“长图海报”后,在“海报尺寸”下方可以切换:
自动高度 自定义高度:1920–30000px,默认 4500px 自定义高度会同步影响画布预览、任务快照、renderDocument、历史恢复和最终 PNG。若内容超过指定高度,系统会阻止导出并提示最低所需高度,不会静默裁掉底部内容。 关键修改: [PosterCreativePanel.vue (line 51)](D:/work/code/python/coding/baodanagent/frontend/src/components/poster/workspace/PosterCreativePanel.vue:51) [PosterHtmlCanvas.vue (line 1)](D:/work/code/python/coding/baodanagent/frontend/src/components/poster/long/PosterHtmlCanvas.vue:1) [poster-exporter.ts (line 95)](D:/work/code/python/coding/baodanagent/frontend/src/utils/poster-exporter.ts:95) [format_registry.py (line 17)](D:/work/code/python/coding/baodanagent/api/insurance/poster/format_registry.py:17) [render_validation.py (line 13)](D:/work/code/python/coding/baodanagent/api/insurance/poster/render_validation.py:13) 验证结果:33 项海报测试通过、Vue 类型检查通过、生产构建通过、UI 静态检查通过
This commit is contained in:
parent
31c41ec7e9
commit
e1770be4cc
@ -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,
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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("长图高度超出浏览器安全范围")
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<div ref="canvasRef" class="poster-html-canvas" :class="modeClass" :style="canvasStyle">
|
||||
<div
|
||||
ref="canvasRef"
|
||||
class="poster-html-canvas"
|
||||
:class="modeClass"
|
||||
:style="canvasStyle"
|
||||
:data-custom-output-height="customOutputHeight || undefined"
|
||||
>
|
||||
<template v-for="section in visibleSections" :key="section.id">
|
||||
<PosterHero
|
||||
v-if="section.id === 'hero'"
|
||||
@ -63,6 +69,7 @@ const props = defineProps<{
|
||||
companyName?: string
|
||||
outputMode: 'single' | 'long'
|
||||
formatId: string
|
||||
customOutputHeight?: number | null
|
||||
features?: Array<{ title: string; summary?: string; icon?: string }>
|
||||
benefits?: Array<{
|
||||
year: number
|
||||
@ -105,7 +112,13 @@ const canvasStyle = computed(() => {
|
||||
'--poster-text': theme?.text || '#17231f',
|
||||
'--poster-muted': theme?.muted || '#64716d',
|
||||
} as CSSProperties
|
||||
if (props.formatId === 'long_1242_auto') return { ...variables, width: '414px' }
|
||||
if (props.formatId === 'long_1242_auto') {
|
||||
return {
|
||||
...variables,
|
||||
width: '414px',
|
||||
...(props.customOutputHeight ? { height: `${props.customOutputHeight / 3}px` } : {}),
|
||||
}
|
||||
}
|
||||
if (props.formatId === 'single_9_16') return { ...variables, width: '540px', height: '960px' }
|
||||
return { ...variables, width: '512px', height: '768px' }
|
||||
})
|
||||
@ -149,4 +162,8 @@ defineExpose({ canvasRef })
|
||||
.poster--long {
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.poster--long > :deep(*) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -38,10 +38,13 @@
|
||||
:scenario="draft.scenario"
|
||||
:output-mode="draft.outputMode"
|
||||
:format-id="draft.formatId"
|
||||
:long-height-mode="draft.longHeightMode"
|
||||
:custom-long-height="draft.customLongHeight"
|
||||
:template-id="draft.templateId"
|
||||
:template-name="draft.templateName"
|
||||
@update:scenario="$emit('update:draft', { scenario: $event })"
|
||||
@update:format="$emit('update:draft', { formatId: $event.id, outputMode: $event.outputMode, exportSize: $event.exportSize })"
|
||||
@update:format="onFormatUpdate"
|
||||
@update:height="onHeightUpdate"
|
||||
@update:template="$emit('update:draft', { templateId: $event.id, templateName: $event.name, templateColorScheme: $event.colorScheme || null })"
|
||||
/>
|
||||
|
||||
@ -104,6 +107,24 @@ function onProductSourceUpdate(source: {
|
||||
})
|
||||
}
|
||||
|
||||
function onFormatUpdate(format: { id: string; outputMode: 'single' | 'long'; exportSize: string }) {
|
||||
emit('update:draft', {
|
||||
formatId: format.id,
|
||||
outputMode: format.outputMode,
|
||||
exportSize: format.outputMode === 'long' && props.draft.longHeightMode === 'custom'
|
||||
? `1242x${props.draft.customLongHeight}`
|
||||
: format.exportSize,
|
||||
})
|
||||
}
|
||||
|
||||
function onHeightUpdate(value: { mode: 'auto' | 'custom'; height: number }) {
|
||||
emit('update:draft', {
|
||||
longHeightMode: value.mode,
|
||||
customLongHeight: value.height,
|
||||
exportSize: value.mode === 'custom' ? `1242x${value.height}` : '1242xauto',
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openProductSelector: () => productPanelRef.value?.openSelector(),
|
||||
openSourceUpload: () => sourcePanelRef.value?.openUpload(),
|
||||
|
||||
@ -65,6 +65,39 @@
|
||||
<span class="preset-size">{{ formatSizeLabel(preset) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="outputMode === 'long'" class="height-settings">
|
||||
<div class="height-toggle" role="group" aria-label="长图高度模式">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: longHeightMode === 'auto' }"
|
||||
@click="onHeightModeChange('auto')"
|
||||
>自动高度</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: longHeightMode === 'custom' }"
|
||||
@click="onHeightModeChange('custom')"
|
||||
>自定义高度</button>
|
||||
</div>
|
||||
<div v-if="longHeightMode === 'custom'" class="custom-height-row">
|
||||
<el-input-number
|
||||
:model-value="customLongHeight"
|
||||
:min="1920"
|
||||
:max="30000"
|
||||
:step="100"
|
||||
:precision="0"
|
||||
controls-position="right"
|
||||
aria-label="长图输出高度"
|
||||
@update:model-value="onCustomHeightChange"
|
||||
/>
|
||||
<span class="height-unit">px</span>
|
||||
</div>
|
||||
<p class="height-hint">
|
||||
{{ longHeightMode === 'auto'
|
||||
? '根据实际内容自动延长,适合完整方案。'
|
||||
: '范围 1920–30000px;高度不足时导出会提示所需最小值,不会静默裁切。' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模板选择 -->
|
||||
@ -145,6 +178,8 @@ const props = defineProps<{
|
||||
scenario: string
|
||||
outputMode: 'single' | 'long'
|
||||
formatId: string
|
||||
longHeightMode: 'auto' | 'custom'
|
||||
customLongHeight: number
|
||||
templateId: number | null
|
||||
templateName: string
|
||||
}>()
|
||||
@ -152,6 +187,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
'update:scenario': [value: string]
|
||||
'update:format': [value: PosterFormatSpec]
|
||||
'update:height': [value: { mode: 'auto' | 'custom'; height: number }]
|
||||
'update:template': [template: { id: number; name: string; colorScheme?: any }]
|
||||
}>()
|
||||
|
||||
@ -223,9 +259,20 @@ function selectMode(mode: 'single' | 'long') {
|
||||
}
|
||||
|
||||
function formatSizeLabel(format: PosterFormatSpec) {
|
||||
if (format.outputMode === 'long' && props.longHeightMode === 'custom') {
|
||||
return `${format.output.width}×${props.customLongHeight}`
|
||||
}
|
||||
return `${format.output.width}×${format.output.height ?? '自动高度'}`
|
||||
}
|
||||
|
||||
function onHeightModeChange(mode: 'auto' | 'custom') {
|
||||
emit('update:height', { mode, height: props.customLongHeight || 4500 })
|
||||
}
|
||||
|
||||
function onCustomHeightChange(value: number | undefined) {
|
||||
emit('update:height', { mode: 'custom', height: value || 4500 })
|
||||
}
|
||||
|
||||
// ── 模板选择 ──────────────────────────
|
||||
function onSelectTemplate(t: any) {
|
||||
emit('update:template', {
|
||||
@ -390,7 +437,7 @@ watch(() => [props.templateId, props.outputMode, props.formatId], ensureValidTem
|
||||
/* ── 尺寸预设 ──────────────────────── */
|
||||
.size-presets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@ -428,6 +475,65 @@ watch(() => [props.templateId, props.outputMode, props.formatId], ensureValidTem
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.height-settings {
|
||||
margin-top: 4px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--poster-border);
|
||||
}
|
||||
|
||||
.height-toggle {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 3px;
|
||||
border-radius: 8px;
|
||||
background: #eef3f0;
|
||||
}
|
||||
|
||||
.height-toggle button {
|
||||
min-height: 30px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
color: var(--poster-muted);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.height-toggle button.active {
|
||||
color: var(--poster-accent);
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 7px rgb(24 61 44 / 10%);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.height-toggle button:focus-visible {
|
||||
outline: 2px solid var(--poster-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.custom-height-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.custom-height-row :deep(.el-input-number) {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.height-unit {
|
||||
color: var(--poster-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.height-hint {
|
||||
margin: 7px 0 0;
|
||||
color: var(--poster-muted);
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── 模板缩略图网格 ────────────────── */
|
||||
.template-grid {
|
||||
display: grid;
|
||||
|
||||
@ -58,7 +58,11 @@ defineEmits<{ 'update:compliance-confirmed': [value: boolean] }>()
|
||||
|
||||
const sizeText = computed(() => {
|
||||
if (props.draft.formatId === 'single_9_16') return '竖屏单图 1080×1920'
|
||||
if (props.draft.formatId === 'long_1242_auto') return '完整方案长图 1242×自动高度'
|
||||
if (props.draft.formatId === 'long_1242_auto') {
|
||||
return props.draft.longHeightMode === 'custom'
|
||||
? `完整方案长图 1242×${props.draft.customLongHeight}`
|
||||
: '完整方案长图 1242×自动高度'
|
||||
}
|
||||
return '竖版单图 1024×1536'
|
||||
})
|
||||
|
||||
|
||||
@ -32,6 +32,7 @@
|
||||
:company-name="renderCompanyName"
|
||||
:output-mode="draft.outputMode"
|
||||
:format-id="draft.formatId"
|
||||
:custom-output-height="draft.longHeightMode === 'custom' ? draft.customLongHeight : null"
|
||||
:features="renderFeatures"
|
||||
:benefits="renderBenefits"
|
||||
:hero-background="draft.backgroundCandidateUrl || draft.posterUrl"
|
||||
|
||||
@ -59,6 +59,8 @@ export interface PosterDraft {
|
||||
outputMode: 'single' | 'long'
|
||||
formatId: string
|
||||
exportSize: string
|
||||
longHeightMode: 'auto' | 'custom'
|
||||
customLongHeight: number
|
||||
templateId: number | null
|
||||
templateName: string
|
||||
templateColorScheme: PosterTheme | null
|
||||
@ -112,6 +114,8 @@ function createEmptyDraft(): PosterDraft {
|
||||
outputMode: 'single',
|
||||
formatId: 'single_2_3',
|
||||
exportSize: '1024x1536',
|
||||
longHeightMode: 'auto',
|
||||
customLongHeight: 4500,
|
||||
templateId: null,
|
||||
templateName: '',
|
||||
templateColorScheme: null,
|
||||
@ -146,7 +150,14 @@ function createEmptyDraft(): PosterDraft {
|
||||
function normalizeFormat(draft: PosterDraft) {
|
||||
if (draft.outputMode === 'long') {
|
||||
draft.formatId = 'long_1242_auto'
|
||||
draft.exportSize = '1242xauto'
|
||||
const height = Number(draft.customLongHeight)
|
||||
if (draft.longHeightMode === 'custom' && Number.isInteger(height) && height >= 1920 && height <= 30000) {
|
||||
draft.customLongHeight = height
|
||||
draft.exportSize = `1242x${height}`
|
||||
} else {
|
||||
draft.longHeightMode = 'auto'
|
||||
draft.exportSize = '1242xauto'
|
||||
}
|
||||
return
|
||||
}
|
||||
if (draft.formatId === 'single_9_16' || draft.exportSize === '1080x1920') {
|
||||
@ -196,6 +207,8 @@ export function usePosterWorkspace() {
|
||||
outputMode: d.outputMode,
|
||||
formatId: d.formatId,
|
||||
exportSize: d.exportSize,
|
||||
longHeightMode: d.longHeightMode,
|
||||
customLongHeight: d.customLongHeight,
|
||||
templateId: d.templateId,
|
||||
templateName: d.templateName,
|
||||
templateColorScheme: d.templateColorScheme,
|
||||
@ -309,6 +322,18 @@ export function usePosterWorkspace() {
|
||||
draft.value.formatId = record.document.formatId || draft.value.formatId
|
||||
draft.value.templateColorScheme = record.document.theme || record.document.style || draft.value.templateColorScheme
|
||||
draft.value.sections = record.document.sections || draft.value.sections
|
||||
const rawRequestedHeight = record.document.requestedOutput?.height
|
||||
const requestedHeight = Number(rawRequestedHeight)
|
||||
if (
|
||||
record.document.outputMode === 'long'
|
||||
&& rawRequestedHeight != null
|
||||
&& Number.isInteger(requestedHeight)
|
||||
&& requestedHeight >= 1920
|
||||
&& requestedHeight <= 30000
|
||||
) {
|
||||
draft.value.longHeightMode = 'custom'
|
||||
draft.value.customLongHeight = requestedHeight
|
||||
}
|
||||
}
|
||||
if (record.aiRawContent) {
|
||||
draft.value.aiRawContent = record.aiRawContent
|
||||
@ -319,6 +344,10 @@ export function usePosterWorkspace() {
|
||||
if (record.extraData?.formatId) {
|
||||
draft.value.formatId = record.extraData.formatId
|
||||
}
|
||||
if (record.extraData?.customHeight) {
|
||||
draft.value.longHeightMode = 'custom'
|
||||
draft.value.customLongHeight = Number(record.extraData.customHeight)
|
||||
}
|
||||
normalizeFormat(draft.value)
|
||||
if (record.compliance) {
|
||||
draft.value.complianceStatus = record.compliance.status || 'unchecked'
|
||||
|
||||
@ -123,6 +123,7 @@ const stageRef = ref<InstanceType<typeof PosterStage> | null>(null)
|
||||
const configRailRef = ref<InstanceType<typeof PosterConfigRail> | null>(null)
|
||||
const inlineInspectorRef = ref<InstanceType<typeof PosterInspector> | null>(null)
|
||||
const drawerInspectorRef = ref<InstanceType<typeof PosterInspector> | null>(null)
|
||||
const lastRenderError = ref<string | null>(null)
|
||||
|
||||
// 独立标题(不污染 productName)
|
||||
const posterTitle = ref('')
|
||||
@ -164,6 +165,12 @@ function syncRenderDocument() {
|
||||
outputMode: d.outputMode,
|
||||
templateId: d.templateId,
|
||||
exportSize: d.exportSize,
|
||||
requestedOutput: {
|
||||
width: d.outputMode === 'long' ? 1242 : (d.formatId === 'single_9_16' ? 1080 : 1024),
|
||||
height: d.outputMode === 'long'
|
||||
? (d.longHeightMode === 'custom' ? d.customLongHeight : null)
|
||||
: (d.formatId === 'single_9_16' ? 1920 : 1536),
|
||||
},
|
||||
copy: d.copyContent,
|
||||
facts: d.parsedFields,
|
||||
theme: d.templateColorScheme || d.renderDocument.theme,
|
||||
@ -323,6 +330,9 @@ async function onGenerate() {
|
||||
formatId: d.formatId,
|
||||
size: d.exportSize,
|
||||
outputMode: d.outputMode,
|
||||
customHeight: d.outputMode === 'long' && d.longHeightMode === 'custom'
|
||||
? d.customLongHeight
|
||||
: undefined,
|
||||
copyMode: d.aiRawContent ? 'ai' : 'template',
|
||||
referenceImage,
|
||||
})
|
||||
@ -411,6 +421,12 @@ function buildPosterDocument() {
|
||||
outputMode: d.outputMode,
|
||||
formatId: d.formatId,
|
||||
exportSize: d.exportSize,
|
||||
requestedOutput: {
|
||||
width: d.outputMode === 'long' ? 1242 : (d.formatId === 'single_9_16' ? 1080 : 1024),
|
||||
height: d.outputMode === 'long'
|
||||
? (d.longHeightMode === 'custom' ? d.customLongHeight : null)
|
||||
: (d.formatId === 'single_9_16' ? 1920 : 1536),
|
||||
},
|
||||
copy: d.copyContent,
|
||||
facts: d.parsedFields,
|
||||
theme: d.templateColorScheme || d.renderDocument?.theme || {},
|
||||
@ -430,6 +446,8 @@ watch(
|
||||
ws.draft.value.outputMode,
|
||||
ws.draft.value.formatId,
|
||||
ws.draft.value.exportSize,
|
||||
ws.draft.value.longHeightMode,
|
||||
ws.draft.value.customLongHeight,
|
||||
ws.draft.value.templateColorScheme,
|
||||
ws.draft.value.sections,
|
||||
],
|
||||
@ -463,11 +481,17 @@ function loadImage(url: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function renderComposite(): Promise<Blob | null> {
|
||||
lastRenderError.value = null
|
||||
const htmlCanvas = stageRef.value?.htmlCanvasRef
|
||||
if (htmlCanvas?.canvasRef) {
|
||||
try {
|
||||
return await renderPosterToBlob(htmlCanvas.canvasRef, ws.draft.value.formatId)
|
||||
} catch (e) {
|
||||
const d = ws.draft.value
|
||||
const expectedHeight = d.outputMode === 'long' && d.longHeightMode === 'custom'
|
||||
? d.customLongHeight
|
||||
: null
|
||||
return await renderPosterToBlob(htmlCanvas.canvasRef, d.formatId, expectedHeight)
|
||||
} catch (e: any) {
|
||||
lastRenderError.value = e?.message || '海报合成失败'
|
||||
console.error('HTML 海报导出失败:', e)
|
||||
}
|
||||
}
|
||||
@ -547,7 +571,7 @@ async function onDownload() {
|
||||
ElMessage.success('海报已导出')
|
||||
return
|
||||
}
|
||||
ElMessage.warning('合成导出失败,尝试下载已保存的海报')
|
||||
ElMessage.warning(lastRenderError.value || '合成导出失败,尝试下载已保存的海报')
|
||||
|
||||
// 回退:下载已生成的海报
|
||||
const recordId = d.taskRecordId || ws.recordId.value
|
||||
@ -644,6 +668,19 @@ async function restoreWorkspace() {
|
||||
d.parsedFields = snap.renderDocument.facts || d.parsedFields
|
||||
d.templateColorScheme = snap.renderDocument.theme || d.templateColorScheme
|
||||
d.sections = snap.renderDocument.sections || d.sections
|
||||
const rawRequestedHeight = snap.renderDocument.requestedOutput?.height
|
||||
const requestedHeight = Number(rawRequestedHeight)
|
||||
if (
|
||||
snap.renderDocument.outputMode === 'long'
|
||||
&& rawRequestedHeight != null
|
||||
&& Number.isInteger(requestedHeight)
|
||||
&& requestedHeight >= 1920
|
||||
&& requestedHeight <= 30000
|
||||
) {
|
||||
d.longHeightMode = 'custom'
|
||||
d.customLongHeight = requestedHeight
|
||||
d.exportSize = `1242x${requestedHeight}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -146,6 +146,7 @@ export const posterApi = {
|
||||
formatId?: string
|
||||
size?: string
|
||||
outputMode?: string
|
||||
customHeight?: number
|
||||
productId?: string
|
||||
productSource?: { type: 'library_product' | 'user_material'; id: string }
|
||||
referenceImage?: string
|
||||
|
||||
@ -79,31 +79,43 @@ async function readBlobSize(blob: Blob): Promise<{ width: number; height: number
|
||||
}
|
||||
}
|
||||
|
||||
async function validateOutput(blob: Blob, formatId: string): Promise<void> {
|
||||
async function validateOutput(blob: Blob, formatId: string, expectedHeight?: number | null): Promise<void> {
|
||||
const expected = FORMAT_OUTPUTS[formatId]
|
||||
if (!expected) throw new Error(`不支持的导出格式: ${formatId}`)
|
||||
|
||||
const { width, height } = await readBlobSize(blob)
|
||||
if (width !== expected.width || (expected.height !== null && height !== expected.height)) {
|
||||
throw new Error(`导出尺寸错误:期望 ${expected.width}×${expected.height ?? '自动高度'},实际 ${width}×${height}`)
|
||||
const requiredHeight = expectedHeight ?? expected.height
|
||||
if (width !== expected.width || (requiredHeight !== null && height !== requiredHeight)) {
|
||||
throw new Error(`导出尺寸错误:期望 ${expected.width}×${requiredHeight ?? '自动高度'},实际 ${width}×${height}`)
|
||||
}
|
||||
if (expected.height === null && (height < 1 || height > 32767)) {
|
||||
throw new Error(`长图高度 ${height}px 超出浏览器安全范围`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderPosterToBlob(canvas: HTMLElement, formatId: string): Promise<Blob> {
|
||||
export async function renderPosterToBlob(
|
||||
canvas: HTMLElement,
|
||||
formatId: string,
|
||||
expectedHeight?: number | null,
|
||||
): Promise<Blob> {
|
||||
const spec = FORMAT_OUTPUTS[formatId]
|
||||
if (!spec) throw new Error(`不支持的导出格式: ${formatId}`)
|
||||
if (expectedHeight != null && formatId !== 'long_1242_auto') {
|
||||
throw new Error('自定义高度仅长图模式可用')
|
||||
}
|
||||
|
||||
const { toBlob } = await import('html-to-image')
|
||||
await waitForPosterAssets(canvas)
|
||||
if (expectedHeight != null && canvas.scrollHeight > canvas.clientHeight + 1) {
|
||||
const minimumHeight = Math.ceil(canvas.scrollHeight * spec.pixelRatio)
|
||||
throw new Error(`当前内容超出自定义高度,请将高度调整到至少 ${minimumHeight}px`)
|
||||
}
|
||||
const blob = await toBlob(canvas, {
|
||||
quality: 0.95,
|
||||
pixelRatio: spec.pixelRatio,
|
||||
cacheBust: true,
|
||||
})
|
||||
if (!blob) throw new Error('浏览器未生成海报文件')
|
||||
await validateOutput(blob, formatId)
|
||||
await validateOutput(blob, formatId, expectedHeight)
|
||||
return blob
|
||||
}
|
||||
|
||||
@ -66,6 +66,17 @@ def test_background_asset_size_is_provider_size_not_final_poster_size():
|
||||
assert long_format["backgroundAssetSize"] == "1024x1536"
|
||||
|
||||
|
||||
def test_custom_long_height_is_bounded_and_explicit():
|
||||
from insurance.poster.format_registry import PosterFormatError, resolve_custom_long_height
|
||||
|
||||
assert resolve_custom_long_height(4500, "long") == 4500
|
||||
assert resolve_custom_long_height(None, "long") is None
|
||||
with pytest.raises(PosterFormatError, match="1920"):
|
||||
resolve_custom_long_height(1200, "long")
|
||||
with pytest.raises(PosterFormatError, match="仅长图"):
|
||||
resolve_custom_long_height(4500, "single")
|
||||
|
||||
|
||||
def test_fallback_background_never_draws_marketing_copy(monkeypatch):
|
||||
from PIL import ImageDraw
|
||||
from insurance.poster.image_generator import generate_fallback
|
||||
|
||||
@ -62,6 +62,25 @@ def test_render_document_long_mode_keeps_long_content_budget():
|
||||
assert next(item for item in document["sections"] if item["id"] == "benefits")["visible"] is True
|
||||
|
||||
|
||||
def test_render_document_keeps_custom_long_height():
|
||||
from insurance.poster.format_registry import resolve_poster_format
|
||||
from insurance.poster.render_document_builder import build_render_document
|
||||
|
||||
document = build_render_document(
|
||||
format_spec=resolve_poster_format("long_1242_auto", output_mode="long"),
|
||||
template={},
|
||||
copy_content={},
|
||||
case_facts={},
|
||||
product_rules={},
|
||||
plan_type="other",
|
||||
compliance_revision="",
|
||||
custom_height=4500,
|
||||
)
|
||||
|
||||
assert document["requestedOutput"] == {"width": 1242, "height": 4500}
|
||||
assert document["exportSize"] == "1242x4500"
|
||||
|
||||
|
||||
def test_template_mode_and_format_compatibility_is_explicit():
|
||||
from insurance.models.poster_template_model import PosterTemplate
|
||||
|
||||
|
||||
@ -40,6 +40,15 @@ def test_long_rendered_png_requires_exact_width_and_safe_height():
|
||||
validate_rendered_png(_png(1080, 3600), "long_1242_auto")
|
||||
|
||||
|
||||
def test_custom_long_height_requires_exact_output_height():
|
||||
from insurance.poster.render_validation import PosterRenderValidationError, validate_rendered_png
|
||||
|
||||
result = validate_rendered_png(_png(1242, 4500), "long_1242_auto", expected_height=4500)
|
||||
assert result["height"] == 4500
|
||||
with pytest.raises(PosterRenderValidationError, match="自定义高度"):
|
||||
validate_rendered_png(_png(1242, 4600), "long_1242_auto", expected_height=4500)
|
||||
|
||||
|
||||
def test_single_and_long_use_separate_canvas_components():
|
||||
stage = (ROOT / "frontend/src/components/poster/workspace/PosterStage.vue").read_text(encoding="utf-8")
|
||||
single_canvas = ROOT / "frontend/src/components/poster/single/PosterSingleCanvas.vue"
|
||||
@ -50,6 +59,16 @@ def test_single_and_long_use_separate_canvas_components():
|
||||
assert "<PosterHtmlCanvas" in stage
|
||||
|
||||
|
||||
def test_long_editor_exposes_custom_height_without_silent_crop():
|
||||
creative = (ROOT / "frontend/src/components/poster/workspace/PosterCreativePanel.vue").read_text(encoding="utf-8")
|
||||
stage = (ROOT / "frontend/src/components/poster/workspace/PosterStage.vue").read_text(encoding="utf-8")
|
||||
exporter = (ROOT / "frontend/src/utils/poster-exporter.ts").read_text(encoding="utf-8")
|
||||
|
||||
assert "自定义高度" in creative
|
||||
assert ':custom-output-height="draft.longHeightMode === \'custom\' ? draft.customLongHeight : null"' in stage
|
||||
assert "当前内容超出自定义高度" in exporter
|
||||
|
||||
|
||||
def test_background_generation_does_not_claim_final_poster_is_done():
|
||||
celery_source = (ROOT / "api/insurance/generation/celery_tasks.py").read_text(encoding="utf-8")
|
||||
service_source = (ROOT / "api/insurance/poster/service.py").read_text(encoding="utf-8")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user