0727 PPT加海报功能02修复一个版本
This commit is contained in:
parent
961924b299
commit
48f6388554
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
"""获取当前供应商信息。"""
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"而不是生成通用产品"""
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)):
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}`)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user