Compare commits

...

6 Commits

Author SHA1 Message Date
961924b299 0727 PPT加海报功能01版本修复-修复一个版本 2026-07-27 10:36:13 +08:00
36f08f93e0 修复一次 2026-07-25 14:13:38 +08:00
dbe2f9202d feat: support direct LLM provider config with model sync
- Fix _call_provider to support custom providers (OpenAI-compatible
  fallback) instead of raising ValueError for unknown providers
- Add POST /admin/ppt/sync-models endpoint that fetches available
  models from the configured provider's API
- Redesign PptSettingsAdmin UI: replace Dify model dropdown with
  per-provider 'sync models' button + searchable model selector
- Remove dead Dify-specific code (fetchDifyModels, applyDifyModel)
- Update deepseek default model from deepseek-chat to deepseek-v4-pro
- API Key field now always shown (not conditional on non-dify)
2026-07-25 13:45:32 +08:00
8e897276e7 fix: rename upgrade() to migrate() in migrate_017 so it runs automatically
The migration runner (db/__init__.py) calls module.migrate() with no
arguments, but migrate_017 defined upgrade(engine). This meant the
slides_config_json column was never added to insurance_ppt_templates
in production, causing a 500 on /insurance/ppt/render-options.
2026-07-25 08:47:35 +08:00
63c7e87ea7 feat(ppt): add data review step between parsing and generation
Add a mandatory data verification step so users can inspect and edit
LLM-extracted data before generating the PPT.

- New PptDataReview.vue: auto-validation on mount, key metrics cards,
  editable benefit/withdrawal tables, inline error highlighting
- New PUT /session/<id>/extractions API endpoint for saving user edits
- Extend PptPage flow from 4 steps to 5 (upload→parse→review→generate→result)
- Update ppt-api.ts with updateExtractions() method
- Update PptParsing button text to match new flow

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:22:14 +08:00
1debd7df39 feat(ppt): upgrade renderer from 5 fixed slides to 10+ configurable pages
Rewrite fast_pptx_renderer.py to read requiredPageTypes from template
config instead of hardcoding 5 slides. Add 10 slide builder functions
(cover, company, narrative, chart, timeline, table, compare, synergy,
conclusion, closing) with python-pptx native charts.

Key changes:
- Renderer reads templateConfig.requiredPageTypes and slidesConfig
  from DeckContract to determine slide sequence and per-slide metadata
- routes.py loads PptTemplate and PptCompany from DB, normalizes all
  PDF extractions (not just the first), passes full context to renderer
- renderer.py injects templateConfig, company info, and multi-product
  data into DeckContract
- Add slides_config_json column to PptTemplate (migrate_017) for
  per-slide title/narrative/chartType configuration via admin UI
- Admin template editor now supports drag-reorder slides, per-slide
  title/narrative hint, chart/table type selection
- Add requiredPageTypes to savings/ink, savings/minimal, savings/business
  templates (were missing, causing fallback to defaults)
- Fix IUL normalizer: add payYears and totalPremium to policy dict
- Fix CI/IUL normalizer: add totalSurrenderValue alias to benefitRows
  so charts render correctly for all product types
- Port calculation functions from baodanppt: decade_rows, paid_premium,
  simple_return, compound_return, find_payback_year

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:49:09 +08:00
26 changed files with 2097 additions and 348 deletions

View File

@ -0,0 +1,9 @@
{
"_data": {},
"_meta": {
"cacheVersion": 3,
"originalFile": "5429f024_计划书(1).pdf",
"extractedAt": "2026-07-24T12:50:28.325762",
"fileHash": "159aa3c49aebe185a3dc2af7f0b50e335f9a8e00a6998d84a7a259eb754ed4cd"
}
}

View File

@ -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"
}
}

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

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

View File

@ -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", [])]

View 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 列")

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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", ""),
},

View 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)

View File

@ -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")

View File

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

View File

@ -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 = ''

View File

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

View File

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

View 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>

View File

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

View File

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

View File

@ -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')