Compare commits
6 Commits
a4d458c978
...
961924b299
| Author | SHA1 | Date | |
|---|---|---|---|
| 961924b299 | |||
| 36f08f93e0 | |||
| dbe2f9202d | |||
| 8e897276e7 | |||
| 63c7e87ea7 | |||
| 1debd7df39 |
@ -0,0 +1,9 @@
|
||||
{
|
||||
"_data": {},
|
||||
"_meta": {
|
||||
"cacheVersion": 3,
|
||||
"originalFile": "5429f024_计划书(1).pdf",
|
||||
"extractedAt": "2026-07-24T12:50:28.325762",
|
||||
"fileHash": "159aa3c49aebe185a3dc2af7f0b50e335f9a8e00a6998d84a7a259eb754ed4cd"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
{
|
||||
"_data": {},
|
||||
"_meta": {
|
||||
"cacheVersion": 3,
|
||||
"originalFile": "372e0293_MLS_SIUL3_F-48-N-CN-USD-S3m-10x_coi__SC_.pdf",
|
||||
"extractedAt": "2026-07-24T12:54:05.604558",
|
||||
"fileHash": "60e9e349a2169511dd85ce7fecc3c1bc9ab69a7c9f254a03ca75a29625f5e9d4"
|
||||
}
|
||||
}
|
||||
1
.cache/latest_ppt_extractions.json
Normal file
1
.cache/latest_ppt_extractions.json
Normal file
File diff suppressed because one or more lines are too long
Binary file not shown.
@ -244,3 +244,14 @@ def get_settings():
|
||||
def update_settings():
|
||||
updated_by = getattr(request, "user_id", "")
|
||||
return jsonify(ppt_admin_service.update_settings(request.get_json(force=True), updated_by))
|
||||
|
||||
|
||||
@ppt_admin_bp.route("/sync-models", methods=["POST"])
|
||||
@permission_required("config_manage")
|
||||
def sync_models():
|
||||
data = request.get_json(force=True)
|
||||
return jsonify(ppt_admin_service.sync_models(
|
||||
provider=data.get("provider", ""),
|
||||
api_key=data.get("api_key", ""),
|
||||
base_url=data.get("base_url", ""),
|
||||
))
|
||||
|
||||
@ -278,6 +278,7 @@ class PptAdminService:
|
||||
preview_image=data.get("previewImage"),
|
||||
applicable_company_ids=json.dumps(data.get("applicableCompanyIds", []), ensure_ascii=False) if data.get("applicableCompanyIds") else None,
|
||||
applicable_product_ids=json.dumps(data.get("applicableProductIds", []), ensure_ascii=False) if data.get("applicableProductIds") else None,
|
||||
slides_config_json=json.dumps(data.get("slidesConfig", []), ensure_ascii=False) if data.get("slidesConfig") else None,
|
||||
status=data.get("status", 1),
|
||||
)
|
||||
db.session.add(template)
|
||||
@ -295,6 +296,8 @@ class PptAdminService:
|
||||
template.applicable_company_ids = json.dumps(data["applicableCompanyIds"], ensure_ascii=False) if data["applicableCompanyIds"] else None
|
||||
if "applicableProductIds" in data:
|
||||
template.applicable_product_ids = json.dumps(data["applicableProductIds"], ensure_ascii=False) if data["applicableProductIds"] else None
|
||||
if "slidesConfig" in data:
|
||||
template.slides_config_json = json.dumps(data["slidesConfig"], ensure_ascii=False) if data["slidesConfig"] else None
|
||||
if "status" in data:
|
||||
template.status = data["status"]
|
||||
db.session.commit()
|
||||
@ -563,3 +566,58 @@ class PptAdminService:
|
||||
db.session.add(SystemSetting(key=key, value=str(value), updated_by=updated_by))
|
||||
db.session.commit()
|
||||
return {"code": 0, "data": None}
|
||||
|
||||
def sync_models(self, provider: str, api_key: str, base_url: str = "") -> dict:
|
||||
"""从供应商 API 拉取可用模型列表。"""
|
||||
if not api_key:
|
||||
return {"code": 1001, "message": "请填写 API Key", "data": None}
|
||||
|
||||
try:
|
||||
models = self._fetch_provider_models(provider, api_key, base_url)
|
||||
except Exception as e:
|
||||
logger.warning(f"同步模型失败: {e}")
|
||||
return {"code": 5001, "message": f"同步失败: {e}", "data": None}
|
||||
|
||||
if not models:
|
||||
return {"code": 0, "data": {"models": [], "message": "未获取到模型"}}
|
||||
|
||||
return {"code": 0, "data": {"models": models}}
|
||||
|
||||
def _fetch_provider_models(self, provider: str, api_key: str, base_url: str) -> list:
|
||||
"""根据供应商类型拉取模型列表。"""
|
||||
import httpx
|
||||
|
||||
if provider == "deepseek":
|
||||
url = "https://api.deepseek.com/v1/models"
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
resp = httpx.get(url, headers=headers, timeout=10)
|
||||
resp.raise_for_status()
|
||||
return [m["id"] for m in resp.json().get("data", [])]
|
||||
|
||||
elif provider == "gemini":
|
||||
url = f"https://generativelanguage.googleapis.com/v1/models?key={api_key}"
|
||||
resp = httpx.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
return [m["name"].split("/")[-1] for m in resp.json().get("models", [])]
|
||||
|
||||
elif provider == "minimax":
|
||||
url = "https://api.minimax.chat/v1/models"
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
resp = httpx.get(url, headers=headers, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
models = data.get("models") or data.get("data", [])
|
||||
return [m.get("id") or m.get("model", "") for m in models if m]
|
||||
|
||||
elif provider == "dify":
|
||||
return [m["model"] for m in (self._fetch_dify_models() or [])]
|
||||
|
||||
else:
|
||||
# 自定义供应商:OpenAI 兼容 /models 端点
|
||||
if not base_url:
|
||||
return []
|
||||
url = f"{base_url.rstrip('/')}/models"
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
resp = httpx.get(url, headers=headers, timeout=10)
|
||||
resp.raise_for_status()
|
||||
return [m["id"] for m in resp.json().get("data", [])]
|
||||
|
||||
144
api/insurance/db/migrate_017.py
Normal file
144
api/insurance/db/migrate_017.py
Normal file
@ -0,0 +1,144 @@
|
||||
"""迁移 017: 给 PPT 模板表新增 slides_config_json 列。
|
||||
|
||||
该列存储每种模板的逐页幻灯片配置(标题模板、叙事提示、图表类型等),
|
||||
使管理员可以在后台管理页面中配置 PPT 的每一页内容。
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from sqlalchemy import text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate():
|
||||
"""执行迁移。"""
|
||||
from insurance.db.compat import db
|
||||
|
||||
# 检查列是否已存在
|
||||
result = db.session.execute(text(
|
||||
"SELECT COUNT(*) FROM information_schema.columns "
|
||||
"WHERE table_name = 'insurance_ppt_templates' AND column_name = 'slides_config_json'"
|
||||
))
|
||||
if result.scalar() > 0:
|
||||
logger.info("slides_config_json 列已存在,跳过迁移")
|
||||
return
|
||||
|
||||
db.session.execute(text(
|
||||
"ALTER TABLE insurance_ppt_templates ADD COLUMN slides_config_json TEXT"
|
||||
))
|
||||
db.session.commit()
|
||||
logger.info("已添加 slides_config_json 列")
|
||||
|
||||
# 为现有模板填充默认 slides_config
|
||||
_seed_default_slides_config(db)
|
||||
|
||||
|
||||
def _seed_default_slides_config(db):
|
||||
"""为现有模板填充默认的逐页配置。"""
|
||||
templates = db.session.execute(text(
|
||||
"SELECT id, plan_type, required_page_types_json FROM insurance_ppt_templates WHERE status = 1"
|
||||
)).fetchall()
|
||||
|
||||
for tpl in templates:
|
||||
tpl_id, plan_type, rpt_json = tpl
|
||||
if not rpt_json:
|
||||
continue
|
||||
|
||||
try:
|
||||
page_types = json.loads(rpt_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
|
||||
slides_config = _build_default_slides_config(page_types, plan_type)
|
||||
db.session.execute(text(
|
||||
"UPDATE insurance_ppt_templates SET slides_config_json = :config WHERE id = :id"
|
||||
), {"config": json.dumps(slides_config, ensure_ascii=False), "id": tpl_id})
|
||||
|
||||
db.session.commit()
|
||||
logger.info(f"已为 {len(templates)} 个模板填充默认 slides_config")
|
||||
|
||||
|
||||
def _build_default_slides_config(page_types, plan_type):
|
||||
"""根据 pageType 列表构建默认的逐页配置。"""
|
||||
defaults = {
|
||||
"cover": {"pageType": "cover", "title": "{{customerName}} 专属方案",
|
||||
"subtitle": "{{productName}}", "narrativeHint": ""},
|
||||
"company": {"pageType": "company", "title": "{{companyName}} 公司介绍",
|
||||
"narrativeHint": "公司事实来自内部知识库权威口径"},
|
||||
"narrative": {"pageType": "narrative", "title": "方案核心逻辑",
|
||||
"narrativeHint": "先看结构,再看收益与保障"},
|
||||
"chart": {"pageType": "chart", "title": "价值增长曲线",
|
||||
"chartType": "growth", "narrativeHint": "先看回本区间,再看20/30年关键点"},
|
||||
"timeline": {"pageType": "timeline", "title": "家庭时间轴",
|
||||
"narrativeHint": "把谁在什么阶段发挥作用讲清楚"},
|
||||
"table": {"pageType": "table", "title": "数据表(每10年)",
|
||||
"tableType": "no_withdraw", "narrativeHint": "含单利复利,便于长期价值对比"},
|
||||
"compare": {"pageType": "compare", "title": "产品对比",
|
||||
"narrativeHint": "保证部分是底线,非保证部分是弹性"},
|
||||
"synergy": {"pageType": "synergy", "title": "协同关系",
|
||||
"narrativeHint": "功能分层,互不冲突"},
|
||||
"conclusion": {"pageType": "conclusion", "title": "结论",
|
||||
"narrativeHint": "一张保家庭不失速,一张保未来不落空"},
|
||||
"closing": {"pageType": "closing", "title": "感谢信任",
|
||||
"narrativeHint": "方案可继续迭代优化,如有疑问请随时联系"},
|
||||
}
|
||||
|
||||
# 根据产品类型调整默认标题
|
||||
if plan_type == "ci":
|
||||
defaults["narrative"]["title"] = "家庭风险防线"
|
||||
defaults["narrative"]["narrativeHint"] = "先防风险,再做未来"
|
||||
defaults["chart"]["title"] = "重疾保障数据曲线"
|
||||
defaults["chart"]["chartType"] = "growth"
|
||||
elif plan_type == "iul":
|
||||
defaults["narrative"]["title"] = "传承杠杆设计"
|
||||
defaults["narrative"]["narrativeHint"] = "先看杠杆,再看现金价值弹性"
|
||||
defaults["chart"]["title"] = "IUL 长期利益曲线"
|
||||
defaults["chart"]["chartType"] = "growth"
|
||||
|
||||
slides = []
|
||||
table_count = 0
|
||||
chart_count = 0
|
||||
for pt in page_types:
|
||||
if pt == "chart":
|
||||
meta = dict(defaults.get(pt, {"pageType": pt}))
|
||||
if chart_count == 0:
|
||||
meta["chartType"] = "growth"
|
||||
meta["title"] = "价值增长曲线"
|
||||
else:
|
||||
meta["chartType"] = "stacked"
|
||||
meta["title"] = "保证/非保证构成"
|
||||
chart_count += 1
|
||||
elif pt == "table":
|
||||
meta = dict(defaults.get(pt, {"pageType": pt}))
|
||||
if table_count == 0:
|
||||
meta["tableType"] = "no_withdraw"
|
||||
meta["title"] = "不提领方案数据表(每10年)"
|
||||
else:
|
||||
meta["tableType"] = "withdraw"
|
||||
meta["title"] = "提领方案数据表(每10年)"
|
||||
table_count += 1
|
||||
elif pt == "timeline":
|
||||
meta = dict(defaults.get(pt, {"pageType": pt}))
|
||||
# 第二个 timeline 用不同标题
|
||||
if sum(1 for s in slides if s["pageType"] == "timeline") > 0:
|
||||
meta["title"] = "中后期里程碑"
|
||||
meta["narrativeHint"] = "养老金与长期传承阶段"
|
||||
elif pt == "conclusion":
|
||||
meta = dict(defaults.get(pt, {"pageType": pt}))
|
||||
if sum(1 for s in slides if s["pageType"] == "conclusion") > 0:
|
||||
meta["title"] = "下一步行动建议"
|
||||
meta["narrativeHint"] = "建议尽快与保险经纪人预约确认方案"
|
||||
else:
|
||||
meta = dict(defaults.get(pt, {"pageType": pt}))
|
||||
slides.append(meta)
|
||||
|
||||
return {"slides": slides}
|
||||
|
||||
|
||||
def downgrade(engine):
|
||||
"""回滚迁移。"""
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text(
|
||||
"ALTER TABLE insurance_ppt_templates DROP COLUMN IF EXISTS slides_config_json"
|
||||
))
|
||||
logger.info("已删除 slides_config_json 列")
|
||||
36
api/insurance/db/migrate_018.py
Normal file
36
api/insurance/db/migrate_018.py
Normal file
@ -0,0 +1,36 @@
|
||||
"""迁移 018: 为 PPT 解析任务增加进度字段。"""
|
||||
import logging
|
||||
from sqlalchemy import text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate():
|
||||
"""执行迁移。"""
|
||||
from insurance.db.compat import db
|
||||
|
||||
columns = {
|
||||
"parse_progress": "INTEGER DEFAULT 0 NOT NULL",
|
||||
"parse_message": "TEXT",
|
||||
"parse_error": "TEXT",
|
||||
"parse_started_at": "TIMESTAMP",
|
||||
"parse_finished_at": "TIMESTAMP",
|
||||
}
|
||||
|
||||
for column_name, column_type in columns.items():
|
||||
if _column_exists(db, "insurance_ppt_sessions", column_name):
|
||||
logger.info(f"[migrate_018] {column_name} 已存在,跳过")
|
||||
continue
|
||||
db.session.execute(text(
|
||||
f"ALTER TABLE insurance_ppt_sessions ADD COLUMN {column_name} {column_type}"
|
||||
))
|
||||
db.session.commit()
|
||||
logger.info(f"[migrate_018] 已添加 {column_name} 列")
|
||||
|
||||
|
||||
def _column_exists(db, table_name: str, column_name: str) -> bool:
|
||||
result = db.session.execute(text(
|
||||
"SELECT COUNT(*) FROM information_schema.columns "
|
||||
"WHERE table_name = :table_name AND column_name = :column_name"
|
||||
), {"table_name": table_name, "column_name": column_name})
|
||||
return result.scalar() > 0
|
||||
@ -39,6 +39,8 @@ class PptCompany(db.Model):
|
||||
"evidenceRanking": json.loads(self.evidence_ranking_json) if self.evidence_ranking_json else [],
|
||||
"companyIntro": self.company_intro,
|
||||
"companyHighlights": json.loads(self.company_highlights_json) if self.company_highlights_json else [],
|
||||
"rating": self.rating,
|
||||
"foundedYear": self.founded_year,
|
||||
"logoUrl": self.logo_url,
|
||||
"status": self.status,
|
||||
"sortOrder": self.sort_order,
|
||||
@ -119,6 +121,7 @@ class PptTemplate(db.Model):
|
||||
preview_image = Column(String(500), nullable=True, comment="预览图地址")
|
||||
applicable_company_ids = Column(Text, nullable=True, comment="适用保司 ID 列表 JSON")
|
||||
applicable_product_ids = Column(Text, nullable=True, comment="适用产品 ID 列表 JSON")
|
||||
slides_config_json = Column(Text, nullable=True, comment="逐页幻灯片配置 JSON")
|
||||
status = Column(SmallInteger, default=1, comment="1=启用, 0=停用")
|
||||
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||
|
||||
@ -137,6 +140,7 @@ class PptTemplate(db.Model):
|
||||
"previewImage": self.preview_image,
|
||||
"applicableCompanyIds": json.loads(self.applicable_company_ids) if self.applicable_company_ids else [],
|
||||
"applicableProductIds": json.loads(self.applicable_product_ids) if self.applicable_product_ids else [],
|
||||
"slidesConfig": json.loads(self.slides_config_json) if self.slides_config_json else [],
|
||||
"status": self.status,
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,19 @@
|
||||
"displayName": "储蓄险商务风",
|
||||
"maxDisplayPolicyYear": 80,
|
||||
"tableIntervalYears": 10,
|
||||
"requiredPageTypes": [
|
||||
"cover",
|
||||
"company",
|
||||
"narrative",
|
||||
"chart",
|
||||
"chart",
|
||||
"timeline",
|
||||
"timeline",
|
||||
"table",
|
||||
"table",
|
||||
"conclusion",
|
||||
"closing"
|
||||
],
|
||||
"qualityRules": {
|
||||
"forbidPlaceholderImages": true,
|
||||
"forbidSyntheticOfficialValues": true,
|
||||
|
||||
@ -8,6 +8,10 @@
|
||||
"displayName": "储蓄险水墨风",
|
||||
"maxDisplayPolicyYear": 80,
|
||||
"tableIntervalYears": 10,
|
||||
"requiredPageTypes": [
|
||||
"cover", "company", "narrative", "chart", "chart",
|
||||
"timeline", "timeline", "table", "table", "conclusion", "closing"
|
||||
],
|
||||
"qualityRules": {
|
||||
"forbidPlaceholderImages": true,
|
||||
"forbidSyntheticOfficialValues": true,
|
||||
|
||||
@ -8,6 +8,10 @@
|
||||
"displayName": "储蓄险简洁风",
|
||||
"maxDisplayPolicyYear": 80,
|
||||
"tableIntervalYears": 10,
|
||||
"requiredPageTypes": [
|
||||
"cover", "company", "narrative", "chart", "chart",
|
||||
"timeline", "timeline", "table", "table", "conclusion", "closing"
|
||||
],
|
||||
"qualityRules": {
|
||||
"forbidPlaceholderImages": true,
|
||||
"forbidSyntheticOfficialValues": true,
|
||||
|
||||
@ -194,7 +194,7 @@ class ExtractionOrchestrator:
|
||||
self.use_cache = use_cache
|
||||
self.cache_dir = cache_dir
|
||||
|
||||
async def extract_plan(self, pdf_path: str, plan_type: str = "savings") -> ExtractionResult:
|
||||
async def extract_plan(self, pdf_path: str, plan_type: str = "savings", force_reparse: bool = False) -> ExtractionResult:
|
||||
"""从 PDF 提取结构化数据。"""
|
||||
from insurance.ppt.llm_client import llm_client
|
||||
from insurance.ppt.prompts import (
|
||||
@ -213,7 +213,7 @@ class ExtractionOrchestrator:
|
||||
)
|
||||
|
||||
# 检查缓存
|
||||
if self.use_cache:
|
||||
if self.use_cache and not force_reparse:
|
||||
cached = self._load_from_cache(abs_path)
|
||||
if cached:
|
||||
cached.duration_ms = (time.time() - start) * 1000
|
||||
|
||||
@ -35,7 +35,7 @@ PROVIDERS = {
|
||||
"deepseek": LLMProviderConfig(
|
||||
name="deepseek",
|
||||
base_url="https://api.deepseek.com/v1",
|
||||
model="deepseek-chat",
|
||||
model="deepseek-v4-pro",
|
||||
max_retries=2,
|
||||
rate_limit=0,
|
||||
),
|
||||
@ -149,7 +149,16 @@ async def _call_provider(
|
||||
"generationConfig": {"temperature": 0.3, "maxOutputTokens": 4096},
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"Unknown provider: {config.name}")
|
||||
# 自定义供应商:OpenAI 兼容格式
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
base = config.base_url.rstrip("/")
|
||||
url = f"{base}/chat/completions"
|
||||
body = {
|
||||
"model": config.model,
|
||||
"messages": messages,
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 4096,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout_s) as client:
|
||||
resp = await client.post(url, json=body, headers=headers)
|
||||
@ -159,20 +168,7 @@ async def _call_provider(
|
||||
latency_ms = (time.monotonic() - start) * 1000
|
||||
|
||||
# 解析响应
|
||||
if config.name in ("deepseek", "minimax"):
|
||||
content = ""
|
||||
choices = data.get("choices", [])
|
||||
if choices:
|
||||
content = choices[0].get("message", {}).get("content", "")
|
||||
usage = data.get("usage")
|
||||
tokens = None
|
||||
if usage:
|
||||
tokens = {
|
||||
"input": usage.get("prompt_tokens", 0),
|
||||
"output": usage.get("completion_tokens", 0),
|
||||
}
|
||||
else:
|
||||
# Gemini
|
||||
if config.name == "gemini":
|
||||
content = ""
|
||||
candidates = data.get("candidates", [])
|
||||
if candidates:
|
||||
@ -186,6 +182,19 @@ async def _call_provider(
|
||||
"input": usage_meta.get("promptTokenCount", 0),
|
||||
"output": usage_meta.get("candidatesTokenCount", 0),
|
||||
}
|
||||
else:
|
||||
# OpenAI 兼容格式(deepseek / minimax / 自定义供应商)
|
||||
content = ""
|
||||
choices = data.get("choices", [])
|
||||
if choices:
|
||||
content = choices[0].get("message", {}).get("content", "")
|
||||
usage = data.get("usage")
|
||||
tokens = None
|
||||
if usage:
|
||||
tokens = {
|
||||
"input": usage.get("prompt_tokens", 0),
|
||||
"output": usage.get("completion_tokens", 0),
|
||||
}
|
||||
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
|
||||
@ -158,6 +158,7 @@ def normalize_ci_plan(raw: dict, pdf_path: str = None, parser: str = "llm-json")
|
||||
"policyYear": int(policy_year),
|
||||
"totalPremiumPaid": _safe_number(row.get("total_premium_paid")),
|
||||
"deathBenefit": death_benefit,
|
||||
"totalSurrenderValue": death_benefit, # 渲染器统一字段(CI 用身故赔付作为主值)
|
||||
"ciBenefit": _safe_number(row.get("ci_benefit")) if row.get("ci_benefit") else None,
|
||||
"sourcePage": int(_safe_number(row.get("source_page"))) if row.get("source_page") else None,
|
||||
})
|
||||
@ -256,6 +257,7 @@ def normalize_iul_plan(raw: dict, pdf_path: str = None, parser: str = "llm-json"
|
||||
"totalPremiumPaid": _safe_number(row.get("total_premium_paid")),
|
||||
"guaranteedCashValue": _safe_number(row.get("guaranteed_cash_value")),
|
||||
"nonGuaranteedCashValue": non_guaranteed_cash,
|
||||
"totalSurrenderValue": non_guaranteed_cash, # 渲染器统一字段
|
||||
"guaranteedDeathBenefit": _safe_number(row.get("guaranteed_death_benefit")),
|
||||
"nonGuaranteedDeathBenefit": non_guaranteed_death,
|
||||
"sourcePage": int(_safe_number(row.get("source_page"))) if row.get("source_page") else None,
|
||||
@ -264,6 +266,7 @@ def normalize_iul_plan(raw: dict, pdf_path: str = None, parser: str = "llm-json"
|
||||
|
||||
annual_premium = _safe_number(policy.get("annual_premium"))
|
||||
payment_period = policy.get("premium_payment_period", "")
|
||||
pay_years = _extract_years(payment_period)
|
||||
|
||||
return {
|
||||
"kind": "iul",
|
||||
@ -279,6 +282,8 @@ def normalize_iul_plan(raw: dict, pdf_path: str = None, parser: str = "llm-json"
|
||||
"sumInsured": _safe_number(policy.get("sum_insured")),
|
||||
"initialPremium": _safe_number(policy.get("initial_premium")),
|
||||
"annualPremium": annual_premium,
|
||||
"payYears": pay_years,
|
||||
"totalPremium": annual_premium * pay_years,
|
||||
"paymentPeriod": str(payment_period),
|
||||
"coveragePeriod": policy.get("coverage_period", ""),
|
||||
},
|
||||
|
||||
196
api/insurance/ppt/parse_worker.py
Normal file
196
api/insurance/ppt/parse_worker.py
Normal file
@ -0,0 +1,196 @@
|
||||
"""PPT 解析后台任务。"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
from insurance.db.compat import db
|
||||
from insurance.models.ppt_session import PptSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_local_locks: set[str] = set()
|
||||
_local_locks_guard = threading.Lock()
|
||||
_redis_locks: set[str] = set()
|
||||
|
||||
|
||||
def start_parse_task(app, session_id: str, user_id: str) -> bool:
|
||||
"""启动后台解析任务,返回是否新启动。"""
|
||||
if not _acquire_task_lock(session_id):
|
||||
return False
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_run_parse_task,
|
||||
args=(app, session_id, user_id),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
return True
|
||||
|
||||
|
||||
def _run_parse_task(app, session_id: str, user_id: str):
|
||||
with app.app_context():
|
||||
try:
|
||||
_execute_parse(session_id, user_id)
|
||||
except Exception as exc:
|
||||
logger.error(f"PPT 解析后台任务失败 [{session_id}]: {exc}", exc_info=True)
|
||||
_mark_session_failed(session_id, str(exc))
|
||||
finally:
|
||||
_release_task_lock(session_id)
|
||||
db.session.remove()
|
||||
|
||||
|
||||
def _execute_parse(session_id: str, user_id: str):
|
||||
from insurance.ppt.extraction import ExtractionOrchestrator
|
||||
|
||||
session = PptSession.query.filter_by(id=session_id, user_id=user_id).first()
|
||||
if not session:
|
||||
return
|
||||
|
||||
files = json.loads(session.files_json) if session.files_json else []
|
||||
if not files:
|
||||
_mark_session_failed(session_id, "没有可解析的 PDF 文件")
|
||||
return
|
||||
|
||||
orchestrator = ExtractionOrchestrator()
|
||||
extractions = []
|
||||
total = len(files)
|
||||
|
||||
session.status = "parsing"
|
||||
session.parse_progress = 0
|
||||
session.parse_message = "解析任务已启动"
|
||||
session.parse_error = None
|
||||
session.parse_started_at = datetime.now()
|
||||
session.parse_finished_at = None
|
||||
session.extractions_json = json.dumps([], ensure_ascii=False)
|
||||
db.session.commit()
|
||||
|
||||
for index, file_info in enumerate(files, start=1):
|
||||
filename = file_info.get("name", "")
|
||||
filepath = file_info.get("path", "")
|
||||
plan_type = file_info.get("type", "savings")
|
||||
|
||||
_update_progress(
|
||||
session_id,
|
||||
progress=_progress(index - 1, total),
|
||||
message=f"正在解析 {filename or f'第 {index} 个文件'}",
|
||||
extractions=extractions,
|
||||
)
|
||||
|
||||
try:
|
||||
result = asyncio.run(orchestrator.extract_plan(filepath, plan_type, force_reparse=True))
|
||||
extractions.append(_build_extraction(file_info, filepath, result))
|
||||
except Exception as exc:
|
||||
logger.error(f"PDF 解析失败 [{filename}]: {exc}", exc_info=True)
|
||||
extractions.append({
|
||||
"pdfName": filename,
|
||||
"pdfPath": filepath,
|
||||
"planType": plan_type,
|
||||
"status": "error",
|
||||
"productName": "unknown",
|
||||
"data": None,
|
||||
"error": str(exc),
|
||||
"yearCount": 0,
|
||||
})
|
||||
|
||||
_update_progress(
|
||||
session_id,
|
||||
progress=_progress(index, total),
|
||||
message=f"已完成 {index}/{total} 个文件",
|
||||
extractions=extractions,
|
||||
)
|
||||
|
||||
session = PptSession.query.filter_by(id=session_id, user_id=user_id).first()
|
||||
if not session:
|
||||
return
|
||||
|
||||
all_failed = all(e.get("status") == "error" for e in extractions)
|
||||
partial_count = sum(1 for e in extractions if e.get("status") == "partial")
|
||||
session.extractions_json = json.dumps(extractions, ensure_ascii=False)
|
||||
session.status = "error" if all_failed else "parsed"
|
||||
session.parse_progress = 100
|
||||
if all_failed:
|
||||
session.parse_message = "解析失败"
|
||||
elif partial_count:
|
||||
session.parse_message = f"解析完成,{partial_count} 个文件需补充数据"
|
||||
else:
|
||||
session.parse_message = "解析完成"
|
||||
session.parse_error = "所有文件均解析失败" if all_failed else None
|
||||
session.parse_finished_at = datetime.now()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _build_extraction(file_info: dict, filepath: str, result) -> dict:
|
||||
return {
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
def _update_progress(session_id: str, progress: int, message: str, extractions: list[dict]):
|
||||
session = PptSession.query.filter_by(id=session_id).first()
|
||||
if not session:
|
||||
return
|
||||
session.status = "parsing"
|
||||
session.parse_progress = progress
|
||||
session.parse_message = message
|
||||
session.extractions_json = json.dumps(extractions, ensure_ascii=False)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _mark_session_failed(session_id: str, error: str):
|
||||
session = PptSession.query.filter_by(id=session_id).first()
|
||||
if not session:
|
||||
return
|
||||
session.status = "error"
|
||||
session.parse_progress = 100
|
||||
session.parse_message = "解析失败"
|
||||
session.parse_error = error[:1000]
|
||||
session.parse_finished_at = datetime.now()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _progress(done: int, total: int) -> int:
|
||||
if total <= 0:
|
||||
return 0
|
||||
return min(99, int(done / total * 100))
|
||||
|
||||
|
||||
def _acquire_task_lock(session_id: str) -> bool:
|
||||
redis_key = f"ppt_parse_lock:{session_id}"
|
||||
try:
|
||||
from insurance.db.compat import redis_client
|
||||
if redis_client and redis_client.set(redis_key, "1", nx=True, ex=1800):
|
||||
_redis_locks.add(session_id)
|
||||
return True
|
||||
if redis_client:
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with _local_locks_guard:
|
||||
if session_id in _local_locks:
|
||||
return False
|
||||
_local_locks.add(session_id)
|
||||
return True
|
||||
|
||||
|
||||
def _release_task_lock(session_id: str):
|
||||
if session_id in _redis_locks:
|
||||
try:
|
||||
from insurance.db.compat import redis_client
|
||||
if redis_client:
|
||||
redis_client.delete(f"ppt_parse_lock:{session_id}")
|
||||
except Exception:
|
||||
pass
|
||||
_redis_locks.discard(session_id)
|
||||
|
||||
with _local_locks_guard:
|
||||
_local_locks.discard(session_id)
|
||||
@ -43,9 +43,10 @@ def _build_deck_contract(
|
||||
normalized_data: dict,
|
||||
company_info: dict = None,
|
||||
theme: str = "broker",
|
||||
template_config: dict = None,
|
||||
all_products: list = None,
|
||||
) -> dict:
|
||||
"""将归一化数据转换为 DeckContract 格式。"""
|
||||
# 映射主题到 stylePreset
|
||||
theme_map = {
|
||||
"broker": "broker",
|
||||
"modern": "modern",
|
||||
@ -56,27 +57,38 @@ def _build_deck_contract(
|
||||
"chinese": "chinese",
|
||||
}
|
||||
|
||||
# 提取产品信息
|
||||
product = {
|
||||
"kind": normalized_data.get("kind", "savings"),
|
||||
"productName": normalized_data.get("productName", ""),
|
||||
"rawProductName": normalized_data.get("rawProductName", ""),
|
||||
"insured": normalized_data.get("insured", {}),
|
||||
"policy": normalized_data.get("policy", {}),
|
||||
"benefitRows": normalized_data.get("benefitRows", []),
|
||||
"withdrawalRows": normalized_data.get("withdrawalRows", []),
|
||||
"withdrawalProvenance": normalized_data.get("withdrawalProvenance", "missing"),
|
||||
}
|
||||
def _extract_product(data):
|
||||
return {
|
||||
"kind": data.get("kind", "savings"),
|
||||
"productName": data.get("productName", ""),
|
||||
"rawProductName": data.get("rawProductName", ""),
|
||||
"insured": data.get("insured", {}),
|
||||
"policy": data.get("policy", {}),
|
||||
"benefitRows": data.get("benefitRows", []),
|
||||
"withdrawalRows": data.get("withdrawalRows", []),
|
||||
"withdrawalProvenance": data.get("withdrawalProvenance", "missing"),
|
||||
}
|
||||
|
||||
# 公司信息
|
||||
company = company_info or {
|
||||
products = [_extract_product(normalized_data)]
|
||||
if all_products:
|
||||
for extra in all_products:
|
||||
if extra is not normalized_data:
|
||||
products.append(_extract_product(extra))
|
||||
|
||||
# 公司信息(扩展:传递 companyIntro, companyHighlights 等用于公司介绍页)
|
||||
company = {
|
||||
"id": "",
|
||||
"displayName": "",
|
||||
"shortEn": "",
|
||||
"evidence": [],
|
||||
"companyIntro": "",
|
||||
"companyHighlights": [],
|
||||
"rating": "",
|
||||
"logoUrl": "",
|
||||
}
|
||||
if company_info:
|
||||
company.update(company_info)
|
||||
|
||||
# 构建 DeckContract
|
||||
deck = {
|
||||
"id": f"deck_{uuid.uuid4().hex[:12]}",
|
||||
"generatedAt": __import__("datetime").datetime.now().isoformat(),
|
||||
@ -90,8 +102,9 @@ def _build_deck_contract(
|
||||
"quality": "standard",
|
||||
"outputFormat": "pptx",
|
||||
"outputStem": "insurance_plan",
|
||||
"products": [product],
|
||||
"products": products,
|
||||
"company": company,
|
||||
"templateConfig": template_config or {},
|
||||
"meta": normalized_data.get("source", {
|
||||
"pdfHash": "",
|
||||
"pdfPath": "",
|
||||
@ -123,6 +136,8 @@ class PptRenderer:
|
||||
theme: str = "broker",
|
||||
company_id: str = None,
|
||||
company_info: dict = None,
|
||||
template_config: dict = None,
|
||||
all_products: list = None,
|
||||
) -> dict:
|
||||
"""增强渲染(使用 fast_pptx_renderer.py)。
|
||||
|
||||
@ -142,7 +157,8 @@ class PptRenderer:
|
||||
os.makedirs(session_dir, exist_ok=True)
|
||||
|
||||
# 转换为 DeckContract 格式
|
||||
deck = _build_deck_contract(normalized_data, company_info, theme)
|
||||
deck = _build_deck_contract(normalized_data, company_info, theme,
|
||||
template_config, all_products)
|
||||
|
||||
# 写入 DeckContract JSON
|
||||
deck_path = os.path.join(session_dir, "deck.json")
|
||||
|
||||
@ -135,7 +135,7 @@ def parse_session(session_id):
|
||||
filepath = file_info.get("path", "")
|
||||
plan_type = file_info.get("type", "savings")
|
||||
try:
|
||||
result = asyncio.run(orchestrator.extract_plan(filepath, plan_type))
|
||||
result = asyncio.run(orchestrator.extract_plan(filepath, plan_type, force_reparse=True))
|
||||
extractions.append({
|
||||
"pdfName": file_info.get("name", ""),
|
||||
"pdfPath": filepath,
|
||||
@ -275,37 +275,39 @@ def generate_ppt(session_id):
|
||||
if not extractions:
|
||||
return error(ErrorCode.PARAM_ERROR, "无解析数据")
|
||||
|
||||
# 取第一个成功的提取结果
|
||||
ext_data = None
|
||||
pdf_path = None
|
||||
for ext in extractions:
|
||||
if ext.get("status") in ("success", "cached") and ext.get("data"):
|
||||
ext_data = ext["data"]
|
||||
pdf_path = ext.get("pdfPath")
|
||||
break
|
||||
|
||||
if not ext_data:
|
||||
return error(ErrorCode.PARAM_ERROR, "无有效提取数据")
|
||||
|
||||
# 归一化
|
||||
# 归一化所有成功的提取结果
|
||||
from insurance.ppt.normalizer import normalize_savings_plan, normalize_ci_plan, normalize_iul_plan
|
||||
from insurance.ppt.validator import validate_formal_savings_plan, validate_formal_ci_plan, validate_formal_iul_plan
|
||||
|
||||
plan_type = ext_data.get("product_type", "savings")
|
||||
if plan_type == "ci":
|
||||
normalized = normalize_ci_plan(ext_data, pdf_path)
|
||||
issues = validate_formal_ci_plan(normalized)
|
||||
elif plan_type == "iul":
|
||||
normalized = normalize_iul_plan(ext_data, pdf_path)
|
||||
issues = validate_formal_iul_plan(normalized)
|
||||
else:
|
||||
normalized = normalize_savings_plan(ext_data, pdf_path)
|
||||
issues = validate_formal_savings_plan(normalized)
|
||||
all_normalized = []
|
||||
for ext in extractions:
|
||||
if ext.get("status") not in ("success", "cached") or not ext.get("data"):
|
||||
continue
|
||||
ext_data = ext["data"]
|
||||
pdf_path = ext.get("pdfPath")
|
||||
plan_type = ext_data.get("product_type", "savings")
|
||||
try:
|
||||
if plan_type == "ci":
|
||||
normalized = normalize_ci_plan(ext_data, pdf_path)
|
||||
issues = validate_formal_ci_plan(normalized)
|
||||
elif plan_type == "iul":
|
||||
normalized = normalize_iul_plan(ext_data, pdf_path)
|
||||
issues = validate_formal_iul_plan(normalized)
|
||||
else:
|
||||
normalized = normalize_savings_plan(ext_data, pdf_path)
|
||||
issues = validate_formal_savings_plan(normalized)
|
||||
errors = [i for i in issues if i.level == "error"]
|
||||
if errors:
|
||||
logger.warning(f"产品 {ext_data.get('product_type', '')} 有验证错误: {errors}")
|
||||
all_normalized.append(normalized)
|
||||
except Exception as e:
|
||||
logger.error(f"归一化失败: {e}", exc_info=True)
|
||||
|
||||
# 检查是否有阻断性错误
|
||||
errors = [i for i in issues if i.level == "error"]
|
||||
if errors:
|
||||
return error(ErrorCode.PARAM_ERROR, f"数据验证失败: {'; '.join(e.message for e in errors)}")
|
||||
if not all_normalized:
|
||||
return error(ErrorCode.PARAM_ERROR, "无有效提取数据")
|
||||
|
||||
normalized = all_normalized[0]
|
||||
plan_type = normalized.get("kind", "savings")
|
||||
|
||||
# 渲染 PPT
|
||||
session.status = "generating"
|
||||
@ -317,7 +319,24 @@ def generate_ppt(session_id):
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
output_path = os.path.join(output_dir, f"{session_id}.pptx")
|
||||
|
||||
result = renderer.render_enhanced(normalized, output_path, theme=theme, company_id=company_id)
|
||||
# 加载模板配置
|
||||
from insurance.models.ppt_config import PptTemplate, PptCompany
|
||||
template = PptTemplate.query.filter_by(plan_type=plan_type, style_preset=theme, status=1).first()
|
||||
template_config = template.to_dict() if template else None
|
||||
|
||||
# 加载公司信息
|
||||
company_info = None
|
||||
if company_id:
|
||||
company = PptCompany.query.get(company_id)
|
||||
if company:
|
||||
company_info = company.to_dict()
|
||||
|
||||
result = renderer.render_enhanced(
|
||||
normalized, output_path, theme=theme,
|
||||
company_id=company_id, company_info=company_info,
|
||||
template_config=template_config,
|
||||
all_products=all_normalized if len(all_normalized) > 1 else None,
|
||||
)
|
||||
|
||||
if not result.get("ok"):
|
||||
session.status = "error"
|
||||
@ -431,6 +450,61 @@ def validate_extraction(session_id):
|
||||
})
|
||||
|
||||
|
||||
# ─── 更新提取数据 ─────────────────────────────────────────
|
||||
|
||||
@ppt_bp.route("/session/<session_id>/extractions", methods=["PUT"])
|
||||
@jwt_required
|
||||
def update_extractions(session_id):
|
||||
"""保存用户修改后的提取数据。"""
|
||||
user_id = str(getattr(request, "user_id", "guest"))
|
||||
session = _get_session(session_id, user_id)
|
||||
if not session:
|
||||
return error(ErrorCode.NOT_FOUND, "会话不存在")
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
extractions = data.get("extractions")
|
||||
if not isinstance(extractions, list):
|
||||
return error(ErrorCode.PARAM_ERROR, "extractions 必须是数组")
|
||||
|
||||
# 合并更新:只更新 data 字段,保留 pdfPath/status 等元信息
|
||||
existing = json.loads(session.extractions_json) if session.extractions_json else []
|
||||
existing_map = {e["pdfName"]: e for e in existing}
|
||||
|
||||
for ext in extractions:
|
||||
pdf_name = ext.get("pdfName")
|
||||
if not pdf_name or pdf_name not in existing_map:
|
||||
continue
|
||||
# 更新数据字段
|
||||
if "data" in ext:
|
||||
existing_map[pdf_name]["data"] = ext["data"]
|
||||
if "productName" in ext:
|
||||
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)
|
||||
|
||||
updated = list(existing_map.values())
|
||||
session.extractions_json = json.dumps(updated, ensure_ascii=False)
|
||||
session.status = "parsed" # 回到 parsed 状态,需要重新生成
|
||||
_save_session(session)
|
||||
|
||||
return success({
|
||||
"sessionId": session_id,
|
||||
"status": "updated",
|
||||
"extractions": [{
|
||||
"pdfName": e["pdfName"],
|
||||
"planType": e["planType"],
|
||||
"status": e["status"],
|
||||
"productName": e["productName"],
|
||||
"yearCount": e["yearCount"],
|
||||
} for e in updated],
|
||||
})
|
||||
|
||||
|
||||
# ─── 公司知识库匹配 ───────────────────────────────────────
|
||||
|
||||
@ppt_bp.route("/company-kb/match", methods=["POST"])
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -4,6 +4,7 @@
|
||||
<el-steps :active="currentStep" finish-status="success" align-center class="ppt-steps">
|
||||
<el-step title="上传" description="选择PDF计划书" />
|
||||
<el-step title="解析" description="AI智能识别" />
|
||||
<el-step title="校验" description="查看并确认数据" />
|
||||
<el-step title="生成" description="选择风格并生成" />
|
||||
<el-step title="结果" description="下载PPT" />
|
||||
</el-steps>
|
||||
@ -20,16 +21,22 @@
|
||||
@parsed="onParsed"
|
||||
@back="currentStep = 0"
|
||||
/>
|
||||
<PptGenerate
|
||||
<PptDataReview
|
||||
v-else-if="currentStep === 2"
|
||||
:session-id="sessionId"
|
||||
@generated="onGenerated"
|
||||
@confirmed="onDataConfirmed"
|
||||
@back="currentStep = 1"
|
||||
/>
|
||||
<PptResult
|
||||
<PptGenerate
|
||||
v-else-if="currentStep === 3"
|
||||
:session-id="sessionId"
|
||||
@regenerate="currentStep = 2"
|
||||
@generated="onGenerated"
|
||||
@back="currentStep = 2"
|
||||
/>
|
||||
<PptResult
|
||||
v-else-if="currentStep === 4"
|
||||
:session-id="sessionId"
|
||||
@regenerate="currentStep = 3"
|
||||
@new-session="resetAll"
|
||||
/>
|
||||
</div>
|
||||
@ -40,6 +47,7 @@
|
||||
import { ref } from 'vue'
|
||||
import PptUpload from './components/ppt/PptUpload.vue'
|
||||
import PptParsing from './components/ppt/PptParsing.vue'
|
||||
import PptDataReview from './components/ppt/PptDataReview.vue'
|
||||
import PptGenerate from './components/ppt/PptGenerate.vue'
|
||||
import PptResult from './components/ppt/PptResult.vue'
|
||||
|
||||
@ -55,10 +63,14 @@ function onParsed() {
|
||||
currentStep.value = 2
|
||||
}
|
||||
|
||||
function onGenerated() {
|
||||
function onDataConfirmed() {
|
||||
currentStep.value = 3
|
||||
}
|
||||
|
||||
function onGenerated() {
|
||||
currentStep.value = 4
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
currentStep.value = 0
|
||||
sessionId.value = ''
|
||||
|
||||
@ -20,34 +20,28 @@
|
||||
|
||||
<el-form-item label="模型名称">
|
||||
<div style="display: flex; gap: 8px; width: 100%">
|
||||
<el-input v-model="form.ppt_llm_model" placeholder="如 deepseek-chat" />
|
||||
<el-button v-if="!difyModels.length" @click="fetchDifyModels" :loading="fetchingModels">从 Dify 获取</el-button>
|
||||
<el-dropdown v-else trigger="click" @command="(cmd: string) => applyDifyModel('ppt', cmd)">
|
||||
<el-button>从 Dify 获取 <el-icon><ArrowDown /></el-icon></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-for="m in difyModels" :key="m.provider + '/' + m.model"
|
||||
:command="m.provider + '|' + m.model">
|
||||
{{ m.label }} ({{ m.provider }}/{{ m.model }})
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-select
|
||||
v-if="pptAvailableModels.length"
|
||||
v-model="form.ppt_llm_model"
|
||||
filterable allow-create
|
||||
placeholder="选择或输入模型名"
|
||||
style="flex: 1"
|
||||
>
|
||||
<el-option v-for="m in pptAvailableModels" :key="m" :label="m" :value="m" />
|
||||
</el-select>
|
||||
<el-input v-else v-model="form.ppt_llm_model" placeholder="如 deepseek-v4-pro" style="flex: 1" />
|
||||
<el-button @click="handlePptSyncModels" :loading="syncingPptModels">同步模型</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="form.ppt_llm_provider !== 'dify'" label="API Key">
|
||||
<el-input v-model="form.ppt_llm_api_key" type="password" show-password placeholder="留空则使用环境变量配置" />
|
||||
<el-form-item label="API Key">
|
||||
<el-input v-model="form.ppt_llm_api_key" type="password" show-password placeholder="该供应商的 API Key" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="form.ppt_llm_provider === 'custom'" label="Base URL">
|
||||
<el-input v-model="form.ppt_llm_base_url" placeholder="如 https://api.openai.com/v1" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="form.ppt_llm_provider === 'dify'" label="">
|
||||
<el-tag type="success">通过 Dify 调用,无需配置 API Key</el-tag>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 海报文案模型 -->
|
||||
<el-divider content-position="left">海报文案模型</el-divider>
|
||||
|
||||
@ -63,34 +57,30 @@
|
||||
|
||||
<el-form-item label="模型名称">
|
||||
<div style="display: flex; gap: 8px; width: 100%">
|
||||
<el-input v-model="form.poster_llm_model" placeholder="如 deepseek-chat" />
|
||||
<el-button v-if="!difyModels.length" @click="fetchDifyModels" :loading="fetchingModels">从 Dify 获取</el-button>
|
||||
<el-dropdown v-else trigger="click" @command="(cmd: string) => applyDifyModel('poster', cmd)">
|
||||
<el-button>从 Dify 获取 <el-icon><ArrowDown /></el-icon></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-for="m in difyModels" :key="m.provider + '/' + m.model"
|
||||
:command="m.provider + '|' + m.model">
|
||||
{{ m.label }} ({{ m.provider }}/{{ m.model }})
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-select
|
||||
v-if="availableModels.length"
|
||||
v-model="form.poster_llm_model"
|
||||
filterable allow-create
|
||||
placeholder="选择或输入模型名"
|
||||
style="flex: 1"
|
||||
>
|
||||
<el-option v-for="m in availableModels" :key="m" :label="m" :value="m" />
|
||||
</el-select>
|
||||
<el-input v-else v-model="form.poster_llm_model" placeholder="如 deepseek-v4-pro" style="flex: 1" />
|
||||
<el-button @click="handleSyncModels" :loading="syncingModels">
|
||||
同步模型
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="form.poster_llm_provider !== 'dify'" label="API Key">
|
||||
<el-input v-model="form.poster_llm_api_key" type="password" show-password placeholder="留空则使用环境变量配置" />
|
||||
<el-form-item label="API Key">
|
||||
<el-input v-model="form.poster_llm_api_key" type="password" show-password placeholder="该供应商的 API Key" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="form.poster_llm_provider === 'custom'" label="Base URL">
|
||||
<el-input v-model="form.poster_llm_base_url" placeholder="如 https://api.openai.com/v1" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="form.poster_llm_provider === 'dify'" label="">
|
||||
<el-tag type="success">通过 Dify 调用,无需配置 API Key</el-tag>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 海报图片模型 -->
|
||||
<el-divider content-position="left">海报图片模型</el-divider>
|
||||
|
||||
@ -133,13 +123,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowDown } from '@element-plus/icons-vue'
|
||||
import { pptAdminApi } from '@/utils/ppt-admin-api'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const fetchingModels = ref(false)
|
||||
const difyModels = ref<Array<{ provider: string; model: string; label: string }>>([])
|
||||
const syncingModels = ref(false)
|
||||
const availableModels = ref<string[]>([])
|
||||
const syncingPptModels = ref(false)
|
||||
const pptAvailableModels = ref<string[]>([])
|
||||
|
||||
const form = reactive({
|
||||
ppt_llm_provider: 'deepseek',
|
||||
@ -158,7 +149,7 @@ const form = reactive({
|
||||
})
|
||||
|
||||
const DEFAULT_MODELS: Record<string, string> = {
|
||||
deepseek: 'deepseek-chat',
|
||||
deepseek: 'deepseek-v4-pro',
|
||||
minimax: 'MiniMax-2.7-Flash',
|
||||
gemini: 'gemini-2.5-flash',
|
||||
openai: 'gpt-4o',
|
||||
@ -213,33 +204,47 @@ function inferBaseUrl(model: string): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
function applyDifyModel(target: 'ppt' | 'poster', command: string) {
|
||||
const [, model] = command.split('|')
|
||||
if (target === 'ppt') {
|
||||
form.ppt_llm_provider = 'dify'
|
||||
form.ppt_llm_model = model
|
||||
form.ppt_llm_api_key = ''
|
||||
form.ppt_llm_base_url = ''
|
||||
} else {
|
||||
form.poster_llm_provider = 'dify'
|
||||
form.poster_llm_model = model
|
||||
form.poster_llm_api_key = ''
|
||||
form.poster_llm_base_url = ''
|
||||
async function handleSyncModels() {
|
||||
syncingModels.value = true
|
||||
try {
|
||||
const res: any = await pptAdminApi.syncModels({
|
||||
provider: form.poster_llm_provider,
|
||||
api_key: form.poster_llm_api_key,
|
||||
base_url: form.poster_llm_base_url,
|
||||
})
|
||||
const models = res?.data?.models || []
|
||||
availableModels.value = models
|
||||
if (!models.length) {
|
||||
ElMessage.warning('未获取到模型,请检查 API Key 和供应商配置')
|
||||
} else {
|
||||
ElMessage.success(`同步成功,共 ${models.length} 个模型`)
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '同步失败')
|
||||
} finally {
|
||||
syncingModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDifyModels() {
|
||||
fetchingModels.value = true
|
||||
async function handlePptSyncModels() {
|
||||
syncingPptModels.value = true
|
||||
try {
|
||||
const res: any = await pptAdminApi.getAvailableModels()
|
||||
difyModels.value = res?.data?.models || []
|
||||
if (!difyModels.value.length) {
|
||||
ElMessage.warning('未获取到可用模型,请检查 Dify Workspace API Key 配置')
|
||||
const res: any = await pptAdminApi.syncModels({
|
||||
provider: form.ppt_llm_provider,
|
||||
api_key: form.ppt_llm_api_key,
|
||||
base_url: form.ppt_llm_base_url,
|
||||
})
|
||||
const models = res?.data?.models || []
|
||||
pptAvailableModels.value = models
|
||||
if (!models.length) {
|
||||
ElMessage.warning('未获取到模型,请检查 API Key 和供应商配置')
|
||||
} else {
|
||||
ElMessage.success(`同步成功,共 ${models.length} 个模型`)
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('获取模型列表失败')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '同步失败')
|
||||
} finally {
|
||||
fetchingModels.value = false
|
||||
syncingPptModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -17,6 +17,11 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="stylePreset" label="风格" width="100" />
|
||||
<el-table-column prop="scenarioTag" label="场景标签" width="100" />
|
||||
<el-table-column label="幻灯片" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" type="info">{{ row.slidesConfig?.length || 0 }}页</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||
@ -38,7 +43,7 @@
|
||||
:total="total" :page-size="pageSize" v-model:current-page="page"
|
||||
@current-change="loadData" style="margin-top: 16px; justify-content: flex-end" />
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editRow ? '编辑模板' : '新增模板'" width="500px">
|
||||
<el-dialog v-model="dialogVisible" :title="editRow ? '编辑模板' : '新增模板'" width="720px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="模板ID" required>
|
||||
<el-input v-model="form.id" :disabled="!!editRow" />
|
||||
@ -68,6 +73,39 @@
|
||||
<el-form-item label="预览图URL">
|
||||
<el-input v-model="form.previewImage" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">幻灯片配置</el-divider>
|
||||
|
||||
<div class="slides-config">
|
||||
<div v-for="(slide, idx) in slidesList" :key="idx" class="slide-item">
|
||||
<div class="slide-item-header">
|
||||
<span class="slide-index">{{ idx + 1 }}</span>
|
||||
<el-select v-model="slide.pageType" size="small" style="width: 120px">
|
||||
<el-option v-for="pt in pageTypes" :key="pt.value" :label="pt.label" :value="pt.value" />
|
||||
</el-select>
|
||||
<el-input v-model="slide.title" size="small" placeholder="页面标题" style="flex:1; margin: 0 8px" />
|
||||
<el-button size="small" type="danger" text @click="slidesList.splice(idx, 1)">删除</el-button>
|
||||
<el-button size="small" text :disabled="idx === 0" @click="moveSlide(idx, -1)">↑</el-button>
|
||||
<el-button size="small" text :disabled="idx === slidesList.length - 1" @click="moveSlide(idx, 1)">↓</el-button>
|
||||
</div>
|
||||
<div class="slide-item-detail">
|
||||
<el-input v-model="slide.narrativeHint" size="small" placeholder="叙事提示(30字内)" style="width: 50%" />
|
||||
<el-select v-if="slide.pageType === 'chart'" v-model="slide.chartType" size="small" style="width: 120px; margin-left: 8px">
|
||||
<el-option label="增长曲线" value="growth" />
|
||||
<el-option label="构成图" value="stacked" />
|
||||
</el-select>
|
||||
<el-select v-if="slide.pageType === 'table'" v-model="slide.tableType" size="small" style="width: 120px; margin-left: 8px">
|
||||
<el-option label="不提领" value="no_withdraw" />
|
||||
<el-option label="提领" value="withdraw" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 12px; display: flex; gap: 8px">
|
||||
<el-button size="small" @click="addSlide">添加页面</el-button>
|
||||
<el-button size="small" @click="resetSlides">恢复默认</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
@ -92,6 +130,55 @@ const dialogVisible = ref(false)
|
||||
const editRow = ref<any>(null)
|
||||
const form = ref<any>({})
|
||||
const saving = ref(false)
|
||||
const slidesList = ref<any[]>([])
|
||||
|
||||
const pageTypes = [
|
||||
{ value: 'cover', label: '封面' },
|
||||
{ value: 'company', label: '公司介绍' },
|
||||
{ value: 'narrative', label: '叙事页' },
|
||||
{ value: 'chart', label: '图表' },
|
||||
{ value: 'timeline', label: '时间轴' },
|
||||
{ value: 'table', label: '数据表' },
|
||||
{ value: 'compare', label: '对比' },
|
||||
{ value: 'synergy', label: '协同关系' },
|
||||
{ value: 'conclusion', label: '结论' },
|
||||
{ value: 'closing', label: '结束页' },
|
||||
]
|
||||
|
||||
const defaultSlides: Record<string, any[]> = {
|
||||
savings: [
|
||||
{ pageType: 'cover', title: '{{customerName}} 专属方案', narrativeHint: '' },
|
||||
{ pageType: 'company', title: '{{companyName}} 公司介绍', narrativeHint: '公司事实来自内部知识库' },
|
||||
{ pageType: 'narrative', title: '方案核心逻辑', narrativeHint: '先看结构,再看收益与保障' },
|
||||
{ pageType: 'chart', title: '价值增长曲线', chartType: 'growth', narrativeHint: '先看回本区间,再看20/30年关键点' },
|
||||
{ pageType: 'chart', title: '保证/非保证构成', chartType: 'stacked', narrativeHint: '保证底盘与弹性贡献' },
|
||||
{ pageType: 'timeline', title: '家庭时间轴', narrativeHint: '关键里程碑按年龄展开' },
|
||||
{ pageType: 'timeline', title: '中后期里程碑', narrativeHint: '养老金与长期传承阶段' },
|
||||
{ pageType: 'table', title: '不提领方案数据表(每10年)', tableType: 'no_withdraw', narrativeHint: '含单利复利' },
|
||||
{ pageType: 'table', title: '提领方案数据表(每10年)', tableType: 'withdraw', narrativeHint: '教育金节点' },
|
||||
{ pageType: 'conclusion', title: '结论', narrativeHint: '一张保家庭不失速,一张保未来不落空' },
|
||||
{ pageType: 'closing', title: '感谢信任', narrativeHint: '方案可继续迭代优化' },
|
||||
],
|
||||
ci: [
|
||||
{ pageType: 'cover', title: '{{customerName}} 家庭保障方案', narrativeHint: '' },
|
||||
{ pageType: 'company', title: '{{companyName}} 公司介绍', narrativeHint: '' },
|
||||
{ pageType: 'narrative', title: '家庭风险防线', narrativeHint: '先防风险,再做未来' },
|
||||
{ pageType: 'chart', title: '重疾保障曲线', chartType: 'growth', narrativeHint: '保障力度与现金价值' },
|
||||
{ pageType: 'table', title: '保障数据表(每10年)', tableType: 'no_withdraw', narrativeHint: '' },
|
||||
{ pageType: 'conclusion', title: '结论', narrativeHint: '' },
|
||||
{ pageType: 'closing', title: '感谢信任', narrativeHint: '' },
|
||||
],
|
||||
iul: [
|
||||
{ pageType: 'cover', title: '{{customerName}} 传承方案', narrativeHint: '' },
|
||||
{ pageType: 'company', title: '{{companyName}} 公司介绍', narrativeHint: '' },
|
||||
{ pageType: 'narrative', title: '传承杠杆设计', narrativeHint: '先看杠杆,再看现金价值弹性' },
|
||||
{ pageType: 'chart', title: 'IUL 长期利益曲线', chartType: 'growth', narrativeHint: '现金值和身故赔偿' },
|
||||
{ pageType: 'compare', title: '传统寿险 vs IUL', narrativeHint: '传统固定利率 vs 指数策略' },
|
||||
{ pageType: 'table', title: '数据表(每10年)', tableType: 'no_withdraw', narrativeHint: '' },
|
||||
{ pageType: 'conclusion', title: '结论', narrativeHint: '' },
|
||||
{ pageType: 'closing', title: '感谢信任', narrativeHint: '' },
|
||||
],
|
||||
}
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
@ -110,16 +197,36 @@ async function loadData() {
|
||||
function showDialog(row?: any) {
|
||||
editRow.value = row || null
|
||||
form.value = row ? { ...row } : { id: '', name: '', planType: 'savings', stylePreset: 'broker', scenarioTag: '' }
|
||||
slidesList.value = row?.slidesConfig?.length
|
||||
? JSON.parse(JSON.stringify(row.slidesConfig))
|
||||
: JSON.parse(JSON.stringify(defaultSlides[form.value.planType] || defaultSlides.savings))
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function addSlide() {
|
||||
slidesList.value.push({ pageType: 'narrative', title: '', narrativeHint: '' })
|
||||
}
|
||||
|
||||
function moveSlide(idx: number, dir: number) {
|
||||
const target = idx + dir
|
||||
if (target < 0 || target >= slidesList.value.length) return
|
||||
const arr = [...slidesList.value]
|
||||
;[arr[idx], arr[target]] = [arr[target], arr[idx]]
|
||||
slidesList.value = arr
|
||||
}
|
||||
|
||||
function resetSlides() {
|
||||
slidesList.value = JSON.parse(JSON.stringify(defaultSlides[form.value.planType] || defaultSlides.savings))
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { ...form.value, slidesConfig: slidesList.value }
|
||||
if (editRow.value) {
|
||||
await pptAdminApi.updateTemplate(editRow.value.id, form.value)
|
||||
await pptAdminApi.updateTemplate(editRow.value.id, payload)
|
||||
} else {
|
||||
await pptAdminApi.createTemplate(form.value)
|
||||
await pptAdminApi.createTemplate(payload)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
@ -137,3 +244,40 @@ async function onToggleStatus(row: any) {
|
||||
loadData()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slides-config {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.slide-item {
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
.slide-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.slide-index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--el-color-primary);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.slide-item-detail {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
497
frontend/src/pages/components/ppt/PptDataReview.vue
Normal file
497
frontend/src/pages/components/ppt/PptDataReview.vue
Normal file
@ -0,0 +1,497 @@
|
||||
<template>
|
||||
<div class="ppt-data-review">
|
||||
<el-card shadow="never" class="review-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
<span>数据校验</span>
|
||||
<el-tag v-if="validationStatus === 'pass'" type="success" size="small" class="header-tag">全部通过</el-tag>
|
||||
<el-tag v-else-if="validationStatus === 'warn'" type="warning" size="small" class="header-tag">有警告</el-tag>
|
||||
<el-tag v-else-if="validationStatus === 'error'" type="danger" size="small" class="header-tag">有错误</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="loading-state">
|
||||
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
|
||||
<p>正在校验数据...</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 验证问题列表 -->
|
||||
<div v-if="issues.length" class="issues-section">
|
||||
<div
|
||||
v-for="(issue, idx) in issues"
|
||||
:key="idx"
|
||||
class="issue-item"
|
||||
:class="issue.severity"
|
||||
>
|
||||
<el-icon v-if="issue.severity === 'error'" color="#f56c6c"><CircleCloseFilled /></el-icon>
|
||||
<el-icon v-else color="#e6a23c"><WarningFilled /></el-icon>
|
||||
<span class="issue-field">{{ issue.field }}</span>
|
||||
<span class="issue-msg">{{ issue.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 逐个产品展示 -->
|
||||
<div v-for="(ext, idx) in extractions" :key="idx" class="extraction-block">
|
||||
<div class="extraction-header">
|
||||
<el-tag :type="typeTagMap[ext.planType] || 'info'" size="small">
|
||||
{{ 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>
|
||||
</div>
|
||||
|
||||
<template v-if="ext.data && (ext.status === 'success' || ext.status === 'cached')">
|
||||
<!-- 关键指标卡 -->
|
||||
<div class="metrics-row">
|
||||
<div class="metric-card" v-for="m in getKeyMetrics(ext)" :key="m.label">
|
||||
<div class="metric-value">{{ m.value }}</div>
|
||||
<div class="metric-label">{{ m.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 基本信息(可编辑) -->
|
||||
<el-collapse>
|
||||
<el-collapse-item title="基本信息(可编辑)" name="basic">
|
||||
<el-form label-width="120px" size="small" class="basic-form">
|
||||
<el-form-item label="产品名称">
|
||||
<el-input v-model="ext.data.product_name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="产品类型">
|
||||
<el-select v-model="ext.data.product_type" style="width: 100%">
|
||||
<el-option label="储蓄险" value="savings" />
|
||||
<el-option label="重疾险" value="ci" />
|
||||
<el-option label="IUL" value="iul" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="被保人年龄">
|
||||
<el-input-number v-model="ext.data.insured.age" :min="0" :max="120" />
|
||||
</el-form-item>
|
||||
<el-form-item label="被保人性别">
|
||||
<el-select v-model="ext.data.insured.gender" style="width: 100%">
|
||||
<el-option label="男" value="male" />
|
||||
<el-option label="女" value="female" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="年缴保费">
|
||||
<el-input-number v-model="ext.data.policy.annual_premium" :min="0" :step="1000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="缴费年期">
|
||||
<el-input-number v-model="ext.data.policy.premium_payment_period" :min="1" :max="50" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="ext.data.policy.sum_insured" label="保额">
|
||||
<el-input-number v-model="ext.data.policy.sum_insured" :min="0" :step="10000" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-collapse-item>
|
||||
|
||||
<!-- 利益演示表(可编辑) -->
|
||||
<el-collapse-item :title="`利益演示表(${ext.yearCount} 行,可编辑)`" name="benefit">
|
||||
<div class="table-wrapper">
|
||||
<el-table
|
||||
:data="ext.data.benefit_illustration || []"
|
||||
stripe
|
||||
border
|
||||
size="small"
|
||||
max-height="400"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="policy_year" label="保单年度" width="80">
|
||||
<template #default="{ row }">
|
||||
<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="age" label="年龄" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.age" :min="0" :max="130" size="small" controls-position="right" style="width: 60px" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="total_premium_paid" label="累计保费" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.total_premium_paid" :min="0" :step="1000" size="small" controls-position="right" style="width: 100px" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="guaranteed_cash_value" label="保证现金价值" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<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="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" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="hasField(ext.data.benefit_illustration, 'terminal_dividend')" prop="terminal_dividend" label="终期红利" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.terminal_dividend" :min="0" :step="1000" size="small" controls-position="right" style="width: 100px" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="total_surrender_value" label="退保总值" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.total_surrender_value" :min="0" :step="1000" size="small" controls-position="right" style="width: 100px" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="hasField(ext.data.benefit_illustration, 'death_benefit')" prop="death_benefit" label="身故赔偿" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<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>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
|
||||
<!-- 退保提取表(可编辑,如果有) -->
|
||||
<el-collapse-item
|
||||
v-if="ext.data.withdrawal_illustration && ext.data.withdrawal_illustration.length"
|
||||
:title="`退保提取表(${ext.data.withdrawal_illustration.length} 行,可编辑)`"
|
||||
name="withdrawal"
|
||||
>
|
||||
<div class="table-wrapper">
|
||||
<el-table
|
||||
:data="ext.data.withdrawal_illustration"
|
||||
stripe
|
||||
border
|
||||
size="small"
|
||||
max-height="300"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="policy_year" label="保单年度" width="80">
|
||||
<template #default="{ row }">
|
||||
<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">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.withdrawal_amount" :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">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.cumulative_withdrawal" :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">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.remaining_surrender_value" :min="0" :step="1000" size="small" controls-position="right" style="width: 110px" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</template>
|
||||
|
||||
<el-empty v-else-if="ext.status === 'error'" :description="ext.error || '解析失败'" />
|
||||
</div>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<div class="review-actions">
|
||||
<el-button @click="$emit('back')">返回上传</el-button>
|
||||
<el-button v-if="isDirty" @click="handleSave" :loading="saving">
|
||||
保存修改
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:disabled="validationStatus === 'error'"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
确认数据,生成 PPT
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
DataAnalysis, Loading, CircleCloseFilled, WarningFilled,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { pptApi } from '@/utils/ppt-api'
|
||||
|
||||
const props = defineProps<{
|
||||
sessionId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirmed: []
|
||||
back: []
|
||||
}>()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const extractions = ref<any[]>([])
|
||||
const issues = ref<Array<{ field: string; severity: string; message: string }>>([])
|
||||
const originalJson = ref('')
|
||||
|
||||
const typeTagMap: Record<string, string> = {
|
||||
savings: 'success',
|
||||
ci: 'warning',
|
||||
iul: 'info',
|
||||
}
|
||||
const typeNameMap: Record<string, string> = {
|
||||
savings: '储蓄险',
|
||||
ci: '重疾险',
|
||||
iul: 'IUL',
|
||||
}
|
||||
|
||||
const validationStatus = computed(() => {
|
||||
if (issues.value.some(i => i.severity === 'error')) return 'error'
|
||||
if (issues.value.some(i => i.severity === 'warn')) return 'warn'
|
||||
return 'pass'
|
||||
})
|
||||
|
||||
const isDirty = computed(() => {
|
||||
return JSON.stringify(extractions.value) !== originalJson.value
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// 并行获取会话数据和验证结果
|
||||
const [sessionRes, validateRes]: any[] = await Promise.all([
|
||||
pptApi.getSession(props.sessionId),
|
||||
pptApi.validate(props.sessionId),
|
||||
])
|
||||
|
||||
// 解析会话中的完整提取数据
|
||||
const sessionData = sessionRes?.data
|
||||
if (sessionData?.extractions_json) {
|
||||
extractions.value = JSON.parse(sessionData.extractions_json)
|
||||
} else if (sessionData?.extractions) {
|
||||
extractions.value = sessionData.extractions
|
||||
}
|
||||
|
||||
// 确保 data 中的子对象存在
|
||||
for (const ext of extractions.value) {
|
||||
if (ext.data) {
|
||||
ext.data.insured = ext.data.insured || {}
|
||||
ext.data.policy = ext.data.policy || {}
|
||||
}
|
||||
}
|
||||
|
||||
originalJson.value = JSON.stringify(extractions.value)
|
||||
|
||||
// 验证结果
|
||||
const validateData = validateRes?.data
|
||||
issues.value = validateData?.issues || []
|
||||
} catch (e: any) {
|
||||
ElMessage.error('加载数据失败: ' + (e?.message || '未知错误'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function getKeyMetrics(ext: any) {
|
||||
const d = ext.data
|
||||
if (!d) return []
|
||||
|
||||
const policy = d.policy || {}
|
||||
const insured = d.insured || {}
|
||||
const rows = d.benefit_illustration || []
|
||||
|
||||
const metrics = [
|
||||
{ label: '被保人年龄', value: insured.age ? `${insured.age}岁` : '-' },
|
||||
{ label: '年缴保费', value: policy.annual_premium ? formatNum(policy.annual_premium) : '-' },
|
||||
{ label: '缴费年期', value: policy.premium_payment_period ? `${policy.premium_payment_period}年` : '-' },
|
||||
]
|
||||
|
||||
// 计算回本年份
|
||||
const annualPremium = Number(policy.annual_premium) || 0
|
||||
const payYears = Number(policy.premium_payment_period) || 0
|
||||
if (annualPremium > 0 && payYears > 0 && rows.length > 0) {
|
||||
const totalInvest = annualPremium * payYears
|
||||
const breakevenRow = rows.find((r: any) => (Number(r.total_surrender_value) || 0) >= totalInvest)
|
||||
metrics.push({ label: '总投入', value: formatNum(totalInvest) })
|
||||
metrics.push({ label: '回本年份', value: breakevenRow ? `第${breakevenRow.policy_year}年` : '未回本' })
|
||||
|
||||
// 第20年倍数
|
||||
const row20 = rows.find((r: any) => r.policy_year === 20)
|
||||
if (row20) {
|
||||
const sv = Number(row20.total_surrender_value) || 0
|
||||
const multiple = totalInvest > 0 ? (sv / totalInvest).toFixed(2) : '-'
|
||||
metrics.push({ label: '20年倍数', value: `${multiple}x` })
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.length > 0) {
|
||||
metrics.push({ label: '数据行数', value: `${rows.length}行` })
|
||||
}
|
||||
|
||||
return metrics
|
||||
}
|
||||
|
||||
function formatNum(n: number) {
|
||||
return Number(n).toLocaleString('en-US')
|
||||
}
|
||||
|
||||
function hasField(rows: any[], field: string): boolean {
|
||||
if (!rows || !rows.length) return false
|
||||
return rows.some(r => r[field] !== undefined && r[field] !== null)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
await pptApi.updateExtractions(props.sessionId, extractions.value)
|
||||
originalJson.value = JSON.stringify(extractions.value)
|
||||
ElMessage.success('数据已保存')
|
||||
|
||||
// 重新验证
|
||||
const validateRes: any = await pptApi.validate(props.sessionId)
|
||||
issues.value = validateRes?.data?.issues || []
|
||||
} catch (e: any) {
|
||||
ElMessage.error('保存失败: ' + (e?.message || '未知错误'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (isDirty.value) {
|
||||
// 有未保存的修改,先保存再跳转
|
||||
handleSave().then(() => emit('confirmed'))
|
||||
} else {
|
||||
emit('confirmed')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ppt-data-review {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.review-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-tag {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 60px 0;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
/* 验证问题 */
|
||||
.issues-section {
|
||||
margin-bottom: 20px;
|
||||
padding: 12px;
|
||||
background: #fdf6ec;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #faecd8;
|
||||
}
|
||||
|
||||
.issue-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.issue-item.error {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.issue-item .issue-field {
|
||||
font-weight: 600;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.issue-item .issue-msg {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 产品块 */
|
||||
.extraction-block {
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.extraction-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 指标卡 */
|
||||
.metrics-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ebeef5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* 基本信息表单 */
|
||||
.basic-form {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
/* 底部操作 */
|
||||
.review-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
</style>
|
||||
@ -35,7 +35,7 @@
|
||||
>
|
||||
<template #extra>
|
||||
<el-button @click="$emit('back')">返回上传</el-button>
|
||||
<el-button v-if="hasSuccess" type="primary" @click="$emit('parsed')">下一步:生成 PPT</el-button>
|
||||
<el-button v-if="hasSuccess" type="primary" @click="$emit('parsed')">下一步:校验数据</el-button>
|
||||
</template>
|
||||
</el-result>
|
||||
|
||||
|
||||
@ -105,4 +105,7 @@ export const pptAdminApi = {
|
||||
getAvailableModels() {
|
||||
return api.get('/admin/ppt/available-models')
|
||||
},
|
||||
syncModels(data: { provider: string; api_key: string; base_url?: string }) {
|
||||
return api.post('/admin/ppt/sync-models', data)
|
||||
},
|
||||
}
|
||||
|
||||
@ -81,6 +81,11 @@ export const pptApi = {
|
||||
return api.get(`/ppt/validate/${sessionId}`)
|
||||
},
|
||||
|
||||
/** 保存用户修改后的提取数据 */
|
||||
updateExtractions(sessionId: string, extractions: any[]) {
|
||||
return api.put(`/ppt/session/${sessionId}/extractions`, { extractions })
|
||||
},
|
||||
|
||||
/** 获取渲染选项 */
|
||||
getRenderOptions() {
|
||||
return api.get('/ppt/render-options')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user