From 573210f9c3cd94a78d6af6f325918ed5f596e440 Mon Sep 17 00:00:00 2001 From: wsb1224 Date: Thu, 30 Jul 2026 10:30:33 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B5=B7=E6=8A=A5=E7=94=9F?= =?UTF-8?q?=E6=88=90=E8=BF=87=E7=A8=8B=E7=9A=84BUG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/insurance/generation/celery_tasks.py | 13 +- api/insurance/ppt/comparison.py | 7 +- api/insurance/ppt/routes.py | 154 ++++-- .../ppt/scripts/fast_pptx_renderer.py | 191 ++++--- .../poster/workspace/PosterActionBar.vue | 9 +- frontend/src/pages/PosterPage.vue | 39 +- .../src/pages/components/ppt/PptGenerate.vue | 9 +- .../src/pages/components/ppt/PptUpload.vue | 491 +++++++++++++----- 8 files changed, 660 insertions(+), 253 deletions(-) diff --git a/api/insurance/generation/celery_tasks.py b/api/insurance/generation/celery_tasks.py index 7ac08d9..a57ba4d 100644 --- a/api/insurance/generation/celery_tasks.py +++ b/api/insurance/generation/celery_tasks.py @@ -348,7 +348,14 @@ def _execute_ppt_generate(task_id: str): scenario, ) generation_mode = generation_mode_for_scenario(scenario) - comparison = build_comparison_contract(all_normalized, mode=generation_mode) + try: + comparison = build_comparison_contract(all_normalized, mode=generation_mode) + except ValueError as ve: + _update_task_status(task_id, status="failed", + error_code="comparison_validation_error", + error_message=str(ve), + finished_at=datetime.now()) + return _update_task_status(task_id, stage="loading_template", progress=30, message="加载模板") @@ -706,7 +713,8 @@ def _execute_ppt_regenerate(task_id: str): # 从 deck 中提取渲染参数 theme = deck.get("stylePreset", "broker") - normalized_data = deck.get("products", [{}])[0] if deck.get("products") else {} + all_products = deck.get("products", []) + normalized_data = all_products[0] if all_products else {} company_info = deck.get("company") template_config = deck.get("templateConfig") comparison = deck.get("comparison") @@ -717,6 +725,7 @@ def _execute_ppt_regenerate(task_id: str): normalized_data, output_path, theme=theme, company_info=company_info, template_config=template_config, + all_products=all_products if len(all_products) > 1 else None, comparison=comparison, generation_mode=generation_mode, scenario=scenario, diff --git a/api/insurance/ppt/comparison.py b/api/insurance/ppt/comparison.py index a42a8bd..cf9500a 100644 --- a/api/insurance/ppt/comparison.py +++ b/api/insurance/ppt/comparison.py @@ -7,7 +7,7 @@ from insurance.ppt.irr import compute_irr_ma, ia_irr_cap DEFAULT_COMPARISON_YEARS = [5, 10, 20, 30] -MAX_COMPARISON_PRODUCTS = 4 +MAX_COMPARISON_PRODUCTS = 10 SCENARIO_SINGLE_SAVINGS = "single_savings" SCENARIO_MULTI_SAVINGS = "multi_savings_comparison" @@ -210,7 +210,10 @@ def build_scenario_slides(products: list[dict], scenario: str) -> list[dict]: page_types = ["cover", "company", "narrative", "chart"] if len(products) > 1: - page_types.extend(["compare", "synergy"]) + page_types.append("compare") + kinds = {str(p.get("kind") or "").lower() for p in products} + if len(kinds) > 1: + page_types.append("synergy") page_types.extend(["table", "conclusion", "closing"]) return [{"pageType": page_type} for page_type in page_types] diff --git a/api/insurance/ppt/routes.py b/api/insurance/ppt/routes.py index a095a47..4279f99 100644 --- a/api/insurance/ppt/routes.py +++ b/api/insurance/ppt/routes.py @@ -66,7 +66,15 @@ def render_options(): @ppt_bp.route("/upload", methods=["POST"]) @jwt_required def upload_pdfs(): - """上传 PDF 文件并创建会话。""" + """上传 PDF 文件并创建会话。 + + 支持 1~10 份计划书,五组并行数组(files/types/companies/products/passwords)长度必须一致。 + 采用"先校验后写入"的原子上传策略:任一文件校验失败时不创建会话。 + """ + from flask import jsonify as _jsonify + MAX_UPLOAD_FILES = 10 + VALID_TYPES = {"savings", "ci", "iul"} + user_id = str(getattr(request, "user_id", "guest")) files = request.files.getlist("files") types = request.form.getlist("types") @@ -74,70 +82,98 @@ def upload_pdfs(): products = request.form.getlist("products") passwords = request.form.getlist("passwords") + # ── 基础参数校验 ── if not files: - return error(ErrorCode.PARAM_ERROR, "未上传文件") + return error(ErrorCode.PARAM_ERROR, "请至少上传 1 份计划书") + if len(files) > MAX_UPLOAD_FILES: + return error(ErrorCode.PARAM_ERROR, f"最多支持上传 {MAX_UPLOAD_FILES} 份计划书") + for name, arr in [("types", types), ("companies", companies), + ("products", products), ("passwords", passwords)]: + if len(arr) != len(files): + return error(ErrorCode.PARAM_ERROR, f"{name} 数组长度与 files 不一致") + for i, t in enumerate(types): + if t not in VALID_TYPES: + return error(ErrorCode.PARAM_ERROR, f"第 {i + 1} 份文件的险种 '{t}' 不合法") - # 创建上传目录(使用持久化存储) - from insurance.config import get_storage_root - upload_dir = os.path.join(get_storage_root(), "uploads", "ppt", user_id) - os.makedirs(upload_dir, exist_ok=True) - - file_records = [] - validation_errors = [] + # ── 第一阶段:校验全部文件,不写入磁盘 ── from insurance.utils.security import prepare_pdf_upload + from insurance.models.ppt_config import PptCompany, PptProduct + + validated = [] # (f, pdf_bytes, file_record) 通过校验的文件 + file_errors = [] # 结构化错误列表 + for i, f in enumerate(files): + fname = f.filename or f"文件{i + 1}" if not f.filename or not f.filename.lower().endswith(".pdf"): + file_errors.append({"index": i, "fileName": fname, "field": "file", "message": "仅支持 PDF 格式"}) continue - plan_type = types[i] if i < len(types) else "savings" + + plan_type = types[i] company_id = companies[i] if i < len(companies) else "" product_id = products[i] if i < len(products) else "" - from insurance.models.ppt_config import PptCompany, PptProduct - company = ( - PptCompany.query.filter_by(id=company_id, status=1).first() - if company_id else None - ) - product = ( - PptProduct.query.filter_by(id=product_id, status=1).first() - if product_id else None - ) + password = passwords[i] if i < len(passwords) else "" + + # 保司校验 + company = PptCompany.query.filter_by(id=company_id, status=1).first() if company_id else None if company_id and not company: - validation_errors.append(f"{f.filename}: 所选保司不存在或已停用") + file_errors.append({"index": i, "fileName": fname, "field": "company", "message": "所选保司不存在或已停用"}) continue + + # 产品校验 + product = PptProduct.query.filter_by(id=product_id, status=1).first() if product_id else None if product_id and ( not product or product.plan_type != plan_type or (company_id and product.company_id != company_id) ): - validation_errors.append(f"{f.filename}: 所选产品与险种或保司不匹配") + file_errors.append({"index": i, "fileName": fname, "field": "product", "message": "所选产品与险种或保司不匹配"}) continue if product and not company_id: company_id = product.company_id - # 文件安全校验(SEC-P1-01) - password = passwords[i] if i < len(passwords) else "" + + # PDF 安全校验 is_valid, err_msg, pdf_bytes = prepare_pdf_upload(f, password) if not is_valid: - validation_errors.append(f"{f.filename}: {err_msg}") + file_errors.append({"index": i, "fileName": fname, "field": "password", "message": err_msg or "PDF 校验失败"}) continue - # 保存文件 - filename = f"{uuid.uuid4().hex[:8]}_{f.filename}" - filepath = os.path.join(upload_dir, filename) - with open(filepath, "wb") as output: - output.write(pdf_bytes) - file_records.append({ - "path": filepath, - "name": f.filename, + validated.append((f, pdf_bytes, { + "name": fname, "type": plan_type, "companyId": company_id, "productId": product_id, - }) + })) - if not file_records: - if validation_errors: - return error(ErrorCode.FILE_FORMAT_ERROR, "; ".join(validation_errors)) + # 任一文件失败则整体拒绝 + if file_errors: + return _jsonify({ + "code": ErrorCode.FILE_FORMAT_ERROR, + "message": "部分计划书校验失败", + "data": {"fileErrors": file_errors}, + }), 400 + + if not validated: return error(ErrorCode.FILE_FORMAT_ERROR, "无有效 PDF 文件") - # 创建会话 + # ── 第二阶段:全部通过,统一写入磁盘并创建会话 ── + from insurance.config import get_storage_root + upload_dir = os.path.join(get_storage_root(), "uploads", "ppt", user_id) + os.makedirs(upload_dir, exist_ok=True) + + file_records = [] + for f, pdf_bytes, meta in validated: + filename = f"{uuid.uuid4().hex[:8]}_{meta['name']}" + filepath = os.path.join(upload_dir, filename) + with open(filepath, "wb") as output: + output.write(pdf_bytes) + file_records.append({ + "path": filepath, + "name": meta["name"], + "type": meta["type"], + "companyId": meta["companyId"], + "productId": meta["productId"], + }) + session_id = uuid.uuid4().hex from insurance.models.ppt_session import PptSession session = PptSession( @@ -563,6 +599,50 @@ def validate_extraction(session_id): error_count = sum(1 for i in all_issues if i["severity"] == "error") warn_count = sum(1 for i in all_issues if i["severity"] == "warn") + # ── 跨文件兼容性校验(同险种比较场景) ── + valid_extractions = [ + e for e in extractions + if e.get("status") in ("success", "partial") and e.get("data") + ] + if len(valid_extractions) >= 2: + try: + from insurance.ppt.comparison import ( + detect_generation_scenario, + generation_mode_for_scenario, + build_comparison_contract, + SCENARIO_GENERIC_COMPARE, + SCENARIO_MULTI_SAVINGS, + ) + normalized_for_check = [] + from insurance.ppt.normalizer import normalize_savings_plan as _ns, normalize_ci_plan as _nc, normalize_iul_plan as _ni + for ext in valid_extractions: + data = ext["data"] + pt = (ext.get("planType") or data.get("product_type") or "savings").lower() + try: + if pt == "ci": + n = _nc(data, ext.get("pdfPath")) + elif pt == "iul": + n = _ni(data, ext.get("pdfPath")) + else: + n = _ns(data, ext.get("pdfPath")) + n["kind"] = pt + normalized_for_check.append(n) + except Exception: + pass + + if len(normalized_for_check) >= 2: + scenario = detect_generation_scenario(normalized_for_check) + mode = generation_mode_for_scenario(scenario) + try: + contract = build_comparison_contract(normalized_for_check, mode=mode) + for w in contract.get("warnings", []): + all_issues.append({"field": "comparison", "severity": "warn", "message": w}) + except ValueError as ve: + all_issues.append({"field": "comparison", "severity": "error", "message": str(ve)}) + error_count += 1 + except Exception as e: + logger.warning("跨文件兼容性校验异常: %s", e) + return success({ "sessionId": session_id, "validated": error_count == 0, diff --git a/api/insurance/ppt/scripts/fast_pptx_renderer.py b/api/insurance/ppt/scripts/fast_pptx_renderer.py index 9dafade..82ca7c8 100644 --- a/api/insurance/ppt/scripts/fast_pptx_renderer.py +++ b/api/insurance/ppt/scripts/fast_pptx_renderer.py @@ -1298,7 +1298,7 @@ def add_slide_alignment_table(prs, deck, colors, meta): def add_slide_compare(prs, deck, colors, meta): - """对比页:双产品对比。""" + """对比页:多产品对比,支持 1~10 份。""" slide = add_blank_slide(prs) add_bg(slide, colors) @@ -1319,29 +1319,65 @@ def add_slide_compare(prs, deck, colors, meta): add_paragraphs(slide, Inches(0.85), Inches(4.5), Inches(11.4), Inches(1.5), ["保证部分是底线,非保证部分是弹性。两者合计才是长期回报的完整图景。"], size=15, bullet=True, colors=colors) - else: - # 双产品对比 - left_p = _product_summary(products[0]) - right_p = _product_summary(products[1]) - add_fact_card(slide, Inches(0.8), Inches(1.45), Inches(5.45), Inches(3.0), - left_p["productName"], f"年缴 US${money(left_p['annualPremium'])}\n" - f"总投入 US${money(left_p['totalPremium'])}\n" - f"回本约第 {left_p['paybackYear'] or '?'} 年", - fill_color=colors["accent_light"], colors=colors) - add_fact_card(slide, Inches(6.95), Inches(1.45), Inches(5.45), Inches(3.0), - right_p["productName"], f"年缴 US${money(right_p['annualPremium'])}\n" - f"总投入 US${money(right_p['totalPremium'])}\n" - f"回本约第 {right_p['paybackYear'] or '?'} 年", - fill_color=colors["good_light"], colors=colors) - add_paragraphs(slide, Inches(0.85), Inches(5.0), Inches(11.4), Inches(1.5), - ["组合的价值不是叠加产品数量,而是把功能拆到最清楚。"], + elif len(products) <= 4: + # 2~4 产品:卡片网格布局 + card_w = Inches(5.45) if len(products) == 2 else Inches(5.45) + card_h = Inches(2.5) if len(products) <= 2 else Inches(2.0) + fill_colors = [colors["accent_light"], colors["good_light"], + colors["accent_light"], colors["good_light"]] + positions = [ + (Inches(0.8), Inches(1.45)), + (Inches(6.95), Inches(1.45)), + (Inches(0.8), Inches(3.7)), + (Inches(6.95), Inches(3.7)), + ] + for i, product in enumerate(products): + s = _product_summary(product) + x, y = positions[i] + add_fact_card(slide, x, y, card_w, card_h, + s["productName"] or f"产品 {i + 1}", + f"年缴 ${money(s['annualPremium'])}\n" + f"总投入 ${money(s['totalPremium'])}\n" + f"回本约第 {s['paybackYear'] or '?'} 年", + fill_color=fill_colors[i % len(fill_colors)], colors=colors) + bottom_y = Inches(6.0) if len(products) <= 2 else Inches(6.0) + add_paragraphs(slide, Inches(0.85), bottom_y, Inches(11.4), Inches(1.0), + ["对比重点在相同保单年度下的保证价值、总退保价值和回本时间。"], size=15, bullet=True, colors=colors) + else: + # 5+ 产品:表格布局 + from pptx.util import Emu + rows = len(products) + 1 + cols = 5 + table_left, table_top = Inches(0.6), Inches(1.5) + table_w, table_h = Inches(12.0), Inches(0.4 * rows + 0.2) + table_shape = slide.shapes.add_table(rows, cols, table_left, table_top, table_w, table_h) + table = table_shape.table + headers = ["产品", "年缴保费", "总投入", "回本年", "期末倍数"] + for c, h in enumerate(headers): + table.cell(0, c).text = h + _style_cell(table.cell(0, c), bold=True, bg=colors.get("accent", RGBColor(59, 122, 87))) + for r, product in enumerate(products, 1): + s = _product_summary(product) + values = [ + s["productName"] or f"产品 {r}", + f"${money(s['annualPremium'])}", + f"${money(s['totalPremium'])}", + str(s["paybackYear"] or "-"), + s["multiple"], + ] + for c, v in enumerate(values): + table.cell(r, c).text = v + _style_cell(table.cell(r, c), bold=(c == 0)) + add_paragraphs(slide, Inches(0.85), Inches(5.5), Inches(11.4), Inches(1.0), + ["以上为各产品在相同口径下的核心指标汇总,具体差异请结合正式计划书逐项核对。"], + size=14, bullet=True, colors=colors) add_footer(slide, "", colors=colors) def add_slide_synergy(prs, deck, colors, meta): - """协同关系页:多产品如何配合。""" + """协同关系页:多产品如何配合,支持 N 份。""" slide = add_blank_slide(prs) add_bg(slide, colors) @@ -1350,24 +1386,42 @@ def add_slide_synergy(prs, deck, colors, meta): add_title(slide, title, meta.get("narrativeHint", "功能分层,互不冲突"), colors=colors) if len(products) >= 2: - left_p = _product_summary(products[0]) - right_p = _product_summary(products[1]) - left_kind = products[0].get("kind", "savings") - right_kind = products[1].get("kind", "savings") - - left_label = _kind_label(left_kind) - right_label = _kind_label(right_kind) - - add_fact_card(slide, Inches(0.8), Inches(1.45), Inches(5.45), Inches(2.05), - f"{left_label} 负责", _kind_synergy_left(left_kind, left_p), - fill_color=colors["accent_light"], colors=colors) - add_fact_card(slide, Inches(6.95), Inches(1.45), Inches(5.45), Inches(2.05), - f"{right_label} 负责", _kind_synergy_right(right_kind, right_p), - fill_color=colors["good_light"], colors=colors) - add_paragraphs(slide, Inches(0.85), Inches(4.0), Inches(11.3), Inches(2.0), - [f"{left_label}和{right_label}不是重复配置,而是功能分层。", - "先看谁扛风险,再看谁承接未来目标。"], - size=15, bullet=True, colors=colors) + fill_colors = [colors["accent_light"], colors["good_light"], + colors["accent_light"], colors["good_light"]] + if len(products) <= 2: + # 双产品:左右卡片 + left_p = _product_summary(products[0]) + right_p = _product_summary(products[1]) + left_kind = products[0].get("kind", "savings") + right_kind = products[1].get("kind", "savings") + left_label = _kind_label(left_kind) + right_label = _kind_label(right_kind) + add_fact_card(slide, Inches(0.8), Inches(1.45), Inches(5.45), Inches(2.05), + f"{left_label} 负责", _kind_synergy_left(left_kind, left_p), + fill_color=colors["accent_light"], colors=colors) + add_fact_card(slide, Inches(6.95), Inches(1.45), Inches(5.45), Inches(2.05), + f"{right_label} 负责", _kind_synergy_right(right_kind, right_p), + fill_color=colors["good_light"], colors=colors) + add_paragraphs(slide, Inches(0.85), Inches(4.0), Inches(11.3), Inches(2.0), + [f"{left_label}和{right_label}不是重复配置,而是功能分层。", + "先看谁扛风险,再看谁承接未来目标。"], + size=15, bullet=True, colors=colors) + else: + # 3+ 产品:垂直列表 + card_h = min(1.3, 4.5 / len(products)) + for i, product in enumerate(products): + s = _product_summary(product) + kind = product.get("kind", "savings") + label = _kind_label(kind) + y = 1.45 + i * (card_h + 0.15) + body = _kind_synergy_left(kind, s) if i % 2 == 0 else _kind_synergy_right(kind, s) + add_fact_card(slide, Inches(0.8), Inches(y), Inches(11.5), Inches(card_h), + f"{label}|{s['productName'] or f'产品 {i + 1}'}", + body.replace("\n", " · "), + fill_color=fill_colors[i % len(fill_colors)], colors=colors) + add_paragraphs(slide, Inches(0.85), Inches(5.8), Inches(11.3), Inches(0.8), + ["各产品按功能分层配置,不重复承担相同风险。"], + size=15, bullet=True, colors=colors) else: add_paragraphs(slide, Inches(1), Inches(3), Inches(10), Inches(2), ["单产品方案,无需展示协同关系。"], @@ -1397,37 +1451,56 @@ def _kind_synergy_right(kind, s): def add_slide_conclusion(prs, deck, colors, meta): - """结论页:总结 + 核心数据。""" + """结论页:总结 + 核心数据,支持 N 份产品。""" slide = add_blank_slide(prs) add_bg(slide, colors) products = deck.get("products", []) - product = products[0] if products else {} - s = _product_summary(product) - title = meta.get("title", "结论") add_title(slide, title, meta.get("narrativeHint", ""), colors=colors) - points = [] - if s["paybackYear"]: - points.append(f"总投入 US${money(s['totalPremium'])},回本约第 {s['paybackYear']} 年") - points.append(f"期末退保价值约 US${money(s['finalValue'])},倍数 {s['multiple']}") - if len(products) >= 2: - points.append("组合方案把家庭资产目标拆成不同的功能层") + if len(products) <= 1: + product = products[0] if products else {} + s = _product_summary(product) + points = [] + if s["paybackYear"]: + points.append(f"总投入 ${money(s['totalPremium'])},回本约第 {s['paybackYear']} 年") + points.append(f"期末退保价值约 ${money(s['finalValue'])},倍数 {s['multiple']}") + add_paragraphs(slide, Inches(0.85), Inches(1.5), Inches(6.0), Inches(3.0), + points, size=15, bullet=True, colors=colors) + cards = [ + ("总投入", f"${money(s['totalPremium'])}"), + ("期末价值", f"${money(s['finalValue'])}"), + ("倍数", s["multiple"]), + ] + for i, (label, value) in enumerate(cards): + x = Inches(0.9 + i * 2.8) + add_card(slide, x, Inches(5.0), Inches(2.4), Inches(1.1), + label, value, colors=colors, accent=(i == 2)) + else: + # 多产品:逐产品摘要 + 总览卡片 + lines = [] + for i, product in enumerate(products): + s = _product_summary(product) + name = s["productName"] or f"产品 {i + 1}" + line = f"{name}:投入 ${money(s['totalPremium'])},回本第 {s['paybackYear'] or '?'} 年,倍数 {s['multiple']}" + lines.append(line) + if len(products) >= 2: + lines.append("组合方案把家庭资产目标拆成不同的功能层") + add_paragraphs(slide, Inches(0.85), Inches(1.5), Inches(11.4), Inches(3.0), + lines, size=14, bullet=True, colors=colors) - add_paragraphs(slide, Inches(0.85), Inches(1.5), Inches(6.0), Inches(3.0), - points, size=15, bullet=True, colors=colors) - - # 核心指标卡片 - cards = [ - ("总投入", f"US${money(s['totalPremium'])}"), - ("期末价值", f"US${money(s['finalValue'])}"), - ("倍数", s["multiple"]), - ] - for i, (label, value) in enumerate(cards): - x = Inches(0.9 + i * 2.8) - add_card(slide, x, Inches(5.0), Inches(2.4), Inches(1.1), - label, value, colors=colors, accent=(i == 2)) + # 指标卡片:展示总投入和产品数 + total_investment = sum(_product_summary(p)["totalPremium"] for p in products) + cards = [ + ("计划书数量", f"{len(products)} 份"), + ("总投入", f"${money(total_investment)}"), + ("对比维度", "按保单年度"), + ] + for i, (label, value) in enumerate(cards): + x = Inches(0.9 + i * 3.8) + add_card(slide, x, Inches(5.0), Inches(3.2), Inches(1.1), + label, value, colors=colors, accent=(i == 2)) add_footer(slide, "", colors=colors) diff --git a/frontend/src/components/poster/workspace/PosterActionBar.vue b/frontend/src/components/poster/workspace/PosterActionBar.vue index 73908e0..7815f64 100644 --- a/frontend/src/components/poster/workspace/PosterActionBar.vue +++ b/frontend/src/components/poster/workspace/PosterActionBar.vue @@ -22,6 +22,12 @@ + 新建海报 + + 重新生成 @@ -32,7 +38,7 @@ :loading="isGenerating" @click="$emit('action')" > - {{ actionLabel }} + {{ isGenerating ? '生成中...' : actionLabel }} @@ -56,6 +62,7 @@ defineEmits<{ action: [] download: [] regenerate: [] + 'new-poster': [] }>() const isGenerating = computed(() => diff --git a/frontend/src/pages/PosterPage.vue b/frontend/src/pages/PosterPage.vue index 281dbd1..5bba6b9 100644 --- a/frontend/src/pages/PosterPage.vue +++ b/frontend/src/pages/PosterPage.vue @@ -79,6 +79,7 @@ @action="onAction" @download="onDownload" @regenerate="onRegenerate" + @new-poster="onNewPoster" /> @@ -233,22 +234,28 @@ function startPolling(id: number) { } try { const res: any = await posterApi.getRecord(id) - const data = res?.data - ws.draft.value.taskStatus = data?.taskStatus || 'generating' - ws.draft.value.taskProgress = data?.taskProgress || 0 - ws.draft.value.taskError = data?.taskError || null + // 响应拦截器返回 {code: 0, data: {...}},取 data 字段 + const record = res?.data ?? res + const status = record?.taskStatus || record?.task_status || 'generating' + const progress = record?.taskProgress ?? record?.task_progress ?? 0 + const error = record?.taskError || record?.task_error || null - if (ws.draft.value.taskStatus === 'done') { + ws.draft.value.taskStatus = status + ws.draft.value.taskProgress = progress + ws.draft.value.taskError = error + + // 终态:停止轮询 + if (status === 'done' || status === 'failed') { stopPolling() - try { - const blob = await posterApi.downloadPoster(id) - ws.draft.value.posterUrl = URL.createObjectURL(blob) - } catch { - ws.draft.value.taskStatus = 'failed' - ws.draft.value.taskError = '图片下载失败' + if (status === 'done') { + try { + const blob = await posterApi.downloadPoster(id) + ws.draft.value.posterUrl = URL.createObjectURL(blob) + } catch { + ws.draft.value.taskStatus = 'failed' + ws.draft.value.taskError = '图片下载失败' + } } - } else if (ws.draft.value.taskStatus === 'failed') { - stopPolling() } } catch { // 轮询失败时继续 @@ -287,6 +294,12 @@ function onRegenerate() { pollRetries = 0 } +// ── 新建海报(重置全部状态,开始全新流程)──── +function onNewPoster() { + stopPolling() + ws.reset() +} + // ── 重置 ───────────────────────────────── async function onReset() { try { diff --git a/frontend/src/pages/components/ppt/PptGenerate.vue b/frontend/src/pages/components/ppt/PptGenerate.vue index e1f3159..1b6b966 100644 --- a/frontend/src/pages/components/ppt/PptGenerate.vue +++ b/frontend/src/pages/components/ppt/PptGenerate.vue @@ -165,7 +165,11 @@ const currentScenario = computed(() => { if (savingsCount >= 1 && iulCount >= 1 && savingsCount + iulCount === types.length) { return 'savings_iul_comprehensive' } - return '' + // 通用场景:单份非储蓄险、同险种比较、跨险种组合 + if (types.length === 1) return 'generic_single' + const uniqueTypes = new Set(types) + if (uniqueTypes.size === 1) return 'generic_compare' + return 'generic_portfolio' }) const availableTemplates = computed(() => templates.value.filter(template => { if ( @@ -209,6 +213,9 @@ function scenarioLabel(s: string) { single_savings: '单一储蓄方案', multi_savings_comparison: '多储蓄对比', savings_iul_comprehensive: '储蓄 + IUL 综合', + generic_single: '单份计划分析', + generic_compare: '同险种计划对比', + generic_portfolio: '跨险种组合方案', } return map[s] || '' } diff --git a/frontend/src/pages/components/ppt/PptUpload.vue b/frontend/src/pages/components/ppt/PptUpload.vue index 5791367..4a03c6f 100644 --- a/frontend/src/pages/components/ppt/PptUpload.vue +++ b/frontend/src/pages/components/ppt/PptUpload.vue @@ -8,70 +8,94 @@

- 上传文件仅用于本次生成,不会分享给第三方。受密码保护的 PDF 请填写密码,仅用于本次解密。 + 文件仅用于本次生成;密码仅用于本次解密,不会持久保存。

- + - 开始上传并解析 + {{ submitLabel }}