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 @@
-