修复海报生成过程的BUG
This commit is contained in:
parent
68122c5bb4
commit
573210f9c3
@ -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,
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -22,6 +22,12 @@
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="draft.taskStatus === 'done'"
|
||||
@click="$emit('new-poster')"
|
||||
>
|
||||
新建海报
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="draft.taskStatus === 'failed'"
|
||||
@click="$emit('regenerate')"
|
||||
>
|
||||
重新生成
|
||||
@ -32,7 +38,7 @@
|
||||
:loading="isGenerating"
|
||||
@click="$emit('action')"
|
||||
>
|
||||
{{ actionLabel }}
|
||||
{{ isGenerating ? '生成中...' : actionLabel }}
|
||||
</el-button>
|
||||
</div>
|
||||
</footer>
|
||||
@ -56,6 +62,7 @@ defineEmits<{
|
||||
action: []
|
||||
download: []
|
||||
regenerate: []
|
||||
'new-poster': []
|
||||
}>()
|
||||
|
||||
const isGenerating = computed(() =>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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] || ''
|
||||
}
|
||||
|
||||
@ -8,70 +8,94 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="upload-ports">
|
||||
<div v-for="port in ports" :key="port.type" class="upload-port">
|
||||
<div class="port-header">
|
||||
<el-tag :type="port.tagType" size="small" effect="plain">{{ port.label }}</el-tag>
|
||||
<span class="port-file" v-if="port.file.value">{{ port.file.value.name }}</span>
|
||||
<span class="port-empty" v-else>未选择文件</span>
|
||||
<!-- 场景摘要 -->
|
||||
<div class="scenario-bar">
|
||||
<div class="scenario-left">
|
||||
<el-tag :type="scenarioTagType" size="small" effect="plain">{{ scenarioLabel }}</el-tag>
|
||||
<span class="scenario-hint">{{ scenarioHint }}</span>
|
||||
</div>
|
||||
<span class="scenario-count">已添加 {{ fileCount }} / {{ MAX_FILES }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 条目列表 -->
|
||||
<div class="items-list">
|
||||
<div v-for="(item, idx) in uploadItems" :key="item.id" class="item-card">
|
||||
<div class="item-header">
|
||||
<span class="item-label">方案 {{ planLetters[idx] }}</span>
|
||||
<el-select v-model="item.planType" size="small" class="type-select"
|
||||
@change="handleTypeChange(item)">
|
||||
<el-option label="储蓄险" value="savings" />
|
||||
<el-option label="重疾险" value="ci" />
|
||||
<el-option label="IUL" value="iul" />
|
||||
</el-select>
|
||||
<el-button v-if="uploadItems.length > 1" type="danger" text size="small"
|
||||
:aria-label="`删除方案 ${planLetters[idx]}`"
|
||||
@click="removeItem(item.id)">
|
||||
<el-icon><Delete /></el-icon> 删除
|
||||
</el-button>
|
||||
</div>
|
||||
<el-upload
|
||||
v-if="!port.file.value"
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
accept=".pdf"
|
||||
:on-change="(file: any) => handleFileChange(file, port.type)"
|
||||
:on-remove="() => removeFile(port.type)"
|
||||
class="port-upload"
|
||||
>
|
||||
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||||
<div class="el-upload__text">拖拽或 <em>点击上传</em></div>
|
||||
</el-upload>
|
||||
<div class="port-options">
|
||||
<el-select v-model="port.company.value" placeholder="保险公司" clearable size="small">
|
||||
|
||||
<!-- PDF 文件区 -->
|
||||
<div class="item-file">
|
||||
<div v-if="item.file" class="file-selected">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span class="file-name">{{ item.file.name }}</span>
|
||||
<span class="file-size">{{ formatSize(item.file.size) }}</span>
|
||||
<el-button type="primary" text size="small" @click="replaceFile(item)">重新选择</el-button>
|
||||
</div>
|
||||
<el-upload v-else drag :auto-upload="false" :limit="1" accept=".pdf"
|
||||
:on-change="(file: any) => handleFileSelect(file, item)"
|
||||
class="file-upload">
|
||||
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||||
<div class="el-upload__text">拖拽 PDF 到此处,或 <em>点击选择</em></div>
|
||||
</el-upload>
|
||||
<div v-if="item.errors.file" class="item-error">{{ item.errors.file }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 业务字段 -->
|
||||
<div class="item-fields">
|
||||
<el-select v-model="item.companyId" placeholder="保险公司" clearable size="small"
|
||||
@change="handleCompanyChange(item)">
|
||||
<el-option v-for="c in companies" :key="c.id" :label="c.displayName" :value="c.id" />
|
||||
</el-select>
|
||||
<el-select v-model="port.product.value" placeholder="产品(可选)" clearable size="small">
|
||||
<el-option v-for="p in productsFor(port.type, port.company.value)" :key="p.id" :label="p.displayName" :value="p.id" />
|
||||
<el-select v-model="item.productId" placeholder="产品(可选)" clearable size="small"
|
||||
@change="(val: string) => handleProductChange(item, val)">
|
||||
<el-option v-for="p in productsFor(item.planType, item.companyId)" :key="p.id" :label="p.displayName" :value="p.id" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="port.password.value"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
placeholder="PDF 密码(如有)"
|
||||
size="small"
|
||||
/>
|
||||
<el-input v-model="item.password" type="password" show-password
|
||||
autocomplete="new-password" placeholder="PDF 密码(如有)" size="small" />
|
||||
</div>
|
||||
<div v-if="item.errors.general" class="item-error">{{ item.errors.general }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加按钮 -->
|
||||
<div class="add-bar">
|
||||
<el-button :disabled="!canAdd" @click="addItem" plain>
|
||||
<el-icon><Plus /></el-icon> 添加计划书
|
||||
</el-button>
|
||||
<span v-if="!canAdd" class="add-limit">最多支持 {{ MAX_FILES }} 份计划书</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-skeleton>
|
||||
|
||||
<p class="upload-footnote">
|
||||
<el-icon><Lock /></el-icon>
|
||||
上传文件仅用于本次生成,不会分享给第三方。受密码保护的 PDF 请填写密码,仅用于本次解密。
|
||||
文件仅用于本次生成;密码仅用于本次解密,不会持久保存。
|
||||
</p>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="uploading"
|
||||
:disabled="!hasFiles"
|
||||
@click="handleUpload"
|
||||
class="submit-btn"
|
||||
>
|
||||
<el-button type="primary" size="large" :loading="uploading" :disabled="!canSubmit"
|
||||
@click="handleUpload" class="submit-btn">
|
||||
<el-icon><Upload /></el-icon>
|
||||
开始上传并解析
|
||||
{{ submitLabel }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Upload, UploadFilled, Lock } from '@element-plus/icons-vue'
|
||||
import { Upload, UploadFilled, Lock, Plus, Delete, Document } from '@element-plus/icons-vue'
|
||||
import { pptApi } from '@/utils/ppt-api'
|
||||
|
||||
const emit = defineEmits<{
|
||||
@ -80,31 +104,197 @@ const emit = defineEmits<{
|
||||
|
||||
interface Company { id: string; displayName: string }
|
||||
interface Product { id: string; companyId: string; planType: string; displayName: string }
|
||||
type PlanType = 'savings' | 'ci' | 'iul'
|
||||
|
||||
interface UploadPlanItem {
|
||||
id: string
|
||||
file: File | null
|
||||
planType: PlanType
|
||||
companyId: string
|
||||
productId: string
|
||||
password: string
|
||||
errors: { file?: string; password?: string; general?: string }
|
||||
}
|
||||
|
||||
const MAX_FILES = 10
|
||||
const planLetters = 'ABCDEFGHIJ'
|
||||
|
||||
const companies = ref<Company[]>([])
|
||||
const products = ref<Product[]>([])
|
||||
const savingsFile = ref<File | null>(null)
|
||||
const ciFile = ref<File | null>(null)
|
||||
const iulFile = ref<File | null>(null)
|
||||
const savingsCompany = ref('')
|
||||
const ciCompany = ref('')
|
||||
const iulCompany = ref('')
|
||||
const savingsProduct = ref('')
|
||||
const ciProduct = ref('')
|
||||
const iulProduct = ref('')
|
||||
const savingsPassword = ref('')
|
||||
const ciPassword = ref('')
|
||||
const iulPassword = ref('')
|
||||
const loadingOptions = ref(true)
|
||||
const uploading = ref(false)
|
||||
|
||||
const hasFiles = computed(() => savingsFile.value || ciFile.value || iulFile.value)
|
||||
let _idCounter = 0
|
||||
function createEmptyItem(planType: PlanType = 'savings'): UploadPlanItem {
|
||||
return {
|
||||
id: `item-${++_idCounter}`,
|
||||
file: null,
|
||||
planType,
|
||||
companyId: '',
|
||||
productId: '',
|
||||
password: '',
|
||||
errors: {},
|
||||
}
|
||||
}
|
||||
|
||||
const ports = computed(() => [
|
||||
{ type: 'savings', label: '储蓄险', tagType: 'success' as const, file: savingsFile, company: savingsCompany, product: savingsProduct, password: savingsPassword },
|
||||
{ type: 'ci', label: '重疾险', tagType: 'warning' as const, file: ciFile, company: ciCompany, product: ciProduct, password: ciPassword },
|
||||
{ type: 'iul', label: 'IUL', tagType: 'info' as const, file: iulFile, company: iulCompany, product: iulProduct, password: iulPassword },
|
||||
])
|
||||
const uploadItems = ref<UploadPlanItem[]>([createEmptyItem()])
|
||||
|
||||
// ── 计算属性 ──
|
||||
|
||||
const activeItems = computed(() => uploadItems.value.filter(item => item.file))
|
||||
const fileCount = computed(() => activeItems.value.length)
|
||||
const canAdd = computed(() => uploadItems.value.length < MAX_FILES)
|
||||
|
||||
const scenarioLabel = computed(() => {
|
||||
const count = fileCount.value
|
||||
if (count === 0) return '待添加'
|
||||
if (count === 1) return '单份分析'
|
||||
const types = activeItems.value.map(i => i.planType)
|
||||
const uniqueTypes = new Set(types)
|
||||
if (uniqueTypes.size === 1) return '同险种对比'
|
||||
return '组合方案'
|
||||
})
|
||||
|
||||
const scenarioTagType = computed(() => {
|
||||
if (fileCount.value === 0) return 'info' as const
|
||||
if (fileCount.value === 1) return 'success' as const
|
||||
const types = activeItems.value.map(i => i.planType)
|
||||
const uniqueTypes = new Set(types)
|
||||
if (uniqueTypes.size === 1) return 'warning' as const
|
||||
return '' as const
|
||||
})
|
||||
|
||||
const scenarioHint = computed(() => {
|
||||
const count = fileCount.value
|
||||
if (count === 0) return '添加 1~10 份 PDF 计划书'
|
||||
if (count === 1) return '生成一份计划书的客户讲解 PPT'
|
||||
const types = activeItems.value.map(i => i.planType)
|
||||
const uniqueTypes = new Set(types)
|
||||
if (uniqueTypes.size === 1) return '将按相同保单年度和统一口径比较;币种将在解析后校验'
|
||||
return '展示产品分工与现金流关系,不直接排名'
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (fileCount.value === 0) return false
|
||||
return uploadItems.value.every(item => {
|
||||
if (!item.file) return !item.companyId && !item.productId && !item.password
|
||||
return !!item.planType
|
||||
})
|
||||
})
|
||||
|
||||
const submitLabel = computed(() => {
|
||||
if (uploading.value) return `正在上传 ${fileCount.value} 份计划书…`
|
||||
if (fileCount.value === 0) return '开始上传并解析'
|
||||
return `上传 ${fileCount.value} 份并开始解析`
|
||||
})
|
||||
|
||||
// ── 方法 ──
|
||||
|
||||
function addItem() {
|
||||
if (!canAdd.value) return
|
||||
const lastType = uploadItems.value[uploadItems.value.length - 1]?.planType || 'savings'
|
||||
uploadItems.value.push(createEmptyItem(lastType))
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
if (uploadItems.value.length <= 1) {
|
||||
const item = uploadItems.value[0]
|
||||
item.file = null
|
||||
item.companyId = ''
|
||||
item.productId = ''
|
||||
item.password = ''
|
||||
item.errors = {}
|
||||
return
|
||||
}
|
||||
uploadItems.value = uploadItems.value.filter(i => i.id !== id)
|
||||
}
|
||||
|
||||
function handleTypeChange(item: UploadPlanItem) {
|
||||
const validProducts = productsFor(item.planType, item.companyId)
|
||||
if (item.productId && !validProducts.find(p => p.id === item.productId)) {
|
||||
item.productId = ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleCompanyChange(item: UploadPlanItem) {
|
||||
const validProducts = productsFor(item.planType, item.companyId)
|
||||
if (item.productId && !validProducts.find(p => p.id === item.productId)) {
|
||||
item.productId = ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleProductChange(item: UploadPlanItem, productId: string) {
|
||||
if (productId) {
|
||||
const p = products.value.find(i => i.id === productId)
|
||||
if (p) item.companyId = p.companyId
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(file: any, item: UploadPlanItem) {
|
||||
const raw = file.raw || file
|
||||
if (!raw.name.toLowerCase().endsWith('.pdf')) {
|
||||
item.errors.file = '仅支持 PDF 格式'
|
||||
return
|
||||
}
|
||||
item.file = raw
|
||||
item.errors.file = undefined
|
||||
}
|
||||
|
||||
function replaceFile(item: UploadPlanItem) {
|
||||
item.file = null
|
||||
item.errors.file = undefined
|
||||
}
|
||||
|
||||
function productsFor(planType: string, companyId: string) {
|
||||
return products.value.filter(p => p.planType === planType && (!companyId || p.companyId === companyId))
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!canSubmit.value) return
|
||||
uploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
const itemsToSubmit = uploadItems.value.filter(i => i.file)
|
||||
|
||||
itemsToSubmit.forEach(item => {
|
||||
formData.append('files', item.file!)
|
||||
formData.append('types', item.planType)
|
||||
formData.append('companies', item.companyId)
|
||||
formData.append('products', item.productId)
|
||||
formData.append('passwords', item.password)
|
||||
})
|
||||
|
||||
const res: any = await pptApi.upload(formData)
|
||||
const sessionId = res?.data?.sessionId
|
||||
if (sessionId) {
|
||||
uploadItems.value.forEach(i => { i.password = '' })
|
||||
ElMessage.success('上传成功')
|
||||
emit('uploaded', sessionId)
|
||||
} else {
|
||||
ElMessage.error(res?.message || '上传失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.message || e?.message || '上传失败'
|
||||
const fileErrors = e?.response?.data?.data?.fileErrors
|
||||
if (fileErrors && Array.isArray(fileErrors)) {
|
||||
fileErrors.forEach((err: any) => {
|
||||
const idx = err.index
|
||||
if (idx >= 0 && idx < uploadItems.value.length) {
|
||||
uploadItems.value[idx].errors[err.field === 'file' ? 'file' : 'general'] = err.message
|
||||
}
|
||||
})
|
||||
}
|
||||
ElMessage.error(msg)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@ -117,64 +307,6 @@ onMounted(async () => {
|
||||
loadingOptions.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function productsFor(planType: string, companyId: string) {
|
||||
return products.value.filter(p => p.planType === planType && (!companyId || p.companyId === companyId))
|
||||
}
|
||||
|
||||
watch(savingsProduct, id => { const p = products.value.find(i => i.id === id); if (p) savingsCompany.value = p.companyId })
|
||||
watch(ciProduct, id => { const p = products.value.find(i => i.id === id); if (p) ciCompany.value = p.companyId })
|
||||
watch(iulProduct, id => { const p = products.value.find(i => i.id === id); if (p) iulCompany.value = p.companyId })
|
||||
|
||||
function handleFileChange(file: any, type: string) {
|
||||
const raw = file.raw || file
|
||||
if (type === 'savings') savingsFile.value = raw
|
||||
else if (type === 'ci') ciFile.value = raw
|
||||
else if (type === 'iul') iulFile.value = raw
|
||||
}
|
||||
|
||||
function removeFile(type: string) {
|
||||
if (type === 'savings') savingsFile.value = null
|
||||
else if (type === 'ci') ciFile.value = null
|
||||
else if (type === 'iul') iulFile.value = null
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!hasFiles.value) return
|
||||
uploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
const files: File[] = []
|
||||
const types: string[] = []
|
||||
const companiesArr: string[] = []
|
||||
const productsArr: string[] = []
|
||||
const passwordsArr: string[] = []
|
||||
|
||||
if (savingsFile.value) { files.push(savingsFile.value); types.push('savings'); companiesArr.push(savingsCompany.value); productsArr.push(savingsProduct.value); passwordsArr.push(savingsPassword.value) }
|
||||
if (ciFile.value) { files.push(ciFile.value); types.push('ci'); companiesArr.push(ciCompany.value); productsArr.push(ciProduct.value); passwordsArr.push(ciPassword.value) }
|
||||
if (iulFile.value) { files.push(iulFile.value); types.push('iul'); companiesArr.push(iulCompany.value); productsArr.push(iulProduct.value); passwordsArr.push(iulPassword.value) }
|
||||
|
||||
files.forEach(f => formData.append('files', f))
|
||||
types.forEach(t => formData.append('types', t))
|
||||
companiesArr.forEach(c => formData.append('companies', c))
|
||||
productsArr.forEach(p => formData.append('products', p))
|
||||
passwordsArr.forEach(pw => formData.append('passwords', pw))
|
||||
|
||||
const res: any = await pptApi.upload(formData)
|
||||
const sessionId = res?.data?.sessionId
|
||||
if (sessionId) {
|
||||
savingsPassword.value = ''; ciPassword.value = ''; iulPassword.value = ''
|
||||
ElMessage.success('上传成功')
|
||||
emit('uploaded', sessionId)
|
||||
} else {
|
||||
ElMessage.error('上传失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -185,14 +317,42 @@ async function handleUpload() {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 上传端口列表 */
|
||||
.upload-ports {
|
||||
/* 场景摘要 */
|
||||
.scenario-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
background: #f0f7f3;
|
||||
border: 1px solid #d9ede2;
|
||||
}
|
||||
|
||||
.scenario-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scenario-hint {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.scenario-count {
|
||||
font-size: 12px;
|
||||
color: #3B7A57;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 条目列表 */
|
||||
.items-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.upload-port {
|
||||
.item-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
@ -202,46 +362,94 @@ async function handleUpload() {
|
||||
background: #fafbfa;
|
||||
}
|
||||
|
||||
.port-header {
|
||||
.item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.port-file {
|
||||
font-size: 12px;
|
||||
.item-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #3B7A57;
|
||||
font-weight: 500;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.type-select {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
/* 文件区 */
|
||||
.item-file {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #d9ede2;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #1a2e23;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.port-empty {
|
||||
font-size: 12px;
|
||||
.file-size {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.port-upload {
|
||||
.file-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.port-upload :deep(.el-upload-dragger) {
|
||||
.file-upload :deep(.el-upload-dragger) {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
min-height: 64px;
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.port-options {
|
||||
.item-error {
|
||||
font-size: 12px;
|
||||
color: #ef4444;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 业务字段 */
|
||||
.item-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.port-options .el-input {
|
||||
.item-fields .el-input {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* 添加按钮 */
|
||||
.add-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.add-limit {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 底部说明 */
|
||||
.upload-footnote {
|
||||
display: flex;
|
||||
@ -258,18 +466,25 @@ async function handleUpload() {
|
||||
|
||||
.submit-btn {
|
||||
align-self: flex-end;
|
||||
min-width: 180px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 767px) {
|
||||
.upload-ports {
|
||||
.items-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.port-options {
|
||||
.item-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.scenario-bar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user