From 65d17179b346354487ffe680196544277433c9aa Mon Sep 17 00:00:00 2001 From: wsb1224 Date: Thu, 30 Jul 2026 09:15:07 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=B5=B7=E6=8A=A5=E7=A8=8B?= =?UTF-8?q?=E7=A8=8B=E9=A1=B5=E9=9D=A2PPT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/insurance/generation/celery_tasks.py | 136 ++++- api/insurance/models/ppt_config.py | 23 +- api/insurance/ppt/quality_checker.py | 23 +- .../components/poster/PosterStepPreview.vue | 19 +- .../components/poster/PosterStepTemplate.vue | 19 +- .../components/poster/PosterStepUpload.vue | 5 +- .../poster/workspace/PosterActionBar.vue | 135 +++++ .../poster/workspace/PosterConfigRail.vue | 85 +++ .../poster/workspace/PosterCopyPanel.vue | 291 ++++++++++ .../poster/workspace/PosterCreativePanel.vue | 335 ++++++++++++ .../poster/workspace/PosterDeliveryPanel.vue | 206 +++++++ .../poster/workspace/PosterInspector.vue | 86 +++ .../poster/workspace/PosterProductPanel.vue | 299 ++++++++++ .../poster/workspace/PosterSourcePanel.vue | 473 ++++++++++++++++ .../poster/workspace/PosterStage.vue | 244 +++++++++ .../poster/workspace/PosterToolbar.vue | 155 ++++++ .../src/composables/usePosterWorkspace.ts | 314 +++++++++++ frontend/src/pages/PosterPage.vue | 510 ++++++++++-------- 18 files changed, 3112 insertions(+), 246 deletions(-) create mode 100644 frontend/src/components/poster/workspace/PosterActionBar.vue create mode 100644 frontend/src/components/poster/workspace/PosterConfigRail.vue create mode 100644 frontend/src/components/poster/workspace/PosterCopyPanel.vue create mode 100644 frontend/src/components/poster/workspace/PosterCreativePanel.vue create mode 100644 frontend/src/components/poster/workspace/PosterDeliveryPanel.vue create mode 100644 frontend/src/components/poster/workspace/PosterInspector.vue create mode 100644 frontend/src/components/poster/workspace/PosterProductPanel.vue create mode 100644 frontend/src/components/poster/workspace/PosterSourcePanel.vue create mode 100644 frontend/src/components/poster/workspace/PosterStage.vue create mode 100644 frontend/src/components/poster/workspace/PosterToolbar.vue create mode 100644 frontend/src/composables/usePosterWorkspace.ts diff --git a/api/insurance/generation/celery_tasks.py b/api/insurance/generation/celery_tasks.py index 88528bf..7ac08d9 100644 --- a/api/insurance/generation/celery_tasks.py +++ b/api/insurance/generation/celery_tasks.py @@ -532,6 +532,112 @@ def _execute_ppt_generate(task_id: str): # ─── PPT 重新生成任务(版本化)───────────────────────────── +def _apply_edits_to_pptx(pptx_path: str, edit_slides: list): + """将 slides.json 中的编辑应用到已渲染的 PPTX 文件。 + + 1. 删除被标记为 hidden 的幻灯片(从后往前删避免索引偏移) + 2. 对非隐藏页,按位置匹配文本框并更新文字 + """ + from pptx import Presentation + from pptx.util import Emu + + prs = Presentation(pptx_path) + slides = list(prs.slides) + + # 收集需要删除的幻灯片索引(倒序处理) + hidden_indices = sorted( + [i for i, s in enumerate(edit_slides) if s.get("hidden") and i < len(slides)], + reverse=True, + ) + for idx in hidden_indices: + slide_id = slides[idx].slide_id + rId = prs.slides._sldIdLst[idx].get("{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id") + if not rId: + # 尝试通过 rels 查找 + for rel in prs.part.rels.values(): + if rel.target_part is slides[idx].part: + rId = rel.rId + break + if rId: + prs.part.drop_rel(rId) + del prs.slides._sldIdLst[idx] + logger.info("已删除隐藏幻灯片: index=%d", idx) + + # 应用文字编辑(匹配位置) + TOLERANCE = 50000 # EMU 容差(约 0.5px) + edited_count = 0 + for slide_idx, edit_slide in enumerate(edit_slides): + if edit_slide.get("hidden"): + continue + if slide_idx >= len(list(prs.slides)): + continue + slide = list(prs.slides)[slide_idx] + edit_shapes = edit_slide.get("shapes", []) + for edit_shape in edit_shapes: + if edit_shape.get("type") != "textbox": + continue + edit_paras = edit_shape.get("paragraphs", []) + if not edit_paras: + 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: + continue + shape_x = int(shape.left) if shape.left else 0 + shape_y = int(shape.top) if shape.top else 0 + if abs(shape_x - _px_to_emu(edit_x)) > TOLERANCE: + continue + if abs(shape_y - _px_to_emu(edit_y)) > TOLERANCE: + continue + # 位置匹配,更新文字 + _update_shape_text(shape, edit_paras) + edited_count += 1 + break + + if edited_count > 0 or hidden_indices: + prs.save(pptx_path) + logger.info("PPTX 后处理完成: 编辑 %d 个文本框, 删除 %d 个隐藏页", + edited_count, len(hidden_indices)) + + +def _px_to_emu(px: int) -> int: + """像素转 EMU(96 DPI)。""" + return round(px * 914400 / 96) + + +def _update_shape_text(shape, edit_paras: list): + """将编辑后的段落文本写入 python-pptx 形状。""" + from pptx.util import Pt + from pptx.dml.color import RGBColor + + tf = shape.text_frame + existing_paras = list(tf.paragraphs) + + for i, edit_para in enumerate(edit_paras): + new_text = edit_para.get("text", "") + if i < len(existing_paras): + para = existing_paras[i] + # 保留第一个 run 的格式,替换文本 + if para.runs: + para.runs[0].text = new_text + # 删除多余 runs + for run in para.runs[1:]: + run.text = "" + else: + para.text = new_text + else: + # 新增段落 + para = tf.add_paragraph() + para.text = new_text + + # 删除多余段落(如果编辑后段落变少了) + # python-pptx 不支持直接删除段落,只能清空 + for i in range(len(edit_paras), len(existing_paras)): + existing_paras[i].text = "" + + @shared_task(bind=True, name="insurance.regenerate_ppt", max_retries=3, default_retry_delay=30) def regenerate_ppt_task(self, task_id: str): """PPT 重新生成任务(基于编辑内容生成新版本)。""" @@ -588,12 +694,6 @@ def _execute_ppt_regenerate(task_id: str): snapshot = json.loads(task.input_snapshot_json) if task.input_snapshot_json else {} edits = snapshot.get("edits") - # 应用编辑:移除隐藏的幻灯片 - if edits and edits.get("slides"): - hidden_indices = {i for i, s in enumerate(edits["slides"]) if s.get("hidden")} - if hidden_indices: - logger.info("重新生成: 移除 %d 个隐藏幻灯片: %s", len(hidden_indices), hidden_indices) - _update_task_status(task_id, stage="rendering", progress=30, message="重新渲染 PPT") # 使用 DeckContract 重新渲染 @@ -628,6 +728,15 @@ def _execute_ppt_regenerate(task_id: str): finished_at=datetime.now()) return + _update_task_status(task_id, stage="saving", progress=70, message="应用编辑") + + # 后处理:应用文字编辑和删除隐藏页 + if edits and edits.get("slides"): + try: + _apply_edits_to_pptx(output_path, edits["slides"]) + except Exception as exc: + logger.warning("应用编辑到 PPTX 失败: %s", exc) + _update_task_status(task_id, stage="saving", progress=80, message="保存新版本") # 解析幻灯片结构 @@ -641,24 +750,17 @@ def _execute_ppt_regenerate(task_id: str): except Exception as exc: logger.warning("重新生成: 幻灯片解析失败: %s", exc) - # 如果有隐藏页,从 slides.json 中标记 - if slides_data and edits and edits.get("slides"): - for i, edit_slide in enumerate(edits["slides"]): - if edit_slide.get("hidden") and i < len(slides_data.get("slides", [])): - slides_data["slides"][i]["hidden"] = True - slides_json_path = os.path.join(slides_dir, "slides.json") - with open(slides_json_path, "w", encoding="utf-8") as f: - json.dump(slides_data, f, ensure_ascii=False) - - # 质量检查 + # 质量检查(传入 extractions 避免误判) quality_report = None try: from insurance.ppt.quality_checker import QualityChecker checker = QualityChecker() + # 从 session 读取原始 extractions 用于关键数据检查 + session_extractions = json.loads(session.extractions_json) if session.extractions_json else [] quality_report = checker.check( pptx_path=output_path, slides_data=slides_data, - extractions=[], + extractions=session_extractions, expected_slide_count=result.get("slideCount", 0), ) except Exception as exc: diff --git a/api/insurance/models/ppt_config.py b/api/insurance/models/ppt_config.py index 3a01437..41dea93 100644 --- a/api/insurance/models/ppt_config.py +++ b/api/insurance/models/ppt_config.py @@ -147,7 +147,22 @@ class PptTemplate(db.Model): def to_dict(self): import json - slides = json.loads(self.slides_config_json) if self.slides_config_json else [] + try: + slides = json.loads(self.slides_config_json) if self.slides_config_json else [] + except (json.JSONDecodeError, TypeError): + slides = [] + try: + required_types = json.loads(self.required_page_types_json) if self.required_page_types_json else [] + except (json.JSONDecodeError, TypeError): + required_types = [] + try: + company_ids = json.loads(self.applicable_company_ids) if self.applicable_company_ids else [] + except (json.JSONDecodeError, TypeError): + company_ids = [] + try: + product_ids = json.loads(self.applicable_product_ids) if self.applicable_product_ids else [] + except (json.JSONDecodeError, TypeError): + product_ids = [] return { "id": self.id, "planType": self.plan_type, @@ -160,12 +175,12 @@ class PptTemplate(db.Model): "isBuiltIn": str(self.source_template_asset_id or "").startswith("builtin://"), "cloneReady": self.clone_ready, "cloneRenderer": self.clone_renderer, - "requiredPageTypes": json.loads(self.required_page_types_json) if self.required_page_types_json else [], + "requiredPageTypes": required_types, "name": self.name, "scenarioTag": self.scenario_tag, "previewImage": self.preview_image, - "applicableCompanyIds": json.loads(self.applicable_company_ids) if self.applicable_company_ids else [], - "applicableProductIds": json.loads(self.applicable_product_ids) if self.applicable_product_ids else [], + "applicableCompanyIds": company_ids, + "applicableProductIds": product_ids, "slidesConfig": slides, "slideCount": len(slides), "status": self.status, diff --git a/api/insurance/ppt/quality_checker.py b/api/insurance/ppt/quality_checker.py index 46f6c25..d382d6f 100644 --- a/api/insurance/ppt/quality_checker.py +++ b/api/insurance/ppt/quality_checker.py @@ -117,21 +117,32 @@ class QualityChecker: blank_pages = [] for i, slide in enumerate(slides): shapes = slide.get("shapes", []) - has_text = False + has_content = False for shape in shapes: - if shape.get("type") == "textbox": + stype = shape.get("type", "") + # 文本框有内容 + if stype == "textbox": for para in shape.get("paragraphs", []): if para.get("text", "").strip(): - has_text = True + has_content = True break - if has_text: + # 图片、表格也算内容 + elif stype in ("image", "table"): + has_content = True + # 有填充色的矩形也算内容(封面背景等) + elif stype == "rect" and shape.get("fill"): + has_content = True + # 分组形状中的子元素 + elif stype == "group" and shape.get("children"): + has_content = True + if has_content: break - if not has_text: + if not has_content: blank_pages.append(i + 1) if not blank_pages: return {"key": "no_blank_pages", "status": "pass", - "message": "所有页面均包含文本内容", "page": None} + "message": "所有页面均包含内容(文本/图片/表格/形状)", "page": None} if len(blank_pages) <= 2: return {"key": "no_blank_pages", "status": "warn", "message": f"第 {', '.join(str(p) for p in blank_pages)} 页可能为空白页", diff --git a/frontend/src/components/poster/PosterStepPreview.vue b/frontend/src/components/poster/PosterStepPreview.vue index c82bea6..ca40d2c 100644 --- a/frontend/src/components/poster/PosterStepPreview.vue +++ b/frontend/src/components/poster/PosterStepPreview.vue @@ -15,6 +15,7 @@
{{ copyContent?.headline || '客户专属保障方案' }}
{{ copyContent?.body || '文案将在生成时写入海报。' }}
{{ copyContent?.call_to_action || '联系顾问了解详情' }}
+
↑ 文案排版示意,最终效果以 AI 生成为准
@@ -55,9 +56,9 @@
- - - 生成海报 + + + {{ submitting ? '正在提交...' : '生成海报' }} @@ -96,6 +97,7 @@ const taskProgress = ref(0) const taskError = ref(null) const posterObjectUrl = ref(null) const recordId = ref(null) +const submitting = ref(false) let pollTimer: ReturnType | null = null let pollRetries = 0 const MAX_POLL_RETRIES = 90 // 最多轮询 90 次(3 分钟) @@ -135,6 +137,7 @@ function clearPoster() { taskProgress.value = 0 taskError.value = null pollRetries = 0 + submitting.value = false } function stopPolling() { @@ -145,6 +148,7 @@ function stopPolling() { } async function onGenerate() { + submitting.value = true taskStatus.value = 'queued' taskProgress.value = 0 taskError.value = null @@ -168,6 +172,8 @@ async function onGenerate() { } catch (e: any) { taskStatus.value = 'failed' taskError.value = e?.message || '提交失败' + } finally { + submitting.value = false } } @@ -324,6 +330,13 @@ onBeforeUnmount(() => { font-weight: 700; } +.draft-hint { + margin-top: 8px; + font-size: 11px; + opacity: .55; + text-align: center; +} + .generate-state { width: min(420px, 100%); text-align: center; diff --git a/frontend/src/components/poster/PosterStepTemplate.vue b/frontend/src/components/poster/PosterStepTemplate.vue index 1791c52..a76e795 100644 --- a/frontend/src/components/poster/PosterStepTemplate.vue +++ b/frontend/src/components/poster/PosterStepTemplate.vue @@ -230,8 +230,19 @@ function formatStyle(text?: string) { } function previewBackground(template: any) { - const primary = template.colorScheme?.primary || '#1e3a5f' - const accent = template.colorScheme?.accent || '#2563eb' + // 不同模板使用不同默认配色,避免无预览图时所有卡片看起来一样 + const fallbackPairs = [ + ['#1e3a5f', '#2563eb'], + ['#2d1b4e', '#7c3aed'], + ['#1a3a2a', '#059669'], + ['#3a1a1a', '#dc2626'], + ['#1a2a3a', '#0891b2'], + ['#3a2a1a', '#d97706'], + ] + const idx = (template.id || 0) % fallbackPairs.length + const [defaultPrimary, defaultAccent] = fallbackPairs[idx] + const primary = template.colorScheme?.primary || defaultPrimary + const accent = template.colorScheme?.accent || defaultAccent return `linear-gradient(135deg, ${primary}, ${accent})` } @@ -347,8 +358,10 @@ function onNext() { text-align: left; } -.template-card:hover { +.template-card:hover, +.template-card:focus-visible { border-color: #2563eb; + outline: none; } .template-card.active { diff --git a/frontend/src/components/poster/PosterStepUpload.vue b/frontend/src/components/poster/PosterStepUpload.vue index 756afc7..3c28800 100644 --- a/frontend/src/components/poster/PosterStepUpload.vue +++ b/frontend/src/components/poster/PosterStepUpload.vue @@ -40,7 +40,7 @@
- + @@ -100,6 +100,7 @@ const parsing = ref(false) const parseFailed = ref(false) const progress = ref(0) const parseMessage = ref('正在上传...') +const parseFailMessage = ref('解析计划书时出错,请重新上传或联系管理员。') const confirmed = ref(false) const pdfPassword = ref('') let pollTimer: ReturnType | null = null @@ -118,6 +119,7 @@ function resetUpload() { progress.value = 0 confirmed.value = false pollRetries = 0 + parseFailMessage.value = '解析计划书时出错,请重新上传或联系管理员。' } function stopPolling() { @@ -182,6 +184,7 @@ function startParsePolling(id: number) { stopPolling() parsing.value = false parseFailed.value = true + parseFailMessage.value = '计划书解析失败,可能是文件损坏、页数过多或格式不受支持。请重新上传。' } else if (status === 'parsing' || status === 'queued') { progress.value = Math.min(90, progress.value + 10) parseMessage.value = status === 'queued' ? '任务排队中...' : '正在解析计划书...' diff --git a/frontend/src/components/poster/workspace/PosterActionBar.vue b/frontend/src/components/poster/workspace/PosterActionBar.vue new file mode 100644 index 0000000..73908e0 --- /dev/null +++ b/frontend/src/components/poster/workspace/PosterActionBar.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/frontend/src/components/poster/workspace/PosterConfigRail.vue b/frontend/src/components/poster/workspace/PosterConfigRail.vue new file mode 100644 index 0000000..6bade56 --- /dev/null +++ b/frontend/src/components/poster/workspace/PosterConfigRail.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/frontend/src/components/poster/workspace/PosterCopyPanel.vue b/frontend/src/components/poster/workspace/PosterCopyPanel.vue new file mode 100644 index 0000000..08cfce0 --- /dev/null +++ b/frontend/src/components/poster/workspace/PosterCopyPanel.vue @@ -0,0 +1,291 @@ + + + + + diff --git a/frontend/src/components/poster/workspace/PosterCreativePanel.vue b/frontend/src/components/poster/workspace/PosterCreativePanel.vue new file mode 100644 index 0000000..bcef547 --- /dev/null +++ b/frontend/src/components/poster/workspace/PosterCreativePanel.vue @@ -0,0 +1,335 @@ + + + + + diff --git a/frontend/src/components/poster/workspace/PosterDeliveryPanel.vue b/frontend/src/components/poster/workspace/PosterDeliveryPanel.vue new file mode 100644 index 0000000..5d2e1de --- /dev/null +++ b/frontend/src/components/poster/workspace/PosterDeliveryPanel.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/frontend/src/components/poster/workspace/PosterInspector.vue b/frontend/src/components/poster/workspace/PosterInspector.vue new file mode 100644 index 0000000..6d4d869 --- /dev/null +++ b/frontend/src/components/poster/workspace/PosterInspector.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/frontend/src/components/poster/workspace/PosterProductPanel.vue b/frontend/src/components/poster/workspace/PosterProductPanel.vue new file mode 100644 index 0000000..4ae70ff --- /dev/null +++ b/frontend/src/components/poster/workspace/PosterProductPanel.vue @@ -0,0 +1,299 @@ + + + + + diff --git a/frontend/src/components/poster/workspace/PosterSourcePanel.vue b/frontend/src/components/poster/workspace/PosterSourcePanel.vue new file mode 100644 index 0000000..fe3068f --- /dev/null +++ b/frontend/src/components/poster/workspace/PosterSourcePanel.vue @@ -0,0 +1,473 @@ + + + + + diff --git a/frontend/src/components/poster/workspace/PosterStage.vue b/frontend/src/components/poster/workspace/PosterStage.vue new file mode 100644 index 0000000..85693af --- /dev/null +++ b/frontend/src/components/poster/workspace/PosterStage.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/frontend/src/components/poster/workspace/PosterToolbar.vue b/frontend/src/components/poster/workspace/PosterToolbar.vue new file mode 100644 index 0000000..e94a752 --- /dev/null +++ b/frontend/src/components/poster/workspace/PosterToolbar.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/frontend/src/composables/usePosterWorkspace.ts b/frontend/src/composables/usePosterWorkspace.ts new file mode 100644 index 0000000..56bc6a2 --- /dev/null +++ b/frontend/src/composables/usePosterWorkspace.ts @@ -0,0 +1,314 @@ +/** + * 海报工作区统一状态管理。 + * + * 核心职责: + * 1. 维护 PosterDraft 统一草稿数据 + * 2. 持久化到 localStorage(页面刷新恢复) + * 3. 从后端 API 恢复已有工作区 + * 4. 计算完成进度和主操作按钮状态 + */ +import { ref, computed, watch, onMounted } from 'vue' +import { useRouter, useRoute } from 'vue-router' +import api from '@/utils/api' + +/** 海报草稿统一数据结构 */ +export interface PosterDraft { + // 产品 + productId: string + productName: string + productCompany: string + // 脱敏 + useMaskedData: boolean + // 上传 + caseUploadId: number | null + caseFileName: string + caseFileSize: number + parseStatus: 'none' | 'uploading' | 'queued' | 'parsing' | 'parsed' | 'failed' + parseProgress: number + parseMessage: string + parseFailMessage: string + // 解析字段 + parsedFields: Record + // 确认状态 + dataConfirmed: boolean + // 创意 + scenario: string + exportSize: string + templateId: number | null + templateName: string + // 文案 + copyMode: 'template' | 'ai' + copyTemplateId: number | null + aiStyle: string + copyContent: { + headline: string + body: string + call_to_action: string + } | null + aiRawContent: any + // 生成任务 + taskStatus: 'idle' | 'submitting' | 'queued' | 'generating' | 'done' | 'failed' + taskProgress: number + taskError: string | null + taskRecordId: number | null + // 生成结果 + posterUrl: string | null + // 合规 + complianceConfirmed: boolean +} + +function createEmptyDraft(): PosterDraft { + return { + productId: '', + productName: '', + productCompany: '', + useMaskedData: false, + caseUploadId: null, + caseFileName: '', + caseFileSize: 0, + parseStatus: 'none', + parseProgress: 0, + parseMessage: '', + parseFailMessage: '', + parsedFields: {}, + dataConfirmed: false, + scenario: '朋友圈沟通', + exportSize: '1024x1792', + templateId: null, + templateName: '', + copyMode: 'template', + copyTemplateId: null, + aiStyle: '专业稳健', + copyContent: null, + aiRawContent: null, + taskStatus: 'idle', + taskProgress: 0, + taskError: null, + taskRecordId: null, + posterUrl: null, + complianceConfirmed: false, + } +} + +export function usePosterWorkspace() { + const router = useRouter() + const route = useRoute() + + // ── 核心状态 ────────────────────────────── + const recordId = ref(null) + const draft = ref(createEmptyDraft()) + const loading = ref(false) + const restored = ref(false) + + // ── 持久化 Key ──────────────────────────── + const STORAGE_KEY = computed(() => + recordId.value ? `poster_ws_draft_${recordId.value}` : 'poster_ws_draft_new' + ) + + // ── 持久化到 localStorage ───────────────── + function persistDraft() { + try { + localStorage.setItem(STORAGE_KEY.value, JSON.stringify(draft.value)) + } catch { /* quota exceeded, ignore */ } + } + + // 深度监听草稿变更 → 自动持久化 + watch(draft, persistDraft, { deep: true }) + + // ── 从 localStorage 恢复 ───────────────── + function tryRestoreLocal(): boolean { + try { + const saved = localStorage.getItem(STORAGE_KEY.value) + if (saved) { + const parsed = JSON.parse(saved) + Object.assign(draft.value, parsed) + return true + } + } catch { /* corrupt data, ignore */ } + return false + } + + // ── 从后端 API 恢复 ────────────────────── + async function restore(): Promise { + const urlRecordId = route.params.recordId as string + if (urlRecordId) { + recordId.value = Number(urlRecordId) + } + + // 先尝试 localStorage 快恢复 + if (tryRestoreLocal()) { + restored.value = true + } + + if (!recordId.value) return false + + loading.value = true + try { + const res: any = await api.get(`/poster/records/${recordId.value}`) + const data = res?.data ?? res + if (data?.code === 0 && data?.data) { + const record = data.data + // 用后端数据填充缺失字段 + if (!draft.value.productId && record.productId) { + draft.value.productId = record.productId + } + if (!draft.value.caseUploadId && record.caseUploadId) { + draft.value.caseUploadId = record.caseUploadId + } + if (!draft.value.templateId && record.templateId) { + draft.value.templateId = record.templateId + } + if (record.exportSize) { + draft.value.exportSize = record.exportSize + } + if (record.copyContent) { + draft.value.copyContent = record.copyContent + } + if (record.extraData?.useMaskedData !== undefined) { + draft.value.useMaskedData = record.extraData.useMaskedData + } + restored.value = true + return true + } + } catch (e) { + console.warn('恢复海报工作区失败:', e) + } finally { + loading.value = false + } + return false + } + + // ── URL 同步 ───────────────────────────── + function syncUrl() { + if (recordId.value) { + const target = `/poster/${recordId.value}` + if (route.path !== target) { + router.replace(target) + } + } + } + + function setRecordId(id: number) { + recordId.value = id + persistDraft() + syncUrl() + } + + // ── 重置 ───────────────────────────────── + function reset() { + // 清理 localStorage + try { + localStorage.removeItem(STORAGE_KEY.value) + } catch { /* ignore */ } + + recordId.value = null + draft.value = createEmptyDraft() + restored.value = false + router.replace('/poster') + } + + // ── 完成进度计算 ───────────────────────── + const completionItems = computed(() => { + const d = draft.value + return [ + { + key: 'product', + label: '选择产品', + done: !!d.productId, + blocking: true, + }, + { + key: 'upload', + label: '上传计划书', + done: d.parseStatus === 'parsed' && d.dataConfirmed, + blocking: true, + }, + { + key: 'copy', + label: '编辑文案', + done: !!d.copyContent?.headline, + blocking: true, + }, + { + key: 'compliance', + label: '合规确认', + done: d.complianceConfirmed, + blocking: true, + }, + ] + }) + + const completedCount = computed(() => + completionItems.value.filter(i => i.done).length + ) + + const totalCount = computed(() => completionItems.value.length) + + const completionText = computed(() => { + const remaining = completionItems.value.filter(i => !i.done) + if (remaining.length === 0) return '所有项目已完成,可以生成海报' + return `已完成 ${completedCount.value}/${totalCount.value} 项,还需${remaining.map(i => i.label).join('、')}` + }) + + // ── 主操作按钮状态 ────────────────────── + type ActionState = + | 'select-product' + | 'upload-data' + | 'confirm-data' + | 'generate-copy' + | 'confirm-compliance' + | 'generate-poster' + | 'download-poster' + | 'generating' + + const actionState = computed(() => { + const d = draft.value + if (d.taskStatus === 'submitting' || d.taskStatus === 'queued' || d.taskStatus === 'generating') { + return 'generating' + } + if (d.taskStatus === 'done' && d.posterUrl) { + return 'download-poster' + } + if (!d.productId) return 'select-product' + if (d.parseStatus !== 'parsed' || !d.dataConfirmed) return 'upload-data' + if (!d.copyContent?.headline) return 'generate-copy' + if (!d.complianceConfirmed) return 'confirm-compliance' + return 'generate-poster' + }) + + const actionLabel = computed(() => { + const labels: Record = { + 'select-product': '选择产品', + 'upload-data': '上传计划书', + 'confirm-data': '确认数据', + 'generate-copy': '生成文案', + 'confirm-compliance': '确认合规', + 'generate-poster': '生成海报', + 'download-poster': '下载海报', + 'generating': '生成中...', + } + return labels[actionState.value] + }) + + const canGenerate = computed(() => + actionState.value === 'generate-poster' || actionState.value === 'download-poster' + ) + + return { + recordId, + draft, + loading, + restored, + restore, + reset, + setRecordId, + persistDraft, + completionItems, + completedCount, + totalCount, + completionText, + actionState, + actionLabel, + canGenerate, + } +} diff --git a/frontend/src/pages/PosterPage.vue b/frontend/src/pages/PosterPage.vue index aac805a..d16a510 100644 --- a/frontend/src/pages/PosterPage.vue +++ b/frontend/src/pages/PosterPage.vue @@ -1,267 +1,353 @@