修复海报生成过程的BUG

This commit is contained in:
wsb1224 2026-07-30 10:30:33 +08:00
parent 68122c5bb4
commit 573210f9c3
8 changed files with 660 additions and 253 deletions

View File

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

View File

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

View File

@ -66,7 +66,15 @@ def render_options():
@ppt_bp.route("/upload", methods=["POST"])
@jwt_required
def upload_pdfs():
"""上传 PDF 文件并创建会话。"""
"""上传 PDF 文件并创建会话。
支持 110 份计划书五组并行数组(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,

View File

@ -1298,7 +1298,7 @@ def add_slide_alignment_table(prs, deck, colors, meta):
def add_slide_compare(prs, deck, colors, meta):
"""对比页:双产品对比"""
"""对比页:多产品对比,支持 110 份"""
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:
# 24 产品:卡片网格布局
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)

View File

@ -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(() =>

View File

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

View File

@ -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] || ''
}

View File

@ -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 '添加 110 份 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%;
}