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 @@ + + + + + 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 @@
PPT 已生成 + + 正在查看历史版本 v{{ viewingRevision }} + 已应用:{{ generationConfig.templateName }} @@ -84,6 +87,9 @@