diff --git a/api/insurance/generation/celery_tasks.py b/api/insurance/generation/celery_tasks.py
index 344b0d1..ddef70b 100644
--- a/api/insurance/generation/celery_tasks.py
+++ b/api/insurance/generation/celery_tasks.py
@@ -670,16 +670,20 @@ def _apply_edits_to_pptx(pptx_path: str, edit_slides: list):
slide = list(prs.slides)[slide_idx]
edit_shapes = edit_slide.get("shapes", [])
for edit_shape in edit_shapes:
- if edit_shape.get("type") != "textbox":
+ shape_type = edit_shape.get("type")
+ if shape_type not in ("textbox", "table"):
continue
edit_paras = edit_shape.get("paragraphs", [])
- if not edit_paras:
+ edit_rows = edit_shape.get("rows", [])
+ if not edit_paras and not edit_rows:
continue
edit_x = edit_shape.get("x", 0)
edit_y = edit_shape.get("y", 0)
# 匹配 PPTX 中的形状(按位置)
for shape in slide.shapes:
- if not shape.has_text_frame:
+ if shape_type == "textbox" and not shape.has_text_frame:
+ continue
+ if shape_type == "table" and not shape.has_table:
continue
shape_x = int(shape.left) if shape.left else 0
shape_y = int(shape.top) if shape.top else 0
@@ -687,8 +691,10 @@ def _apply_edits_to_pptx(pptx_path: str, edit_slides: list):
continue
if abs(shape_y - _px_to_emu(edit_y)) > TOLERANCE:
continue
- # 位置匹配,更新文字
- _update_shape_text(shape, edit_paras)
+ if shape_type == "table":
+ _update_table_text(shape, edit_rows)
+ else:
+ _update_shape_text(shape, edit_paras)
edited_count += 1
break
@@ -707,6 +713,7 @@ def _update_shape_text(shape, edit_paras: list):
"""将编辑后的段落文本写入 python-pptx 形状。"""
from pptx.util import Pt
from pptx.dml.color import RGBColor
+ from pptx.enum.text import PP_ALIGN
tf = shape.text_frame
existing_paras = list(tf.paragraphs)
@@ -718,15 +725,39 @@ def _update_shape_text(shape, edit_paras: list):
# 保留第一个 run 的格式,替换文本
if para.runs:
para.runs[0].text = new_text
+ first_run = para.runs[0]
# 删除多余 runs
for run in para.runs[1:]:
run.text = ""
else:
para.text = new_text
+ first_run = para.runs[0] if para.runs else None
else:
# 新增段落
para = tf.add_paragraph()
para.text = new_text
+ first_run = para.runs[0] if para.runs else None
+
+ align_map = {
+ "left": PP_ALIGN.LEFT,
+ "center": PP_ALIGN.CENTER,
+ "right": PP_ALIGN.RIGHT,
+ "justify": PP_ALIGN.JUSTIFY,
+ }
+ if edit_para.get("align") in align_map:
+ para.alignment = align_map[edit_para["align"]]
+ if first_run:
+ if edit_para.get("fontSize"):
+ first_run.font.size = Pt(float(edit_para["fontSize"]))
+ if edit_para.get("fontFamily"):
+ first_run.font.name = str(edit_para["fontFamily"])
+ first_run.font.bold = bool(edit_para.get("bold"))
+ color = str(edit_para.get("color") or "").lstrip("#")
+ if len(color) == 6:
+ try:
+ first_run.font.color.rgb = RGBColor.from_string(color.upper())
+ except ValueError:
+ pass
# 删除多余段落(如果编辑后段落变少了)
# python-pptx 不支持直接删除段落,只能清空
@@ -734,6 +765,26 @@ def _update_shape_text(shape, edit_paras: list):
existing_paras[i].text = ""
+def _update_table_text(shape, edit_rows: list):
+ """将编辑后的表格单元格写回 PPTX。"""
+ from pptx.util import Pt
+
+ for row_index, edit_row in enumerate(edit_rows):
+ if row_index >= len(shape.table.rows):
+ break
+ row = shape.table.rows[row_index]
+ for col_index, edit_cell in enumerate(edit_row):
+ if col_index >= len(row.cells):
+ break
+ cell = row.cells[col_index]
+ cell.text = str(edit_cell.get("text") or "")
+ for para in cell.text_frame.paragraphs:
+ for run in para.runs:
+ run.font.bold = bool(edit_cell.get("bold"))
+ if edit_cell.get("fontSize"):
+ run.font.size = Pt(float(edit_cell["fontSize"]))
+
+
@shared_task(bind=True, name="insurance.regenerate_ppt", max_retries=3, default_retry_delay=30)
def regenerate_ppt_task(self, task_id: str):
"""PPT 重新生成任务(基于编辑内容生成新版本)。"""
diff --git a/api/insurance/poster/compliance.py b/api/insurance/poster/compliance.py
index 0f35aab..42b484f 100644
--- a/api/insurance/poster/compliance.py
+++ b/api/insurance/poster/compliance.py
@@ -10,6 +10,7 @@ RULES = (
"severity": "block",
"message": "不得使用确定性收益承诺",
"suggestion": "改为“利益以正式计划书为准”",
+ "replacement": "相关利益",
},
{
"ruleId": "RISK_FREE_CLAIM",
@@ -17,6 +18,7 @@ RULES = (
"severity": "block",
"message": "不得宣称保险或投资安排不存在风险",
"suggestion": "删除绝对化表述,并补充必要的风险提示",
+ "replacement": "存在一定风险",
},
{
"ruleId": "EXAGGERATED_RETURN",
@@ -24,6 +26,15 @@ RULES = (
"severity": "block",
"message": "收益表述必须有正式计划书依据且不得夸大",
"suggestion": "引用已确认的计划书数据并注明非保证利益",
+ "replacement": "演示利益",
+ },
+ {
+ "ruleId": "UNSUPPORTED_SUPERLATIVE",
+ "terms": ("行业领先", "最佳"),
+ "severity": "warn",
+ "message": "比较性或最高级表述需要可核验依据",
+ "suggestion": "改为客观描述产品特点,或补充权威依据",
+ "replacement": "具有特色",
},
)
@@ -61,6 +72,7 @@ def check_copy_compliance(copy_content: dict) -> dict:
"text": term,
"message": rule["message"],
"suggestion": rule["suggestion"],
+ "replacement": rule["replacement"],
})
start = index + len(term)
diff --git a/api/insurance/poster/tasks.py b/api/insurance/poster/tasks.py
index e934e43..953d4ac 100644
--- a/api/insurance/poster/tasks.py
+++ b/api/insurance/poster/tasks.py
@@ -273,6 +273,17 @@ def _map_extract_plan_fields(data: dict, plan_type: str, status: str) -> dict:
"sourceStatus": status,
"missingFields": missing,
"validFieldCount": valid_count,
+ "method": (
+ (data.get("_meta") or {}).get("method")
+ or (data.get("extraction_meta") or {}).get("method")
+ or status
+ ),
+ "provenance": data.get("_provenance") or data.get("provenance") or {},
+ "lowQualityPages": (
+ (data.get("_meta") or {}).get("low_quality_pages")
+ or (data.get("extraction_meta") or {}).get("lowQualityPages")
+ or []
+ ),
}
return result
diff --git a/api/insurance/ppt/renderer.py b/api/insurance/ppt/renderer.py
index cb0309a..f0a1464 100644
--- a/api/insurance/ppt/renderer.py
+++ b/api/insurance/ppt/renderer.py
@@ -712,6 +712,7 @@ class PptRenderer:
return {
"text": full_text,
"fontSize": font_size,
+ "fontFamily": font.name or "Microsoft YaHei",
"bold": bold,
"color": color,
"align": align_val,
diff --git a/api/insurance/ppt/routes.py b/api/insurance/ppt/routes.py
index 8ca0de7..cd3e327 100644
--- a/api/insurance/ppt/routes.py
+++ b/api/insurance/ppt/routes.py
@@ -904,19 +904,32 @@ def get_preview(session_id):
if not session:
return error(ErrorCode.NOT_FOUND, "会话不存在")
+ versions = json.loads(session.versions_json) if session.versions_json else []
+ requested_revision = request.args.get("revision", type=int)
+ selected_version = next(
+ (item for item in versions if item.get("revision") == requested_revision),
+ None,
+ ) if requested_revision is not None else None
+ if requested_revision is not None and not selected_version:
+ return error(ErrorCode.NOT_FOUND, "PPT 版本不存在")
+
preview_status = session.preview_status or "none"
slides_data = None
+ slides_json_path = (
+ selected_version.get("slidesJsonPath")
+ if selected_version else session.slides_json_path
+ )
- if session.slides_json_path:
- if os.path.exists(session.slides_json_path):
+ if slides_json_path:
+ if os.path.exists(slides_json_path):
try:
- with open(session.slides_json_path, "r", encoding="utf-8") as f:
+ with open(slides_json_path, "r", encoding="utf-8") as f:
slides_data = json.load(f)
except Exception as e:
logger.warning("读取 slides.json 失败: %s", e)
preview_status = "failed"
else:
- logger.warning("slides.json 已被清理: %s", session.slides_json_path)
+ logger.warning("slides.json 已被清理: %s", slides_json_path)
preview_status = "failed"
quality_report = None
@@ -932,8 +945,12 @@ def get_preview(session_id):
"slides": slides_data,
"qualityReport": quality_report,
"slideCount": session.slide_count or 0,
- "versions": json.loads(session.versions_json) if session.versions_json else [],
+ "versions": versions,
"generatedRevision": session.generated_revision or 0,
+ "viewingRevision": (
+ requested_revision
+ if requested_revision is not None else session.generated_revision or 0
+ ),
"generationConfig": (
json.loads(session.draft_options_json)
if session.draft_options_json else {}
@@ -941,6 +958,87 @@ def get_preview(session_id):
})
+@ppt_bp.route("/preview//versions//restore", methods=["POST"])
+@jwt_required
+def restore_preview_version(session_id, revision):
+ """把历史版本复制为新的当前版本,保留原历史文件。"""
+ import shutil
+ import uuid
+ from insurance.config import get_storage_root
+ from insurance.db.compat import db
+
+ user_id = str(getattr(request, "user_id", "guest"))
+ session = _get_session(session_id, user_id)
+ if not session:
+ return error(ErrorCode.NOT_FOUND, "会话不存在")
+ versions = json.loads(session.versions_json) if session.versions_json else []
+ source = next((item for item in versions if item.get("revision") == revision), None)
+ if not source:
+ return error(ErrorCode.NOT_FOUND, "PPT 版本不存在")
+
+ ppt_path = source.get("path")
+ slides_path = source.get("slidesJsonPath")
+ if not ppt_path or not os.path.exists(ppt_path):
+ return error(ErrorCode.NOT_FOUND, "历史版本文件已被清理")
+
+ output_root = os.path.abspath(os.path.join(get_storage_root(), "outputs", "ppt"))
+ source_abs = os.path.abspath(ppt_path)
+ if not source_abs.startswith(output_root):
+ return error(ErrorCode.PARAM_ERROR, "历史版本路径无效")
+
+ new_revision = max(
+ [int(item.get("revision") or 0) for item in versions] + [session.generated_revision or 0]
+ ) + 1
+ output_dir = os.path.join(
+ output_root, str(user_id), f"restore_{uuid.uuid4().hex[:12]}"
+ )
+ os.makedirs(output_dir, exist_ok=True)
+ target_ppt = os.path.join(output_dir, "presentation.pptx")
+ shutil.copy2(source_abs, target_ppt)
+ target_slides = None
+ if slides_path and os.path.exists(slides_path):
+ slides_abs = os.path.abspath(slides_path)
+ if not slides_abs.startswith(output_root):
+ return error(ErrorCode.PARAM_ERROR, "历史预览路径无效")
+ slides_dir = os.path.join(output_dir, "slides")
+ os.makedirs(slides_dir, exist_ok=True)
+ target_slides = os.path.join(slides_dir, "slides.json")
+ shutil.copy2(slides_abs, target_slides)
+
+ restored = {
+ "revision": new_revision,
+ "path": target_ppt,
+ "slidesJsonPath": target_slides,
+ "deckPath": source.get("deckPath"),
+ "slideCount": source.get("slideCount") or 0,
+ "sourceRevision": revision,
+ "source": "restore",
+ "createdAt": __import__("datetime").datetime.now().isoformat(),
+ }
+ versions.append(restored)
+ session.versions_json = json.dumps(versions, ensure_ascii=False)
+ session.ppt_path = target_ppt
+ session.latest_output_path = target_ppt
+ session.slides_json_path = target_slides
+ session.slide_count = restored["slideCount"]
+ session.preview_status = "ready" if target_slides else "failed"
+ session.generated_revision = new_revision
+ session.draft_revision = max(session.draft_revision or 1, new_revision)
+ db.session.commit()
+ _record_history(
+ user_id=user_id,
+ action_type="restore_version",
+ session_id=session_id,
+ content_snapshot={"sourceRevision": revision, "newRevision": new_revision},
+ file_url=target_ppt,
+ )
+ return success({
+ "sessionId": session_id,
+ "revision": new_revision,
+ "sourceRevision": revision,
+ })
+
+
@ppt_bp.route("/preview//slide/", methods=["PUT"])
@jwt_required
def update_slide(session_id, index):
diff --git a/docs/保险智能客服系统_API接口文档.md b/docs/保险智能客服系统_API接口文档.md
index a8c6ad2..a8c33d2 100644
--- a/docs/保险智能客服系统_API接口文档.md
+++ b/docs/保险智能客服系统_API接口文档.md
@@ -1340,6 +1340,12 @@ Content-Disposition: attachment; filename="chat_logs_202606.csv"
| DELETE | `/insurance/admin/ppt/products/{id}` | 软删除产品并保留历史快照 |
| DELETE | `/insurance/admin/ppt/templates/{id}` | 软删除 PPT 模板;内置模板只能停用 |
| DELETE | `/insurance/admin/ppt/copy-templates/{id}` | 软删除文案模板 |
+| POST | `/insurance/poster/compliance-check` | 返回文案合规状态、哈希、规则编号和字符区间 |
+| GET/PUT | `/insurance/poster/records/{id}/document` | 读取或保存可编辑海报文档 |
+| GET | `/insurance/poster/records/{id}/background` | 读取 AI 背景资产 |
+| POST | `/insurance/poster/records/{id}/rendered` | 上传浏览器合成的最终 PNG 和文档快照 |
+| GET | `/insurance/ppt/preview/{sessionId}?revision={revision}` | 查看当前或指定历史版本预览 |
+| POST | `/insurance/ppt/preview/{sessionId}/versions/{revision}/restore` | 将历史版本复制恢复为新版本 |
保司写接口新增 `maskingEnabled`、`logoEnabled`;产品写接口新增 `maskingEnabled`。开启名称脱敏时 `maskedDisplayName` 必填。
diff --git a/frontend/src/components/poster/long/PosterHtmlCanvas.vue b/frontend/src/components/poster/long/PosterHtmlCanvas.vue
index aad978c..1e0b746 100644
--- a/frontend/src/components/poster/long/PosterHtmlCanvas.vue
+++ b/frontend/src/components/poster/long/PosterHtmlCanvas.vue
@@ -1,49 +1,49 @@
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -66,6 +66,7 @@ const props = defineProps<{
features?: Array<{ title: string; summary?: string; icon?: string }>
heroBackground?: string | null
editable?: boolean
+ sections?: Array<{ id: string; visible: boolean }>
}>()
const emit = defineEmits<{
@@ -75,6 +76,18 @@ const emit = defineEmits<{
const canvasRef = ref(null)
const modeClass = computed(() => props.outputMode === 'long' ? 'poster--long' : 'poster--single')
+const visibleSections = computed(() => (
+ props.sections?.length
+ ? props.sections.filter(section => section.visible)
+ : [
+ { id: 'hero', visible: true },
+ { id: 'summary', visible: true },
+ { id: 'benefits', visible: true },
+ { id: 'features', visible: true },
+ { id: 'cta', visible: true },
+ { id: 'disclaimer', visible: true },
+ ]
+))
const canvasStyle = computed(() => {
const [w, h] = (props.exportSize || '1024x1792').split('x').map(Number)
diff --git a/frontend/src/components/poster/workspace/PosterCopyPanel.vue b/frontend/src/components/poster/workspace/PosterCopyPanel.vue
index 9228a59..8b56615 100644
--- a/frontend/src/components/poster/workspace/PosterCopyPanel.vue
+++ b/frontend/src/components/poster/workspace/PosterCopyPanel.vue
@@ -118,16 +118,17 @@
-
+
+ 采用建议
+
@@ -228,6 +229,14 @@ function focusIssue(issue: any) {
input?.setSelectionRange(issue.start, issue.end)
}
+function applySuggestion(issue: any) {
+ if (!props.copyContent) return
+ const original = String((props.copyContent as any)[issue.field] || '')
+ const replacement = issue.replacement || ''
+ const updated = original.slice(0, issue.start) + replacement + original.slice(issue.end)
+ emit('update:copy-content', { ...props.copyContent, [issue.field]: updated })
+}
+
async function runComplianceCheck() {
if (!props.copyContent) {
complianceIssues.value = []
@@ -399,22 +408,33 @@ onMounted(async () => {
width: 100%;
margin-top: 6px;
padding: 7px 8px;
- border: 0;
+ display: flex;
+ align-items: center;
+ gap: 6px;
border-radius: 4px;
background: #fff;
color: #606266;
+ text-align: left;
+}
+
+.issue-main {
+ flex: 1;
+ min-width: 0;
+ padding: 0;
+ border: 0;
+ background: transparent;
cursor: pointer;
text-align: left;
}
-.issue-row strong,
-.issue-row span {
+.issue-main strong,
+.issue-main span {
display: block;
font-size: 11px;
line-height: 1.5;
}
-.issue-row strong {
+.issue-main strong {
color: #b42318;
}
diff --git a/frontend/src/components/poster/workspace/PosterInspector.vue b/frontend/src/components/poster/workspace/PosterInspector.vue
index 7066a52..95abdd9 100644
--- a/frontend/src/components/poster/workspace/PosterInspector.vue
+++ b/frontend/src/components/poster/workspace/PosterInspector.vue
@@ -24,6 +24,13 @@
})"
/>
+
+
+
()
defineEmits<{ 'update:draft': [patch: Partial] }>()
diff --git a/frontend/src/components/poster/workspace/PosterSectionPanel.vue b/frontend/src/components/poster/workspace/PosterSectionPanel.vue
new file mode 100644
index 0000000..eda0f7d
--- /dev/null
+++ b/frontend/src/components/poster/workspace/PosterSectionPanel.vue
@@ -0,0 +1,73 @@
+
+
+
控制导出海报包含的内容区块。长图模式下可调整顺序。
+
+
+
{{ labels[section.id] || section.id }}
+
+ 上移
+ 下移
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/poster/workspace/PosterSourcePanel.vue b/frontend/src/components/poster/workspace/PosterSourcePanel.vue
index 43f2b2f..a367d57 100644
--- a/frontend/src/components/poster/workspace/PosterSourcePanel.vue
+++ b/frontend/src/components/poster/workspace/PosterSourcePanel.vue
@@ -51,8 +51,14 @@
-
{{ parseFailMessage || '解析失败' }}
-
重新上传
+
+
没有识别到可用字段
+
{{ parseFailMessage || '可能是扫描件质量低、PDF 加密或当前版式暂不支持。' }}
+
+
+ 更换文件
+ 人工填写
+
@@ -85,6 +91,12 @@
style="margin-bottom: 10px"
/>
+
+ 解析方式:{{ parseDiagnostics.method || '未知' }}
+
+ 低质量页:{{ parseDiagnostics.lowQualityPages.join('、') }}
+
+
@@ -195,6 +207,23 @@ const missingFieldsText = computed(() => {
if (!missing.length) return '部分字段未识别,请人工核对并补充'
return `未识别:${missing.map((key: string) => fieldLabels[key] || key).join('、')},请人工补充`
})
+const parseDiagnostics = computed(() => props.parsedFields?.meta || null)
+
+function startManualEntry() {
+ emit('update:parse', {
+ parseStatus: 'partial',
+ parsedFields: {
+ meta: {
+ status: 'partial',
+ method: 'manual',
+ missingFields: ['age', 'currency', 'annual_premium', 'premium_term', 'sum_assured'],
+ },
+ },
+ dataConfirmed: false,
+ parseFailMessage: '',
+ })
+ editingFields.value = true
+}
function formatSize(bytes: number) {
if (!bytes) return ''
@@ -453,6 +482,18 @@ function onReset() {
color: #f56c6c;
}
+.fail-detail {
+ margin: 4px 0 0;
+ color: #909399;
+ font-size: 11px;
+ line-height: 1.5;
+}
+
+.fail-actions {
+ display: flex;
+ flex-shrink: 0;
+}
+
/* ── 文件状态行 ────────────────────── */
.file-row {
display: flex;
@@ -500,6 +541,18 @@ function onReset() {
margin-top: 8px;
}
+.parse-diagnostics {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px 12px;
+ margin-bottom: 10px;
+ padding: 7px 8px;
+ border-radius: 4px;
+ background: #f5f7fa;
+ color: #606266;
+ font-size: 11px;
+}
+
.field-form :deep(.el-form-item) {
margin-bottom: 10px;
}
diff --git a/frontend/src/components/poster/workspace/PosterStage.vue b/frontend/src/components/poster/workspace/PosterStage.vue
index aefa34a..47ddd7c 100644
--- a/frontend/src/components/poster/workspace/PosterStage.vue
+++ b/frontend/src/components/poster/workspace/PosterStage.vue
@@ -17,7 +17,8 @@
:output-mode="draft.outputMode"
:export-size="draft.exportSize"
:features="defaultFeatures"
- :hero-background="draft.posterUrl"
+ :hero-background="draft.backgroundCandidateUrl || draft.posterUrl"
+ :sections="draft.sections"
:editable="true"
@edit="onCanvasEdit"
/>
@@ -39,7 +40,12 @@
-
背景已生成,文案仍可直接编辑
+
新背景待确认
+
背景已生成,文案仍可直接编辑
+
+ 使用新背景
+ 保留原背景
+
重新生成背景
@@ -59,6 +65,8 @@ const props = defineProps<{ draft: PosterDraft }>()
const emit = defineEmits<{
retry: []
edit: [field: string, value: string]
+ 'accept-background': []
+ 'reject-background': []
}>()
const stageRef = ref(null)
diff --git a/frontend/src/composables/usePosterWorkspace.ts b/frontend/src/composables/usePosterWorkspace.ts
index 2daa647..0403578 100644
--- a/frontend/src/composables/usePosterWorkspace.ts
+++ b/frontend/src/composables/usePosterWorkspace.ts
@@ -58,6 +58,8 @@ export interface PosterDraft {
referenceImages: Array<{ id: string; url: string; file?: File; name: string }>
// 生成结果
posterUrl: string | null
+ backgroundCandidateUrl: string | null
+ sections: Array<{ id: string; visible: boolean }>
// 合规
complianceConfirmed: boolean
complianceStatus: 'unchecked' | 'pass' | 'warn' | 'block'
@@ -99,6 +101,15 @@ function createEmptyDraft(): PosterDraft {
taskRecordId: null,
referenceImages: [],
posterUrl: null,
+ backgroundCandidateUrl: null,
+ sections: [
+ { id: 'hero', visible: true },
+ { id: 'summary', visible: true },
+ { id: 'benefits', visible: true },
+ { id: 'features', visible: true },
+ { id: 'cta', visible: true },
+ { id: 'disclaimer', visible: true },
+ ],
complianceConfirmed: false,
complianceStatus: 'unchecked',
complianceIssues: [],
@@ -156,6 +167,7 @@ export function usePosterWorkspace() {
complianceStatus: d.complianceStatus,
complianceIssues: d.complianceIssues,
complianceRevision: d.complianceRevision,
+ sections: d.sections,
// 以下字段不持久化:taskStatus, taskProgress, taskError, taskRecordId, posterUrl, referenceImages
}
}
@@ -248,6 +260,7 @@ export function usePosterWorkspace() {
draft.value.templateId = record.document.templateId || draft.value.templateId
draft.value.outputMode = record.document.outputMode || draft.value.outputMode
draft.value.templateColorScheme = record.document.style || draft.value.templateColorScheme
+ draft.value.sections = record.document.sections || draft.value.sections
}
if (record.aiRawContent) {
draft.value.aiRawContent = record.aiRawContent
diff --git a/frontend/src/pages/PosterPage.vue b/frontend/src/pages/PosterPage.vue
index a0cb934..0655e5a 100644
--- a/frontend/src/pages/PosterPage.vue
+++ b/frontend/src/pages/PosterPage.vue
@@ -35,6 +35,8 @@
:draft="ws.draft.value"
@retry="onGenerate"
@edit="onCanvasEdit"
+ @accept-background="acceptBackgroundCandidate"
+ @reject-background="rejectBackgroundCandidate"
/>
@@ -308,11 +310,7 @@ function startPolling(id: number) {
if (status === 'done') {
try {
const blob = await posterApi.downloadBackground(id)
- if (ws.draft.value.posterUrl) {
- URL.revokeObjectURL(ws.draft.value.posterUrl)
- }
- ws.draft.value.posterUrl = URL.createObjectURL(blob)
- await saveCompositeToServer(id)
+ await applyGeneratedBackground(blob, id)
} catch {
ws.draft.value.taskStatus = 'failed'
ws.draft.value.taskError = '图片下载失败'
@@ -334,14 +332,7 @@ function buildPosterDocument() {
copy: d.copyContent,
facts: d.parsedFields,
style: d.templateColorScheme || {},
- sections: [
- { id: 'hero', visible: true },
- { id: 'summary', visible: true },
- { id: 'benefits', visible: true },
- { id: 'features', visible: true },
- { id: 'cta', visible: true },
- { id: 'disclaimer', visible: true },
- ],
+ sections: d.sections,
complianceRevision: d.complianceRevision,
}
}
@@ -353,6 +344,7 @@ watch(
ws.draft.value.templateId,
ws.draft.value.outputMode,
ws.draft.value.templateColorScheme,
+ ws.draft.value.sections,
],
() => {
const recordId = ws.draft.value.taskRecordId || ws.recordId.value
@@ -400,6 +392,34 @@ async function saveCompositeToServer(recordId: number) {
}
}
+async function applyGeneratedBackground(blob: Blob, recordId: number) {
+ const d = ws.draft.value
+ const nextUrl = URL.createObjectURL(blob)
+ if (d.posterUrl) {
+ if (d.backgroundCandidateUrl) URL.revokeObjectURL(d.backgroundCandidateUrl)
+ d.backgroundCandidateUrl = nextUrl
+ return
+ }
+ d.posterUrl = nextUrl
+ await saveCompositeToServer(recordId)
+}
+
+async function acceptBackgroundCandidate() {
+ const d = ws.draft.value
+ if (!d.backgroundCandidateUrl) return
+ if (d.posterUrl) URL.revokeObjectURL(d.posterUrl)
+ d.posterUrl = d.backgroundCandidateUrl
+ d.backgroundCandidateUrl = null
+ const recordId = d.taskRecordId || ws.recordId.value
+ if (recordId) await saveCompositeToServer(recordId)
+}
+
+function rejectBackgroundCandidate() {
+ const d = ws.draft.value
+ if (d.backgroundCandidateUrl) URL.revokeObjectURL(d.backgroundCandidateUrl)
+ d.backgroundCandidateUrl = null
+}
+
// ── 下载海报 ─────────────────────────────
async function onDownload() {
const d = ws.draft.value
@@ -595,8 +615,7 @@ function startPollingUnified(taskId: string) {
if (pollId) {
try {
const blob = await posterApi.downloadBackground(pollId)
- ws.draft.value.posterUrl = URL.createObjectURL(blob)
- await saveCompositeToServer(pollId)
+ await applyGeneratedBackground(blob, pollId)
} catch {
ws.draft.value.taskError = '图片下载失败'
}
@@ -623,6 +642,9 @@ watch(
if (ws.draft.value.posterUrl) {
URL.revokeObjectURL(ws.draft.value.posterUrl)
}
+ if (ws.draft.value.backgroundCandidateUrl) {
+ URL.revokeObjectURL(ws.draft.value.backgroundCandidateUrl)
+ }
await restoreWorkspace()
}
},
@@ -634,6 +656,9 @@ onBeforeUnmount(() => {
if (ws.draft.value.posterUrl) {
URL.revokeObjectURL(ws.draft.value.posterUrl)
}
+ if (ws.draft.value.backgroundCandidateUrl) {
+ URL.revokeObjectURL(ws.draft.value.backgroundCandidateUrl)
+ }
})
diff --git a/frontend/src/pages/components/ppt/PptResult.vue b/frontend/src/pages/components/ppt/PptResult.vue
index bc5a09f..733b06e 100644
--- a/frontend/src/pages/components/ppt/PptResult.vue
+++ b/frontend/src/pages/components/ppt/PptResult.vue
@@ -61,6 +61,9 @@