0727 PPT加海报功能02修复一个版本

This commit is contained in:
wsb1224 2026-07-27 10:37:00 +08:00
parent 961924b299
commit 48f6388554
11 changed files with 506 additions and 124 deletions

View File

@ -13,6 +13,11 @@ class PptSession(db.Model):
comment="状态: created/parsing/parsed/generating/done/error")
files_json = Column(Text, nullable=True, comment="上传文件列表 JSON")
extractions_json = Column(Text, nullable=True, comment="提取结果 JSON")
parse_progress = Column(Integer, default=0, nullable=False, comment="解析进度 0-100")
parse_message = Column(Text, nullable=True, comment="解析进度说明")
parse_error = Column(Text, nullable=True, comment="解析任务错误")
parse_started_at = Column(TIMESTAMP, nullable=True, comment="解析开始时间")
parse_finished_at = Column(TIMESTAMP, nullable=True, comment="解析完成时间")
chat_history_json = Column(Text, nullable=True, comment="对话历史 JSON")
ppt_path = Column(String(500), nullable=True, comment="生成的 PPT 路径")
markdown_path = Column(String(500), nullable=True, comment="Markdown 路径")
@ -30,6 +35,11 @@ class PptSession(db.Model):
"status": self.status,
"files": json.loads(self.files_json) if self.files_json else [],
"extractions": json.loads(self.extractions_json) if self.extractions_json else [],
"parse_progress": self.parse_progress or 0,
"parse_message": self.parse_message,
"parse_error": self.parse_error,
"parse_started_at": str(self.parse_started_at) if self.parse_started_at else None,
"parse_finished_at": str(self.parse_finished_at) if self.parse_finished_at else None,
"chat_history": json.loads(self.chat_history_json) if self.chat_history_json else [],
"ppt_path": self.ppt_path,
"markdown_path": self.markdown_path,

View File

@ -13,12 +13,17 @@ logger = logging.getLogger(__name__)
CACHE_VERSION = 3
def _format_exception(exc: Exception) -> str:
message = str(exc) or repr(exc)
return f"{exc.__class__.__name__}: {message}"
@dataclass
class ExtractionResult:
pdf_path: str
product_name: str
plan_type: str # savings/ci/iul
status: str # success/cached/error
status: str # success/partial/error
data: Optional[dict] = None
usage: Optional[dict] = None
error: Optional[str] = None
@ -187,6 +192,63 @@ def _looks_corrupted(text: str) -> bool:
return bad_chars / len(text) > 0.1
def _normalized_product_name(data: dict) -> str:
product_name = str(data.get("product_name") or "").strip()
if not product_name:
return "unknown"
if product_name.lower() == "unknown":
return "unknown"
return product_name
def assess_extraction_payload(data: Optional[dict], plan_type: str) -> tuple[str, str]:
"""Return extraction status and a user-facing error when data is incomplete."""
if not isinstance(data, dict) or not data:
return "partial", "结构化结果为空,请补充识别数据后再生成 PPT"
insured = data.get("insured") or {}
policy = data.get("policy") or {}
benefit_rows = data.get("benefit_illustration")
benefit_rows = benefit_rows if isinstance(benefit_rows, list) else []
product_name = _normalized_product_name(data)
insured_age = insured.get("age")
def has_positive_number(value) -> bool:
try:
return float(value) > 0
except (TypeError, ValueError):
return False
problems = []
if product_name == "unknown":
problems.append("产品名称未识别")
if not has_positive_number(insured_age):
problems.append("被保人年龄缺失")
if not benefit_rows:
problems.append("利益演示为空")
normalized_type = (plan_type or "").lower()
if normalized_type == "iul":
if not has_positive_number(policy.get("sum_insured")):
problems.append("保额缺失")
index_accounts = data.get("index_accounts")
if not isinstance(index_accounts, list) or not index_accounts:
problems.append("指数账户缺失")
elif normalized_type == "ci":
if not has_positive_number(policy.get("sum_insured")):
problems.append("保额缺失")
coverage_items = data.get("coverage_items")
if not isinstance(coverage_items, list) or not coverage_items:
problems.append("保障项目缺失")
else:
if not has_positive_number(policy.get("annual_premium")):
problems.append("年缴保费缺失")
if problems:
return "partial", "".join(problems[:3])
return "success", ""
class ExtractionOrchestrator:
"""PDF 提取编排器。"""
@ -245,7 +307,7 @@ class ExtractionOrchestrator:
except Exception as e:
return ExtractionResult(
pdf_path=abs_path, product_name="unknown", plan_type=plan_type,
status="error", error=f"LLM 调用失败: {e}",
status="error", error=f"LLM 调用失败: {_format_exception(e)}",
duration_ms=(time.time() - start) * 1000,
)
@ -268,11 +330,13 @@ class ExtractionOrchestrator:
if self.use_cache:
self._save_to_cache(abs_path, data)
product_name = data.get("product_name", "unknown")
product_name = _normalized_product_name(data)
status, extraction_error = assess_extraction_payload(data, detected_type)
return ExtractionResult(
pdf_path=abs_path, product_name=product_name,
plan_type=detected_type, status="success", data=data,
plan_type=detected_type, status=status, data=data,
usage={"input": response.tokens.get("input", 0), "output": response.tokens.get("output", 0)} if response.tokens else None,
error=extraction_error or None,
duration_ms=(time.time() - start) * 1000,
)
@ -296,11 +360,12 @@ class ExtractionOrchestrator:
if meta.get("cacheVersion") != CACHE_VERSION:
return None
data = raw.get("_data", raw)
product_name = data.get("product_name", "unknown")
product_name = _normalized_product_name(data)
plan_type = infer_plan_type(data)
status, extraction_error = assess_extraction_payload(data, plan_type)
return ExtractionResult(
pdf_path=pdf_path, product_name=product_name,
plan_type=plan_type, status="cached", data=data,
plan_type=plan_type, status=status, data=data, error=extraction_error or None,
)
except Exception:
return None

View File

@ -18,6 +18,7 @@ from typing import Optional
import httpx
logger = logging.getLogger(__name__)
DEFAULT_TIMEOUT_MS = 180_000
# ─── 配置 ───────────────────────────────────────────────
@ -32,6 +33,13 @@ class LLMProviderConfig:
PROVIDERS = {
"openai": LLMProviderConfig(
name="openai",
base_url="https://api.openai.com/v1",
model="gpt-4o",
max_retries=2,
rate_limit=0,
),
"deepseek": LLMProviderConfig(
name="deepseek",
base_url="https://api.deepseek.com/v1",
@ -56,6 +64,19 @@ PROVIDERS = {
}
def _format_exception(exc: Exception) -> str:
message = str(exc) or repr(exc)
return f"{exc.__class__.__name__}: {message}"
def _parse_timeout_ms(value: Optional[str], default: int = DEFAULT_TIMEOUT_MS) -> int:
try:
timeout_ms = int(str(value or "").strip())
except (TypeError, ValueError):
return default
return timeout_ms if timeout_ms > 0 else default
# ─── 限流器 ─────────────────────────────────────────────
class RateLimiter:
@ -274,6 +295,7 @@ class LLMClient:
self._configs: list[tuple[LLMProviderConfig, str]] = []
self._limiters: dict[str, RateLimiter] = {}
self._active_idx = 0
self._timeout_ms = _parse_timeout_ms(os.getenv("PPT_LLM_TIMEOUT_MS"))
self._db_config_time: float = 0 # 上次从数据库加载配置的时间戳
self._db_config_ttl: float = 60 # 配置缓存有效期(秒)
@ -309,8 +331,13 @@ class LLMClient:
settings = {s.key: s.value for s in SystemSetting.query.filter(
SystemSetting.key.in_([
"ppt_llm_provider", "ppt_llm_model", "ppt_llm_api_key", "ppt_llm_base_url",
"ppt_llm_timeout_ms",
])
).all()}
self._timeout_ms = _parse_timeout_ms(
settings.get("ppt_llm_timeout_ms"),
_parse_timeout_ms(os.getenv("PPT_LLM_TIMEOUT_MS")),
)
provider = settings.get("ppt_llm_provider", "").strip()
if not provider:
return
@ -427,9 +454,9 @@ class LLMClient:
# Dify 模式:通过 Dify Chat API 调用
if config.name == "dify":
try:
return await _call_dify(config.model, messages, timeout_ms=60_000)
return await _call_dify(config.model, messages, timeout_ms=self._timeout_ms)
except Exception as e:
logger.warning(f"[LLMClient] Dify 调用失败: {e}")
logger.warning(f"[LLMClient] Dify 调用失败: {_format_exception(e)}")
continue
# 限流
@ -442,15 +469,15 @@ class LLMClient:
# 调用
try:
response = await _call_provider(config, api_key, messages, timeout_ms=60_000)
response = await _call_provider(config, api_key, messages, timeout_ms=self._timeout_ms)
self._active_idx = idx
return response
except Exception as e:
logger.warning(f"[LLMClient] {config.name} 失败: {e}")
logger.warning(f"[LLMClient] {config.name} 失败: {_format_exception(e)}")
if attempt < 3 and i < len(self._configs) - 1:
self._active_idx = (idx + 1) % len(self._configs)
raise RuntimeError("所有 LLM 供应商均失败")
raise RuntimeError(f"所有 LLM 供应商均失败,请检查 API Key、Base URL、模型名称或超时设置当前 {self._timeout_ms}ms")
def get_status(self) -> dict:
"""获取当前供应商信息。"""

View File

@ -73,7 +73,7 @@ def normalize_savings_plan(raw: dict, pdf_path: str = None, parser: str = "llm-j
policy_year = _safe_number(row.get("policy_year"))
if policy_year <= 0:
continue
annual = _safe_number(row.get("annual_withdrawal"))
annual = _safe_number(row.get("annual_withdrawal") or row.get("withdrawal_amount"))
total_withdrawn = _safe_number(row.get("total_withdrawn") or row.get("cumulative_withdrawal"))
cumulative = total_withdrawn if total_withdrawn > 0 else cumulative + annual
age = _safe_number(row.get("age")) or (insured_age + policy_year)
@ -83,7 +83,7 @@ def normalize_savings_plan(raw: dict, pdf_path: str = None, parser: str = "llm-j
"totalPremiumPaid": _safe_number(row.get("total_premium_paid")),
"annualWithdrawal": annual,
"cumulativeWithdrawal": cumulative,
"surrenderValueAfter": _safe_number(row.get("surrender_value_after")),
"surrenderValueAfter": _safe_number(row.get("surrender_value_after") or row.get("remaining_surrender_value")),
"guaranteedValueAfter": _safe_number(row.get("guaranteed_value_after")),
"basicSumInsuredAfter": _safe_number(row.get("basic_sum_insured_after")),
"sourcePage": int(_safe_number(row.get("source_page"))) if row.get("source_page") else None,

View File

@ -38,7 +38,8 @@ SAVINGS_PLAN_SYSTEM_PROMPT = """你是一个专业的香港保险计划书数据
"reversionary_bonus": "归原红利(非保证, 数字, 无则0",
"terminal_dividend": "终期分红(非保证, 数字, 无则0",
"total_surrender_value": "退保发还总额(数字)",
"death_benefit": "身故赔偿额(数字, 无则null"
"death_benefit": "身故赔偿额(数字, 无则null",
"source_page": "该行数据来自 PDF 第几页(数字, 无法确定则null"
}
],
@ -49,7 +50,8 @@ SAVINGS_PLAN_SYSTEM_PROMPT = """你是一个专业的香港保险计划书数据
"annual_withdrawal": "当年提取金额(数字)",
"total_withdrawn": "累计提取总额(数字)",
"surrender_value_before": "提取前退保金额无则null",
"surrender_value_after": "提取后退保金额无则null"
"surrender_value_after": "提取后退保金额无则null",
"source_page": "该行数据来自 PDF 第几页(数字, 无法确定则null"
}
],
@ -127,6 +129,7 @@ SAVINGS_PLAN_SYSTEM_PROMPT = """你是一个专业的香港保险计划书数据
- 纯JSON, 无markdown包裹, 无额外说明文字
- benefit_illustration 至少20行
- policy_year 从1开始
- 每一条利益演示/退保演示数据尽量填写 source_page便于人工追溯
- 不确定的字段填null不填0"""
@ -224,7 +227,8 @@ IUL_SYSTEM_PROMPT = """你是一个专业的香港/新加坡IUL指数型万
"non_guaranteed_account_value": "非保证/当前假设账户价值无则0",
"non_guaranteed_cash_value": "非保证/当前假设现金价值无则0",
"non_guaranteed_death_benefit": "非保证/当前假设身故赔偿无则0",
"cost_of_insurance": "保险成本COI, 如有)"
"cost_of_insurance": "保险成本COI, 如有)",
"source_page": "该行数据来自 PDF 第几页(数字, 无法确定则null"
}
],
"sales_insights": {
@ -245,6 +249,7 @@ IUL_SYSTEM_PROMPT = """你是一个专业的香港/新加坡IUL指数型万
## 要求
1. IUL 有保证和非保证两套演示, 优先提取非保证当前假设利率数据
2. 提取所有保单年度
2. 提取 PDF 中实际存在的所有保单年度如果 PDF 有连续年度表必须逐年提取不要只输出 1/5/10/20/30 等摘要年份
3. 注意 index_accounts 的配置比例和利率
4. 强调身故杠杆倍数"""
4. 强调身故杠杆倍数
5. 严禁在文本乱码表格不可读或字段缺失时编造示例数据无法确定的字段填null无法确认产品名时填"unknown"而不是生成通用产品"""

View File

@ -121,67 +121,80 @@ def parse_session(session_id):
if not session:
return error(ErrorCode.NOT_FOUND, "会话不存在")
import asyncio
from insurance.ppt.extraction import ExtractionOrchestrator
orchestrator = ExtractionOrchestrator()
files = json.loads(session.files_json) if session.files_json else []
extractions = []
if not files:
return error(ErrorCode.PARAM_ERROR, "没有可解析的 PDF 文件")
if session.status == "parsing":
return success(_build_parse_status(session), "解析任务正在进行")
from flask import current_app
from insurance.ppt.parse_worker import start_parse_task
session.status = "parsing"
session.parse_progress = 0
session.parse_message = "解析任务已提交"
session.parse_error = None
session.extractions_json = json.dumps([], ensure_ascii=False)
_save_session(session)
for file_info in files:
filepath = file_info.get("path", "")
plan_type = file_info.get("type", "savings")
try:
result = asyncio.run(orchestrator.extract_plan(filepath, plan_type, force_reparse=True))
extractions.append({
"pdfName": file_info.get("name", ""),
"pdfPath": filepath,
"planType": result.plan_type,
"status": result.status,
"productName": result.product_name,
"data": result.data,
"error": result.error,
"yearCount": len(result.data.get("benefit_illustration", [])) if result.data else 0,
})
except Exception as e:
logger.error(f"PDF 解析失败 [{file_info.get('name', '')}]: {e}", exc_info=True)
extractions.append({
"pdfName": file_info.get("name", ""),
"pdfPath": filepath,
"planType": plan_type,
"status": "error",
"productName": "unknown",
"data": None,
"error": str(e),
"yearCount": 0,
})
session.extractions_json = json.dumps(extractions, ensure_ascii=False)
session.status = "parsed"
_save_session(session)
# 生成摘要
lines = ["| 产品 | 类型 | 状态 |", "|------|------|------|"]
for ext in extractions:
status_icon = "" if ext["status"] == "success" else ""
lines.append(f"| {ext['productName']} | {ext['planType']} | {status_icon} |")
started = start_parse_task(current_app._get_current_object(), session_id, user_id)
message = "解析任务已启动" if started else "解析任务正在进行"
return success({
"sessionId": session_id,
"status": "parsed",
"status": session.status,
"progress": session.parse_progress or 0,
"message": message,
})
@ppt_bp.route("/parse/<session_id>/status", methods=["GET"])
@jwt_required
def parse_status(session_id):
"""获取 AI 解析进度。"""
user_id = str(getattr(request, "user_id", "guest"))
session = _get_session(session_id, user_id)
if not session:
return error(ErrorCode.NOT_FOUND, "会话不存在")
return success(_build_parse_status(session))
def _build_parse_status(session):
extractions = json.loads(session.extractions_json) if session.extractions_json else []
return {
"sessionId": session.id,
"status": session.status,
"progress": session.parse_progress or 0,
"message": session.parse_message or "",
"error": session.parse_error,
"extractions": [{
"pdfName": e["pdfName"],
"planType": e["planType"],
"status": e["status"],
"productName": e["productName"],
"yearCount": e["yearCount"],
"pdfName": e.get("pdfName", ""),
"planType": e.get("planType", ""),
"status": e.get("status", ""),
"productName": e.get("productName", ""),
"yearCount": e.get("yearCount", 0),
"error": e.get("error"),
} for e in extractions],
"message": "\n".join(lines),
})
}
def _reassess_extraction(ext: dict):
from insurance.ppt.extraction import assess_extraction_payload, infer_plan_type
data = ext.get("data")
if not data:
return
plan_type = infer_plan_type(data)
status, extraction_error = assess_extraction_payload(data, plan_type)
data["product_type"] = plan_type
ext["planType"] = plan_type
ext["status"] = status
ext["productName"] = (data.get("product_name") or "").strip() or "unknown"
ext["error"] = extraction_error or None
rows = data.get("benefit_illustration") or data.get("benefitRows") or []
ext["yearCount"] = len(rows) if isinstance(rows, list) else 0
# ─── 获取会话状态 ─────────────────────────────────────────
@ -281,11 +294,11 @@ def generate_ppt(session_id):
all_normalized = []
for ext in extractions:
if ext.get("status") not in ("success", "cached") or not ext.get("data"):
if ext.get("status") not in ("success", "partial") or not ext.get("data"):
continue
ext_data = ext["data"]
pdf_path = ext.get("pdfPath")
plan_type = ext_data.get("product_type", "savings")
plan_type = (ext.get("planType") or ext_data.get("product_type") or "savings").lower()
try:
if plan_type == "ci":
normalized = normalize_ci_plan(ext_data, pdf_path)
@ -420,19 +433,20 @@ def validate_extraction(session_id):
from insurance.ppt.validator import validate_formal_savings_plan, validate_formal_ci_plan, validate_formal_iul_plan
for ext in extractions:
if ext.get("status") not in ("success", "cached") or not ext.get("data"):
if ext.get("status") not in ("success", "partial") or not ext.get("data"):
continue
data = ext["data"]
plan_type = data.get("product_type", "savings")
plan_type = (ext.get("planType") or data.get("product_type") or "savings").lower()
pdf_path = ext.get("pdfPath")
try:
if plan_type == "ci":
normalized = normalize_ci_plan(data)
normalized = normalize_ci_plan(data, pdf_path)
issues = validate_formal_ci_plan(normalized)
elif plan_type == "iul":
normalized = normalize_iul_plan(data)
normalized = normalize_iul_plan(data, pdf_path)
issues = validate_formal_iul_plan(normalized)
else:
normalized = normalize_savings_plan(data)
normalized = normalize_savings_plan(data, pdf_path)
issues = validate_formal_savings_plan(normalized)
all_issues.extend([{"field": i.code, "severity": i.level, "message": i.message} for i in issues])
except Exception as e:
@ -481,11 +495,7 @@ def update_extractions(session_id):
existing_map[pdf_name]["productName"] = ext["productName"]
if "planType" in ext:
existing_map[pdf_name]["planType"] = ext["planType"]
# 重新计算行数
d = existing_map[pdf_name].get("data")
if d:
rows = d.get("benefit_illustration") or d.get("benefitRows") or []
existing_map[pdf_name]["yearCount"] = len(rows)
_reassess_extraction(existing_map[pdf_name])
updated = list(existing_map.values())
session.extractions_json = json.dumps(updated, ensure_ascii=False)

View File

@ -103,6 +103,9 @@ def validate_formal_iul_plan(plan: dict) -> list[FormalDeckIssue]:
def err(code, msg):
issues.append(FormalDeckIssue(code, "error", msg))
def warn(code, msg):
issues.append(FormalDeckIssue(code, "warn", msg))
if not plan.get("productName"):
err("IUL_PRODUCT_NAME_MISSING", "产品名称缺失")
if not plan.get("insured", {}).get("age"):
@ -115,19 +118,20 @@ def validate_formal_iul_plan(plan: dict) -> list[FormalDeckIssue]:
benefit_rows = plan.get("benefitRows", [])
if len(benefit_rows) < 20:
err("IUL_BENEFIT_ROWS_INCOMPLETE", f"利益演示行数不足(当前 {len(benefit_rows)} 行)")
if benefit_rows and not _is_continuous(benefit_rows):
err("IUL_BENEFIT_ROWS_DISCONTINUOUS", "利益演示保单年度不连续")
is_continuous = _is_continuous(benefit_rows) if benefit_rows else True
if benefit_rows and not is_continuous:
warn("IUL_BENEFIT_ROWS_DISCONTINUOUS", "利益演示保单年度不连续,请确认 PDF 是否只提供里程碑年度")
source = plan.get("source", {})
if not source.get("pdfHash"):
err("IUL_SOURCE_HASH_MISSING", "缺少 PDF 哈希")
if benefit_rows and not any(r.get("sourcePage") for r in benefit_rows):
err("IUL_SOURCE_PAGE_MISSING", "利益演示缺少来源页码")
warn("IUL_SOURCE_PAGE_MISSING", "利益演示缺少来源页码")
# 缴费年期一致性检查
payment_period = plan.get("policy", {}).get("paymentPeriod", "")
if benefit_rows and payment_period:
if benefit_rows and payment_period and is_continuous:
# 通过数据检测实际缴费年数
detected_years = 0
for i in range(1, len(benefit_rows)):

View File

@ -42,6 +42,17 @@
<el-input v-model="form.ppt_llm_base_url" placeholder="如 https://api.openai.com/v1" />
</el-form-item>
<el-form-item label="调用超时">
<el-input-number
v-model="form.ppt_llm_timeout_ms"
:min="30000"
:max="600000"
:step="30000"
style="width: 220px"
/>
<span class="form-tip">毫秒复杂计划书建议 180000 以上</span>
</el-form-item>
<!-- 海报文案模型 -->
<el-divider content-position="left">海报文案模型</el-divider>
@ -137,6 +148,7 @@ const form = reactive({
ppt_llm_model: '',
ppt_llm_api_key: '',
ppt_llm_base_url: '',
ppt_llm_timeout_ms: 180000,
poster_llm_provider: 'deepseek',
poster_llm_model: '',
poster_llm_api_key: '',
@ -254,7 +266,7 @@ async function loadSettings() {
const res: any = await pptAdminApi.getSettings()
const data = res?.data || {}
for (const key of Object.keys(form)) {
if (data[key]) {
if (Object.prototype.hasOwnProperty.call(data, key)) {
;(form as any)[key] = data[key]
}
}
@ -294,4 +306,9 @@ onMounted(loadSettings)
margin: 0;
font-size: 20px;
}
.form-tip {
margin-left: 12px;
color: #909399;
font-size: 13px;
}
</style>

View File

@ -40,11 +40,10 @@
{{ typeNameMap[ext.planType] || ext.planType }}
</el-tag>
<span class="product-name">{{ ext.productName }}</span>
<el-tag v-if="ext.status === 'success' || ext.status === 'cached'" type="success" size="small">解析成功</el-tag>
<el-tag v-else type="danger" size="small">解析失败</el-tag>
<el-tag :type="statusTagType(ext.status)" size="small">{{ statusLabel(ext.status) }}</el-tag>
</div>
<template v-if="ext.data && (ext.status === 'success' || ext.status === 'cached')">
<template v-if="ext.data && ext.status !== 'error'">
<!-- 关键指标卡 -->
<div class="metrics-row">
<div class="metric-card" v-for="m in getKeyMetrics(ext)" :key="m.label">
@ -89,10 +88,18 @@
</el-collapse-item>
<!-- 利益演示表可编辑 -->
<el-collapse-item :title="`利益演示表(${ext.yearCount} 行,可编辑)`" name="benefit">
<el-collapse-item :title="`利益演示表(${getBenefitRows(ext).length} 行,可编辑)`" name="benefit">
<div class="table-wrapper">
<div class="table-actions">
<el-button size="small" type="primary" plain @click="addBenefitRow(ext)">
新增年度
</el-button>
<el-button size="small" plain @click="sortRows(getBenefitRows(ext))">
按年度排序
</el-button>
</div>
<el-table
:data="ext.data.benefit_illustration || []"
:data="getBenefitRows(ext)"
stripe
border
size="small"
@ -119,6 +126,21 @@
<el-input-number v-model="row.guaranteed_cash_value" :min="0" :step="1000" size="small" controls-position="right" style="width: 110px" />
</template>
</el-table-column>
<el-table-column v-if="isIul(ext)" prop="guaranteed_account_value" label="保证账户价值" min-width="120">
<template #default="{ row }">
<el-input-number v-model="row.guaranteed_account_value" :min="0" :step="1000" size="small" controls-position="right" style="width: 110px" />
</template>
</el-table-column>
<el-table-column v-if="isIul(ext)" prop="non_guaranteed_account_value" label="非保证账户价值" min-width="130">
<template #default="{ row }">
<el-input-number v-model="row.non_guaranteed_account_value" :min="0" :step="1000" size="small" controls-position="right" style="width: 120px" />
</template>
</el-table-column>
<el-table-column v-if="isIul(ext)" prop="non_guaranteed_cash_value" label="非保证现金价值" min-width="130">
<template #default="{ row }">
<el-input-number v-model="row.non_guaranteed_cash_value" :min="0" :step="1000" size="small" controls-position="right" style="width: 120px" />
</template>
</el-table-column>
<el-table-column v-if="hasField(ext.data.benefit_illustration, 'reversionary_bonus')" prop="reversionary_bonus" label="归原红利" min-width="110">
<template #default="{ row }">
<el-input-number v-model="row.reversionary_bonus" :min="0" :step="1000" size="small" controls-position="right" style="width: 100px" />
@ -139,19 +161,43 @@
<el-input-number v-model="row.death_benefit" :min="0" :step="1000" size="small" controls-position="right" style="width: 100px" />
</template>
</el-table-column>
<el-table-column v-if="isIul(ext)" prop="non_guaranteed_death_benefit" label="非保证身故赔偿" min-width="130">
<template #default="{ row }">
<el-input-number v-model="row.non_guaranteed_death_benefit" :min="0" :step="10000" size="small" controls-position="right" style="width: 120px" />
</template>
</el-table-column>
<el-table-column prop="source_page" label="来源页" width="80">
<template #default="{ row }">
<el-input-number v-model="row.source_page" :min="1" :max="999" size="small" controls-position="right" style="width: 70px" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ $index }">
<el-button size="small" type="danger" link @click="removeBenefitRow(ext, $index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-collapse-item>
<!-- 退保提取表可编辑如果有 -->
<!-- 退保提取表可编辑 -->
<el-collapse-item
v-if="ext.data.withdrawal_illustration && ext.data.withdrawal_illustration.length"
:title="`退保提取表(${ext.data.withdrawal_illustration.length} 行,可编辑)`"
:title="`退保提取表(${getWithdrawalRows(ext).length} 行,可编辑)`"
name="withdrawal"
>
<div class="table-wrapper">
<div class="table-actions">
<el-button size="small" type="primary" plain @click="addWithdrawalRow(ext)">
新增退保行
</el-button>
<el-button size="small" plain @click="sortRows(getWithdrawalRows(ext))">
按年度排序
</el-button>
</div>
<el-table
:data="ext.data.withdrawal_illustration"
:data="getWithdrawalRows(ext)"
stripe
border
size="small"
@ -163,19 +209,31 @@
<el-input-number v-model="row.policy_year" :min="1" :max="100" size="small" controls-position="right" style="width: 70px" />
</template>
</el-table-column>
<el-table-column prop="withdrawal_amount" label="提取金额" min-width="110">
<el-table-column prop="annual_withdrawal" label="提取金额" min-width="110">
<template #default="{ row }">
<el-input-number v-model="row.withdrawal_amount" :min="0" :step="1000" size="small" controls-position="right" style="width: 100px" />
<el-input-number v-model="row.annual_withdrawal" :min="0" :step="1000" size="small" controls-position="right" style="width: 100px" />
</template>
</el-table-column>
<el-table-column v-if="hasField(ext.data.withdrawal_illustration, 'cumulative_withdrawal')" prop="cumulative_withdrawal" label="累计提取" min-width="110">
<el-table-column prop="total_withdrawn" label="累计提取" min-width="110">
<template #default="{ row }">
<el-input-number v-model="row.cumulative_withdrawal" :min="0" :step="1000" size="small" controls-position="right" style="width: 100px" />
<el-input-number v-model="row.total_withdrawn" :min="0" :step="1000" size="small" controls-position="right" style="width: 100px" />
</template>
</el-table-column>
<el-table-column prop="remaining_surrender_value" label="剩余退保价值" min-width="120">
<el-table-column prop="surrender_value_after" label="剩余退保价值" min-width="120">
<template #default="{ row }">
<el-input-number v-model="row.remaining_surrender_value" :min="0" :step="1000" size="small" controls-position="right" style="width: 110px" />
<el-input-number v-model="row.surrender_value_after" :min="0" :step="1000" size="small" controls-position="right" style="width: 110px" />
</template>
</el-table-column>
<el-table-column prop="source_page" label="来源页" width="80">
<template #default="{ row }">
<el-input-number v-model="row.source_page" :min="1" :max="999" size="small" controls-position="right" style="width: 70px" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ $index }">
<el-button size="small" type="danger" link @click="removeWithdrawalRow(ext, $index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
@ -272,6 +330,9 @@ onMounted(async () => {
if (ext.data) {
ext.data.insured = ext.data.insured || {}
ext.data.policy = ext.data.policy || {}
ext.data.benefit_illustration = ext.data.benefit_illustration || []
ext.data.withdrawal_illustration = ext.data.withdrawal_illustration || []
normalizeEditableRows(ext)
}
}
@ -335,9 +396,118 @@ function hasField(rows: any[], field: string): boolean {
return rows.some(r => r[field] !== undefined && r[field] !== null)
}
async function handleSave() {
function isIul(ext: any): boolean {
return ext?.planType === 'iul' || ext?.data?.product_type === 'iul'
}
function statusTagType(status: string): string {
if (status === 'success') return 'success'
if (status === 'partial') return 'warning'
return 'danger'
}
function statusLabel(status: string): string {
if (status === 'success') return '解析成功'
if (status === 'partial') return '解析不完整'
return '解析失败'
}
function getBenefitRows(ext: any): any[] {
ext.data.benefit_illustration = ext.data.benefit_illustration || []
return ext.data.benefit_illustration
}
function getWithdrawalRows(ext: any): any[] {
ext.data.withdrawal_illustration = ext.data.withdrawal_illustration || []
return ext.data.withdrawal_illustration
}
function getNextPolicyYear(rows: any[]): number {
const years = rows.map(row => Number(row.policy_year) || 0)
return Math.max(0, ...years) + 1
}
function getDefaultAge(ext: any, policyYear: number): number | null {
const insuredAge = Number(ext?.data?.insured?.age) || 0
return insuredAge > 0 ? insuredAge + policyYear : null
}
function sortRows(rows: any[]) {
rows.sort((a, b) => (Number(a.policy_year) || 0) - (Number(b.policy_year) || 0))
}
function addBenefitRow(ext: any) {
const rows = getBenefitRows(ext)
const policyYear = getNextPolicyYear(rows)
const row: any = {
policy_year: policyYear,
age: getDefaultAge(ext, policyYear),
total_premium_paid: 0,
guaranteed_cash_value: 0,
total_surrender_value: 0,
source_page: null,
}
if (isIul(ext)) {
row.guaranteed_account_value = 0
row.non_guaranteed_account_value = 0
row.non_guaranteed_cash_value = 0
row.non_guaranteed_death_benefit = 0
row.cost_of_insurance = 0
} else {
row.reversionary_bonus = 0
row.terminal_dividend = 0
row.death_benefit = 0
}
rows.push(row)
sortRows(rows)
ext.yearCount = rows.length
}
function removeBenefitRow(ext: any, index: number) {
const rows = getBenefitRows(ext)
rows.splice(index, 1)
ext.yearCount = rows.length
}
function addWithdrawalRow(ext: any) {
const rows = getWithdrawalRows(ext)
const policyYear = getNextPolicyYear(rows)
rows.push({
policy_year: policyYear,
total_premium_paid: 0,
annual_withdrawal: 0,
total_withdrawn: 0,
surrender_value_after: 0,
source_page: null,
})
sortRows(rows)
}
function removeWithdrawalRow(ext: any, index: number) {
getWithdrawalRows(ext).splice(index, 1)
}
function normalizeEditableRows(ext: any) {
for (const row of getWithdrawalRows(ext)) {
row.annual_withdrawal = row.annual_withdrawal ?? row.withdrawal_amount ?? 0
row.total_withdrawn = row.total_withdrawn ?? row.cumulative_withdrawal ?? 0
row.surrender_value_after = row.surrender_value_after ?? row.remaining_surrender_value ?? 0
}
}
async function handleSave(): Promise<boolean> {
saving.value = true
try {
for (const ext of extractions.value) {
if (ext.data) {
normalizeEditableRows(ext)
sortRows(getBenefitRows(ext))
sortRows(getWithdrawalRows(ext))
ext.yearCount = getBenefitRows(ext).length
}
}
await pptApi.updateExtractions(props.sessionId, extractions.value)
originalJson.value = JSON.stringify(extractions.value)
ElMessage.success('数据已保存')
@ -345,20 +515,26 @@ async function handleSave() {
//
const validateRes: any = await pptApi.validate(props.sessionId)
issues.value = validateRes?.data?.issues || []
return !issues.value.some(i => i.severity === 'error')
} catch (e: any) {
ElMessage.error('保存失败: ' + (e?.message || '未知错误'))
return false
} finally {
saving.value = false
}
}
function handleConfirm() {
async function handleConfirm() {
if (isDirty.value) {
//
handleSave().then(() => emit('confirmed'))
} else {
emit('confirmed')
const canContinue = await handleSave()
if (!canContinue) return
}
if (validationStatus.value === 'error') {
ElMessage.error('仍有错误项,请修正后再生成 PPT')
return
}
emit('confirmed')
}
</script>

View File

@ -19,8 +19,8 @@
<p class="status-hint">正在分析 PDF 计划书请稍候...</p>
</div>
<div v-else-if="error" class="parsing-error">
<el-result icon="error" :title="error" sub-title="解析过程出错">
<div v-else-if="error && !results.length" class="parsing-error">
<el-result icon="error" :title="error" sub-title="解析过程出错">
<template #extra>
<el-button @click="$emit('back')">返回上传</el-button>
</template>
@ -29,13 +29,15 @@
<div v-else-if="results.length" class="parsing-results">
<el-result
:icon="allFailed ? 'error' : 'success'"
:title="allFailed ? '解析失败' : '解析完成'"
:sub-title="allFailed ? '所有文件均解析失败,请检查错误信息后重试' : ''"
:icon="allFailed ? 'error' : (hasPartial ? 'warning' : 'success')"
:title="resultTitle"
:sub-title="allFailed ? '所有文件均解析失败,请检查下方错误信息后重试' : ''"
>
<template #extra>
<el-button @click="$emit('back')">返回上传</el-button>
<el-button v-if="hasSuccess" type="primary" @click="$emit('parsed')">下一步校验数据</el-button>
<el-button v-if="hasSuccess" type="primary" @click="$emit('parsed')">
下一步校验数据
</el-button>
</template>
</el-result>
@ -49,14 +51,14 @@
</template>
</el-table-column>
<el-table-column prop="productName" label="产品名称" />
<el-table-column prop="status" label="状态" width="100">
<el-table-column prop="status" label="状态" width="110">
<template #default="{ row }">
<el-tag :type="row.status === 'success' || row.status === 'cached' ? 'success' : 'danger'" size="small">
{{ row.status === 'success' || row.status === 'cached' ? '成功' : '失败' }}
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="error" label="错误信息" min-width="180">
<el-table-column prop="error" label="提示信息" min-width="220">
<template #default="{ row }">
<span v-if="row.error" class="error-msg">{{ row.error }}</span>
<span v-else class="text-muted">-</span>
@ -70,7 +72,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { Loading } from '@element-plus/icons-vue'
import { pptApi } from '@/utils/ppt-api'
@ -89,8 +91,15 @@ const statusText = ref('准备解析...')
const error = ref('')
const results = ref<any[]>([])
const progressStatus = computed(() => (progress.value >= 100 ? 'success' : ''))
const hasSuccess = computed(() => results.value.some(r => r.status === 'success' || r.status === 'cached'))
const hasSuccess = computed(() => results.value.some(r => r.status === 'success' || r.status === 'partial'))
const hasPartial = computed(() => results.value.some(r => r.status === 'partial'))
const allFailed = computed(() => results.value.length > 0 && !hasSuccess.value)
const resultTitle = computed(() => {
if (allFailed.value) return error.value || '解析失败'
if (hasPartial.value) return '解析完成,需补充数据'
return '解析完成'
})
let pollTimer: ReturnType<typeof setInterval> | null = null
const typeTagMap: Record<string, string> = {
savings: 'success',
@ -103,22 +112,76 @@ const typeNameMap: Record<string, string> = {
iul: 'IUL',
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
function statusTagType(status: string) {
if (status === 'success') return 'success'
if (status === 'partial') return 'warning'
return 'danger'
}
function statusLabel(status: string) {
if (status === 'success') return '成功'
if (status === 'partial') return '不完整'
return '失败'
}
function applyStatus(data: any) {
progress.value = data?.progress ?? progress.value
statusText.value = data?.message || statusText.value
results.value = data?.extractions || []
if (data?.status === 'parsed') {
progress.value = 100
statusText.value = hasPartial.value ? '解析完成,部分文件需补充数据' : '解析完成'
parsing.value = false
stopPolling()
return
}
if (data?.status === 'error') {
progress.value = 100
parsing.value = false
error.value = data?.error || '解析失败'
stopPolling()
}
}
async function pollParseStatus() {
const res: any = await pptApi.getParseStatus(props.sessionId)
applyStatus(res?.data || {})
}
onMounted(async () => {
try {
progress.value = 20
statusText.value = '正在调用 AI 解析...'
progress.value = 5
statusText.value = '正在提交解析任务...'
const res: any = await pptApi.parse(props.sessionId)
progress.value = 100
statusText.value = '解析完成'
applyStatus(res?.data || {})
results.value = res?.data?.extractions || []
parsing.value = false
if (parsing.value) {
pollTimer = setInterval(() => {
pollParseStatus().catch((e: any) => {
parsing.value = false
error.value = e?.message || '获取解析状态失败'
stopPolling()
})
}, 2000)
await pollParseStatus()
}
} catch (e: any) {
parsing.value = false
error.value = e?.message || '解析失败'
}
})
onUnmounted(stopPolling)
</script>
<style scoped>
@ -162,7 +225,7 @@ onMounted(async () => {
}
.error-msg {
color: #f56c6c;
color: #e6a23c;
font-size: 13px;
word-break: break-all;
}

View File

@ -57,6 +57,11 @@ export const pptApi = {
return api.post(`/ppt/parse/${sessionId}`)
},
/** 获取 AI 解析进度 */
getParseStatus(sessionId: string) {
return api.get(`/ppt/parse/${sessionId}/status`)
},
/** 获取会话状态 */
getSession(sessionId: string) {
return api.get(`/ppt/session/${sessionId}`)