0723 代码整理
This commit is contained in:
parent
01ebaeb57c
commit
c32efbaed9
109
CLAUDE.md
109
CLAUDE.md
@ -34,74 +34,53 @@
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
baodan-main/
|
||||
baodanagent/
|
||||
├── api/
|
||||
│ ├── core/ # BaoDan 核心(不动)
|
||||
│ ├── controllers/ # BaoDan API 路由(不动)
|
||||
│ ├── models/ # BaoDan 数据模型(不动)
|
||||
│ ├── services/ # BaoDan 业务逻辑(不动)
|
||||
│ ├── extensions/ # BaoDan 扩展(不动)
|
||||
│ │
|
||||
│ ├── insurance/ # ★ 你的代码(新建目录)
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── wecom/ # 企微机器人 + OAuth
|
||||
│ │ │ ├── wecom_bot.py # 消息回调处理
|
||||
│ │ │ ├── wecom_oauth.py # OAuth 登录
|
||||
│ │ │ └── routes.py # Blueprint 路由
|
||||
│ │ ├── recommend/ # 产品推荐
|
||||
│ │ │ ├── recommend_api.py # 推荐接口
|
||||
│ │ │ └── workflow_helper.py# Workflow 调用封装
|
||||
│ │ ├── permissions/ # 角色权限
|
||||
│ │ │ ├── models.py # 角色/权限模型
|
||||
│ │ │ ├── middleware.py # 权限校验
|
||||
│ │ │ └── routes.py # 角色管理接口
|
||||
│ │ ├── stats/ # 数据统计
|
||||
│ │ │ └── stats_api.py # 统计接口
|
||||
│ │ └── db/ # 自研数据表
|
||||
│ │ ├── wecom_user.py # 企微用户映射
|
||||
│ │ └── recommend_record.py# 推荐记录
|
||||
│ │
|
||||
│ └── main.py # ★ BaoDan 入口(只改这里:注册 insurance 路由)
|
||||
│ └── insurance/ # ★ 你的全部自研代码
|
||||
│ ├── app.py # Flask 应用入口
|
||||
│ ├── config.py # 公共配置
|
||||
│ ├── routes.py # Blueprint 路由注册
|
||||
│ ├── admin/ # 管理后台(用户/部门/模板/通知)
|
||||
│ ├── auth/ # 认证(企微 OAuth + 账密登录)
|
||||
│ ├── chat/ # 对话功能(iframe 嵌入 BaoDan)
|
||||
│ ├── db/ # 数据库 + 自定义迁移(migrate_001~013)
|
||||
│ ├── kb/ # 知识库管理
|
||||
│ ├── middleware/ # 认证中间件
|
||||
│ ├── models/ # SQLAlchemy 数据模型(12 个)
|
||||
│ ├── recommend/ # 产品推荐(Workflow 调用)
|
||||
│ ├── stats/ # 数据统计
|
||||
│ ├── utils/ # 工具函数(审计/邮件/错误处理)
|
||||
│ └── wecom/ # 企微机器人 + OAuth
|
||||
│
|
||||
├── web/
|
||||
│ └── .env.local # ★ BaoDan 前端配置(只改这里:开启 iframe)
|
||||
├── frontend/ # 你的前端(独立 Vue 3 项目)
|
||||
│ ├── src/
|
||||
│ │ ├── pages/ # 页面视图(用户端 + 管理端)
|
||||
│ │ ├── components/ # 公共组件
|
||||
│ │ ├── composables/ # 组合式函数
|
||||
│ │ └── utils/ # 工具函数(api.ts)
|
||||
│ └── dist/ # 构建产物
|
||||
│
|
||||
├── docker/ # Docker 部署配置
|
||||
└── dev/ # 开发脚本
|
||||
|
||||
frontend/ # 你的前端(独立 Vue 3 项目)
|
||||
├── src/
|
||||
│ ├── pages/ # 登录/对话/推荐/管理页面
|
||||
│ ├── components/ # 通用组件
|
||||
│ ├── composables/ # 组合函数
|
||||
│ └── utils/ # 工具函数
|
||||
├── package.json
|
||||
└── vite.config.ts
|
||||
|
||||
deploy/
|
||||
├── sql/init.sql # 自研表建表脚本
|
||||
└── nginx.conf # Nginx 反向代理
|
||||
|
||||
docs/ # 所有项目文档
|
||||
├── README.md # 文档索引(入口)
|
||||
├── 保险智能客服系统_需求文档.md
|
||||
├── 保险智能客服系统_API接口文档.md
|
||||
├── 保险智能客服系统_编码规范.md
|
||||
├── 保险智能客服系统_测试用例.md
|
||||
├── 保险智能客服系统_文档规范.md
|
||||
├── 保险智能客服系统_客户验收单.md
|
||||
├── 后续开发计划.md
|
||||
├── 开发任务清单.md
|
||||
├── 后端开发文档.md
|
||||
├── 前端开发计划.md
|
||||
├── 前端移动端适配指南.md
|
||||
├── 企业微信接入指南.md
|
||||
├── Dify_Workflow配置指南.md
|
||||
├── API_curl示例.md
|
||||
├── 快速启动指南.md
|
||||
├── 部署指南.md
|
||||
├── 部署文档_完整版.md
|
||||
└── 宝塔面板部署指南.md
|
||||
├── deploy/ # 部署配置(SQL/init.sql, nginx.conf, logo)
|
||||
├── scripts/ # 脚本目录(按用途分类)
|
||||
│ ├── deploy/ # 部署脚本(deploy-baodanagent.sh 等)
|
||||
│ ├── build/ # 构建脚本(build-and-push.sh, package.sh)
|
||||
│ ├── tools/ # 工具脚本(check-env.sh, docker-cleanup.sh)
|
||||
│ ├── setup/ # 初始化脚本(create_admin.py 等)
|
||||
│ └── README.md # 脚本索引说明
|
||||
├── tests/ # 测试文件
|
||||
├── docs/ # 项目文档(19 篇)
|
||||
├── patches/ # Dify 源码补丁
|
||||
├── plugin_files/ # Dify 插件定义
|
||||
│
|
||||
├── docker-compose.dify.yml # Docker Compose(数据库 + Redis)
|
||||
├── docker-compose.frontend.yml # Docker Compose(前端容器)
|
||||
├── Dockerfile.dify-custom # API 镜像(添加 insurance 模块)
|
||||
├── Dockerfile.web-custom # Web 镜像(替换品牌)
|
||||
├── Dockerfile.frontend # 前端镜像(静态服务 + 反向代理)
|
||||
├── serve.py # 前端静态服务 + API 反向代理
|
||||
│
|
||||
├── dify-main/ # Dify 开源基座(参考,不直接修改)
|
||||
└── deploy-package/ # 预构建部署包(Docker 镜像 tarballs)
|
||||
```
|
||||
|
||||
## 修改 BaoDan 源码清单
|
||||
|
||||
39
alembic.ini
39
alembic.ini
@ -1,39 +0,0 @@
|
||||
# Alembic 保险智能客服系统数据库迁移配置
|
||||
|
||||
[alembic]
|
||||
script_location = api/insurance/db
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = postgresql://postgres:taiyi1224@localhost:5432/baodan
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
Binary file not shown.
@ -1,36 +0,0 @@
|
||||
"""Alembic 迁移环境配置。"""
|
||||
from logging.config import fileConfig
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from alembic import context
|
||||
import os, sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
|
||||
config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
from insurance.models import wecom_user, recommendation, operation_log
|
||||
target_metadata = None
|
||||
|
||||
def run_migrations_offline():
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
def run_migrations_online():
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@ -1,21 +0,0 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@ -1,56 +0,0 @@
|
||||
"""BaoDan Workflow 调用封装。"""
|
||||
import requests
|
||||
from flask import current_app
|
||||
|
||||
|
||||
class WorkflowHelper:
|
||||
"""Workflow API 调用封装。"""
|
||||
|
||||
def run_workflow(self, inputs: dict, user_id: str) -> dict:
|
||||
"""
|
||||
执行 Workflow 获取推荐方案。
|
||||
|
||||
Args:
|
||||
inputs: 客户信息和保险需求
|
||||
user_id: 用户 ID
|
||||
|
||||
Returns:
|
||||
Workflow 执行结果
|
||||
"""
|
||||
base_url = current_app.config.get("BAODAN_API_URL", "http://localhost:5001")
|
||||
api_key = current_app.config.get("BAODAN_WORKFLOW_API_KEY", "")
|
||||
|
||||
if not api_key:
|
||||
return {"code": 5001, "message": "Workflow API Key 未配置"}
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{base_url}/v1/workflows/run",
|
||||
json={
|
||||
"inputs": inputs,
|
||||
"response_mode": "blocking",
|
||||
"user": f"recommend_{user_id}",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
return {"code": 5001, "message": f"Workflow 调用失败: HTTP {resp.status_code}"}
|
||||
|
||||
result = resp.json()
|
||||
|
||||
# 解析 Workflow 输出
|
||||
if result.get("status") == "succeeded":
|
||||
outputs = result.get("data", {}).get("outputs", {})
|
||||
return {"code": 0, "data": outputs}
|
||||
else:
|
||||
error = result.get("error", "未知错误")
|
||||
return {"code": 5001, "message": f"Workflow 执行失败: {error}"}
|
||||
|
||||
except requests.Timeout:
|
||||
return {"code": 5001, "message": "Workflow 调用超时(120秒)"}
|
||||
except requests.RequestException as e:
|
||||
return {"code": 5001, "message": f"Workflow 调用异常: {str(e)}"}
|
||||
except Exception as e:
|
||||
return {"code": 9999, "message": f"未知错误: {str(e)}"}
|
||||
@ -1,58 +0,0 @@
|
||||
"""注册 insurance 模块路由到 Dify Flask 应用。
|
||||
|
||||
在 Dify 的 app_factory.py 中添加以下代码:
|
||||
try:
|
||||
from insurance.register_routes import register_insurance_routes
|
||||
register_insurance_routes(app)
|
||||
except ImportError as e:
|
||||
import logging
|
||||
logging.warning(f"Insurance module not loaded: {e}")
|
||||
"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_insurance_routes(app):
|
||||
"""注册所有 insurance Blueprint 到 Flask app。"""
|
||||
from insurance.auth.routes import auth_bp
|
||||
from insurance.wecom.routes import wecom_bp
|
||||
from insurance.recommend.routes import recommend_bp
|
||||
from insurance.permissions.routes import permissions_bp
|
||||
from insurance.stats.routes import stats_bp
|
||||
from insurance.chat.agent_routes import agent_bp
|
||||
from insurance.chat.routes import chat_bp
|
||||
|
||||
# 使用 /insurance/ 前缀避免与 Dify 的 /api/ 路由冲突
|
||||
app.register_blueprint(auth_bp, url_prefix="/insurance/auth")
|
||||
app.register_blueprint(chat_bp, url_prefix="/insurance/chat")
|
||||
app.register_blueprint(wecom_bp, url_prefix="/insurance/wecom")
|
||||
app.register_blueprint(recommend_bp, url_prefix="/insurance/recommend")
|
||||
app.register_blueprint(permissions_bp, url_prefix="/insurance/admin")
|
||||
app.register_blueprint(stats_bp, url_prefix="/insurance/stats")
|
||||
app.register_blueprint(agent_bp, url_prefix="/insurance/agents")
|
||||
|
||||
# 健康检查(检查实际依赖)
|
||||
@app.route("/insurance/health")
|
||||
def insurance_health():
|
||||
checks = {'status': 'ok', 'service': 'insurance'}
|
||||
try:
|
||||
from insurance.db.compat import db
|
||||
db.session.execute(db.text('SELECT 1'))
|
||||
checks['database'] = 'ok'
|
||||
except Exception as e:
|
||||
checks['database'] = f'error: {str(e)[:100]}'
|
||||
checks['status'] = 'degraded'
|
||||
try:
|
||||
from insurance.db.compat import redis_client
|
||||
if redis_client:
|
||||
redis_client.ping()
|
||||
checks['redis'] = 'ok'
|
||||
else:
|
||||
checks['redis'] = 'not configured'
|
||||
except Exception as e:
|
||||
checks['redis'] = f'error: {str(e)[:100]}'
|
||||
checks['status'] = 'degraded'
|
||||
return checks
|
||||
|
||||
logger.info("Insurance module routes registered successfully")
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 179 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
12
baodanppt/public/assets/library/themes/ci/index.txt
Normal file
12
baodanppt/public/assets/library/themes/ci/index.txt
Normal file
@ -0,0 +1,12 @@
|
||||
# 重疾险主题 素材库
|
||||
|
||||
health-protection-01.jpg - 家庭健康保障
|
||||
health-protection-02.jpg - 家庭健康保障
|
||||
health-protection-03.jpg - 家庭健康保障
|
||||
health-protection-04.jpg - 家庭健康保障
|
||||
man-professional-01.jpg - 成年男性投保人形象
|
||||
man-professional-02.jpg - 成年男性投保人形象
|
||||
man-professional-03.jpg - 成年男性投保人形象
|
||||
woman-professional-01.jpg - 成年女性投保人形象
|
||||
woman-professional-02.jpg - 成年女性投保人形象
|
||||
woman-professional-03.jpg - 成年女性投保人形象
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 214 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
187
baodanppt/public/css/tokens.css
Normal file
187
baodanppt/public/css/tokens.css
Normal file
@ -0,0 +1,187 @@
|
||||
/* =========================================================================
|
||||
Insurance Plan AI - 设计系统 Tokens
|
||||
由 00-DESIGN-SYSTEM.md 提炼而来
|
||||
========================================================================= */
|
||||
|
||||
:root {
|
||||
/* —— Surface 系统 (Material 3 风格 Apple 化) —— */
|
||||
--bg-base: #FAFAFA;
|
||||
--surface: #f9f9f9;
|
||||
--surface-container-lowest: #ffffff;
|
||||
--surface-container-low: #f3f3f3;
|
||||
--surface-container: #eeeeee;
|
||||
--surface-container-high: #e8e8e8;
|
||||
--surface-container-highest: #e2e2e2;
|
||||
|
||||
/* —— 文字 —— */
|
||||
--on-surface: #1a1c1c;
|
||||
--text-primary: #1D1D1F;
|
||||
--text-secondary: #6E6E73;
|
||||
--text-tertiary: #AEAEB2;
|
||||
|
||||
/* —— 边框 / 描边 —— */
|
||||
--border-subtle: #E5E5EA;
|
||||
--outline-variant: #d3c4b2;
|
||||
|
||||
/* —— 品牌金 (单屏 ≤ 2 处) —— */
|
||||
--brand-gold: #C8963E;
|
||||
--brand-gold-hover: #A87E2A;
|
||||
--brand-gold-soft: #FBF6EC;
|
||||
--primary-container: #c8963e;
|
||||
--on-primary-container: #4a3100;
|
||||
|
||||
/* —— 状态色 (Apple 系统色) —— */
|
||||
--status-success: #34C759;
|
||||
--status-warning: #FF9500;
|
||||
--status-error: #FF3B30;
|
||||
--status-info: #007AFF;
|
||||
|
||||
/* —— 其他 Material 3 派生色 (留给跨屏组件) —— */
|
||||
--tertiary: #005bc1;
|
||||
--tertiary-fixed: #d8e2ff;
|
||||
--secondary: #5f5e60;
|
||||
--on-tertiary-fixed-variant: #004493;
|
||||
--inverse-surface: #2f3131;
|
||||
--surface-tint: #7e5700;
|
||||
|
||||
/* —— 圆角 —— */
|
||||
--radius-xs: 6px;
|
||||
--radius-sm: 10px;
|
||||
--radius-md: 14px;
|
||||
--radius-lg: 20px;
|
||||
--radius-xl: 28px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* —— 阴影 (极轻, iOS 质感) —— */
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.05), 0 1px 3px rgba(0,0,0,0.03);
|
||||
--shadow-lg: 0 12px 32px rgba(0,0,0,0.08), 0 2px 8px rgba(0,0,0,0.04);
|
||||
--shadow-xl: 0 24px 64px rgba(0,0,0,0.10);
|
||||
|
||||
/* —— 字体 —— */
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Inter",
|
||||
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
|
||||
--font-mono: "SF Mono", ui-monospace, Menlo, monospace;
|
||||
|
||||
/* —— 间距 8 栅格 —— */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
--space-16: 64px;
|
||||
--space-20: 80px;
|
||||
}
|
||||
|
||||
/* —— 全局 reset —— */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
html, body { font-family: var(--font-sans); }
|
||||
body { background: var(--bg-base); color: var(--on-surface); line-height: 1.5; -webkit-font-smoothing: antialiased; }
|
||||
|
||||
/* —— 滚动条 (干净) —— */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #e2e2e2; border-radius: 10px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--brand-gold); }
|
||||
|
||||
/* —— 屏幕切换基础 —— */
|
||||
.screen { display: none; min-height: 100vh; }
|
||||
.screen.active { display: block; animation: screenIn 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94); }
|
||||
@keyframes screenIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* —— Material Symbols 字体微调 —— */
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* —— 步骤指示器 (5 屏共享) —— */
|
||||
.steps-nav {
|
||||
position: fixed; top: 0; left: 0; right: 0;
|
||||
height: 64px; background: var(--surface);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
z-index: 50;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
gap: 24px;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.steps-nav .step-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 13px; font-weight: 500; color: var(--text-tertiary);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.steps-nav .step-item .step-dot {
|
||||
width: 24px; height: 24px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--surface-container-high);
|
||||
color: var(--text-tertiary); font-size: 11px; font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.steps-nav .step-item.done .step-dot {
|
||||
background: var(--status-success); color: white;
|
||||
font-variation-settings: 'FILL' 1;
|
||||
}
|
||||
.steps-nav .step-item.done { color: var(--status-success); }
|
||||
.steps-nav .step-item.active .step-dot {
|
||||
background: var(--on-surface); color: var(--surface);
|
||||
}
|
||||
.steps-nav .step-item.active { color: var(--on-surface); font-weight: 600; }
|
||||
.steps-nav .step-divider {
|
||||
width: 32px; height: 1px; background: var(--border-subtle);
|
||||
}
|
||||
.steps-nav .step-divider.done { background: var(--status-success); }
|
||||
|
||||
/* —— 通用工具 —— */
|
||||
.ambient-shadow { box-shadow: var(--shadow-md); }
|
||||
.hide-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
.fade-enter { animation: fadeIn 0.3s ease-out; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
/* —— 品牌主按钮 —— */
|
||||
.btn-brand {
|
||||
background: var(--primary-container); color: white;
|
||||
border: none; border-radius: var(--radius-md);
|
||||
padding: 0 32px; height: 48px;
|
||||
font-size: 16px; font-weight: 600; cursor: pointer;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
transition: all 0.2s;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.btn-brand:hover { background: var(--brand-gold-hover); }
|
||||
.btn-brand:active { transform: scale(0.98); }
|
||||
.btn-brand:disabled { background: var(--surface-container-high); color: var(--text-tertiary); cursor: not-allowed; transform: none; }
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--surface-container-lowest); color: var(--on-surface);
|
||||
border: 1px solid var(--border-subtle); border-radius: var(--radius-md);
|
||||
padding: 0 24px; height: 48px;
|
||||
font-size: 15px; font-weight: 500; cursor: pointer;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--surface-container-low); }
|
||||
.btn-secondary:active { transform: scale(0.98); }
|
||||
|
||||
.btn-text {
|
||||
background: transparent; border: none; color: var(--status-info);
|
||||
font-size: 13px; font-weight: 500; cursor: pointer; padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-text:hover { background: rgba(0,122,255,0.08); }
|
||||
|
||||
/* —— 卡片基础 —— */
|
||||
.card-base {
|
||||
background: var(--surface-container-lowest);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
150
baodanppt/public/js/api.js
Normal file
150
baodanppt/public/js/api.js
Normal file
@ -0,0 +1,150 @@
|
||||
/* =========================================================================
|
||||
Insurance Plan AI - 后端 API 封装
|
||||
========================================================================= */
|
||||
|
||||
const DEFAULT_LOCAL_API = 'http://localhost:3000';
|
||||
const BASE = location.protocol === 'file:' ? DEFAULT_LOCAL_API : '';
|
||||
|
||||
// 用户自定义 API key (从 localStorage 读, 用于绕过服务端 key 缺失)
|
||||
function getUserApiKey() {
|
||||
try { return localStorage.getItem('userApiKey') || ''; } catch { return ''; }
|
||||
}
|
||||
function getUserApiProvider() {
|
||||
try { return localStorage.getItem('userApiProvider') || 'deepseek'; } catch { return 'deepseek'; }
|
||||
}
|
||||
function authHeaders(extra = {}) {
|
||||
const k = getUserApiKey();
|
||||
const p = getUserApiProvider();
|
||||
const h = { ...extra };
|
||||
if (k) {
|
||||
h['X-User-Api-Key'] = k;
|
||||
h['X-User-Api-Provider'] = p; // deepseek | openai | gemini
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/* —— POST /api/upload ——————————————————————————————————————————
|
||||
入参: files = [{ file: File, type: 'savings'|'ci'|'iul' }]
|
||||
出参: { sessionId, files: [] }
|
||||
异常: throw Error(msg) */
|
||||
export async function uploadFiles(files, companies = {}) {
|
||||
const form = new FormData();
|
||||
for (const { file, type } of files) {
|
||||
form.append('files', file);
|
||||
form.append('types', type);
|
||||
form.append('companies', companies[type] || '');
|
||||
}
|
||||
const res = await fetch(BASE + '/api/upload', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: getUserApiKey() ? { 'X-User-Api-Key': getUserApiKey(), 'X-User-Api-Provider': getUserApiProvider() } : {},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || '上传失败');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/* —— POST /api/parse/:sessionId ————————————————————————————————
|
||||
触发 AI 解析, 平均 30 秒
|
||||
出参: { extractions: [...], message: 'AI 初始摘要' } */
|
||||
export async function parseSession(sessionId) {
|
||||
// 用 AbortController 设 5 分钟 timeout (后台 fast-path 通常 30 秒, LLM 调用可能 1-2 分钟)
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 5 * 60 * 1000);
|
||||
try {
|
||||
const res = await fetch(BASE + `/api/parse/${sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: getUserApiKey() ? { 'X-User-Api-Key': getUserApiKey(), 'X-User-Api-Provider': getUserApiProvider() } : {},
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || '解析失败');
|
||||
}
|
||||
return res.json();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/* —— GET /api/session/:id ————————————————————————————————————
|
||||
轮询解析状态, 直到 status !== 'parsing'
|
||||
出参: { status, extractions, chatHistory } */
|
||||
export async function getSession(sessionId) {
|
||||
const res = await fetch(BASE + `/api/session/${sessionId}`);
|
||||
if (!res.ok) throw new Error('Session not found');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/* —— GET /api/validate-extraction/:id —————————————————————————
|
||||
出参: { validated, errorCount, warnCount, issues } */
|
||||
export async function validateExtraction(sessionId) {
|
||||
const res = await fetch(BASE + `/api/validate-extraction/${sessionId}`);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || '校验失败');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/* —— POST /api/chat/:id ——————————————————————————————————————
|
||||
入参: { message }
|
||||
出参: { message: AI回复, history: 最新20条 } */
|
||||
export async function sendChat(sessionId, message) {
|
||||
const res = await fetch(BASE + `/api/chat/${sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || '对话失败');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/* —— GET /api/render-options ——————————————————————————————————
|
||||
出参: { companies: [{id, name, tenantId}], templates: [{id, name}] } */
|
||||
export async function getRenderOptions() {
|
||||
try {
|
||||
const res = await fetch(BASE + '/api/render-options');
|
||||
if (!res.ok) return { companies: [], templates: [] };
|
||||
return res.json();
|
||||
} catch { return { companies: [], templates: [] }; }
|
||||
}
|
||||
|
||||
/* —— POST /api/generate-enhanced/:id ——————————————————————————
|
||||
使用增强渲染器 (python-pptx 原生表格/图表)
|
||||
入参: { companyId, theme }
|
||||
出参: { downloadUrl } */
|
||||
export async function generatePPT({ sessionId, style, companyId, companyInfo, format = 'pptx', quality = 'high', savingsCompanyId, ciCompanyId, iulCompanyId, aiNarrative }) {
|
||||
const res = await fetch(BASE + `/api/generate-enhanced/${sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({
|
||||
companyId: companyId || 'ctf',
|
||||
theme: style || 'broker',
|
||||
companyInfo: companyInfo || '',
|
||||
format: format || 'pptx',
|
||||
quality: quality || 'high',
|
||||
savingsCompanyId: savingsCompanyId || '',
|
||||
ciCompanyId: ciCompanyId || '',
|
||||
iulCompanyId: iulCompanyId || '',
|
||||
aiNarrative: aiNarrative || '',
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || '生成失败');
|
||||
return data;
|
||||
}
|
||||
|
||||
/* —— 下载签名文件 ——————————————————————————————————————————
|
||||
走带 expires+token 的签名 URL
|
||||
出参: Blob */
|
||||
export async function downloadSignedFile(relativeUrl) {
|
||||
const res = await fetch(BASE + relativeUrl);
|
||||
if (!res.ok) throw new Error(`下载失败 (${res.status})`);
|
||||
return res.blob();
|
||||
}
|
||||
62
baodanppt/public/js/app.js
Normal file
62
baodanppt/public/js/app.js
Normal file
@ -0,0 +1,62 @@
|
||||
/* =========================================================================
|
||||
Insurance Plan AI - 启动入口
|
||||
========================================================================= */
|
||||
|
||||
import { goStep, buildStepsNav, toast } from './steps.js';
|
||||
import { state } from './state.js';
|
||||
import { getSession } from './api.js';
|
||||
|
||||
// 启动时挂载顶部步骤指示器
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
if (location.protocol === 'file:') {
|
||||
toast('已检测到本地文件模式,页面将自动连接 http://localhost:3000 的后端服务', 'info');
|
||||
}
|
||||
|
||||
// 1) 步骤指示器
|
||||
const navHost = document.getElementById('stepsNavHost');
|
||||
if (navHost) navHost.innerHTML = buildStepsNav();
|
||||
|
||||
// 2) 默认进入 upload 屏
|
||||
goStep('upload');
|
||||
|
||||
// 3) 如果有 sessionId 在 URL 上 (从分享链接回来), 自动恢复
|
||||
const params = new URLSearchParams(location.search);
|
||||
const sid = params.get('session');
|
||||
if (sid) {
|
||||
try {
|
||||
const session = await getSession(sid);
|
||||
state.sessionId = sid;
|
||||
state.extractions = session.extractions || [];
|
||||
state.files = (session.files || []).map((f) => ({
|
||||
file: { name: f.name, size: 0 },
|
||||
type: f.type || 'savings',
|
||||
}));
|
||||
if (session.status === 'parsed' && state.extractions.length > 0) {
|
||||
const last = (session.chatHistory || []).slice(-1)[0];
|
||||
state.initialChatMsg = last?.content || '';
|
||||
toast('已恢复上次会话', 'success');
|
||||
goStep('chat');
|
||||
} else if (session.status === 'done' && session.hasPpt) {
|
||||
state.downloadUrl = session.downloadUrl || '';
|
||||
state.markdownUrl = session.markdownUrl || '';
|
||||
state.previewUrls = session.previewUrls || [];
|
||||
state.previewPdfUrl = session.previewPdfUrl || '';
|
||||
state.slideCount = session.slideCount || 0;
|
||||
state.resultFilename = (() => {
|
||||
try {
|
||||
const parsed = new URL(session.downloadUrl, location.origin);
|
||||
return decodeURIComponent(parsed.pathname.split('/').pop() || 'plan.pptx');
|
||||
} catch {
|
||||
return 'plan.pptx';
|
||||
}
|
||||
})();
|
||||
goStep('result');
|
||||
} else if (session.status === 'parsing') {
|
||||
goStep('parsing');
|
||||
setTimeout(() => window.__triggerParse?.(), 300);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('恢复会话失败:', err);
|
||||
}
|
||||
}
|
||||
});
|
||||
20
baodanppt/public/js/html2canvas.min.js
vendored
Normal file
20
baodanppt/public/js/html2canvas.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
271
baodanppt/public/js/screens/chat.js
Normal file
271
baodanppt/public/js/screens/chat.js
Normal file
@ -0,0 +1,271 @@
|
||||
/* =========================================================================
|
||||
Screen 3: Chat - 解析摘要 + AI 对话
|
||||
========================================================================= */
|
||||
|
||||
import { state } from '../state.js';
|
||||
import { sendChat, validateExtraction } from '../api.js';
|
||||
import { goStep, toast } from '../steps.js';
|
||||
|
||||
const TYPE_COLORS = {
|
||||
savings: { bg: '#E8F1FF', fg: '#007AFF' },
|
||||
ci: { bg: '#FFF0F0', fg: '#FF3B30' },
|
||||
iul: { bg: '#F0FFF4', fg: '#34C759' },
|
||||
};
|
||||
const TYPE_LABEL = { savings: '储蓄险', ci: '重疾险', iul: 'IUL' };
|
||||
|
||||
/* —— 渲染左侧摘要 —— */
|
||||
function renderSummary() {
|
||||
const el = document.getElementById('chatSummary');
|
||||
if (!el) return;
|
||||
el.innerHTML = '';
|
||||
state.extractions.forEach((ext, idx) => {
|
||||
const ok = ext.status === 'success' || (!ext.error && ext.yearCount > 0);
|
||||
const color = TYPE_COLORS[ext.planType] || TYPE_COLORS.savings;
|
||||
const card = document.createElement('div');
|
||||
card.className = 'bg-surface-container-lowest border border-border-subtle rounded-2xl p-4';
|
||||
const d = ext.data || {};
|
||||
const pol = d.policy || {};
|
||||
let stats = '';
|
||||
if (ok) {
|
||||
if (ext.planType === 'savings') {
|
||||
stats = `
|
||||
<div class="grid grid-cols-2 gap-2 mt-3">
|
||||
<div class="bg-surface-container-low rounded-lg p-2"><div class="text-[11px] text-text-secondary font-semibold tracking-wider">年缴保费</div><div class="text-base font-bold text-on-surface">$${fmt(pol.annual_premium)}</div></div>
|
||||
<div class="bg-surface-container-low rounded-lg p-2"><div class="text-[11px] text-text-secondary font-semibold tracking-wider">缴费年期</div><div class="text-base font-bold text-on-surface">${pol.premium_payment_period || '-'}</div></div>
|
||||
</div>`;
|
||||
} else if (ext.planType === 'ci') {
|
||||
stats = `
|
||||
<div class="grid grid-cols-2 gap-2 mt-3">
|
||||
<div class="bg-surface-container-low rounded-lg p-2"><div class="text-[11px] text-text-secondary font-semibold tracking-wider">危疾保额</div><div class="text-base font-bold text-on-surface">$${fmt(pol.sum_insured)}</div></div>
|
||||
<div class="bg-surface-container-low rounded-lg p-2"><div class="text-[11px] text-text-secondary font-semibold tracking-wider">年缴保费</div><div class="text-base font-bold text-on-surface">$${fmt(pol.annual_premium)}</div></div>
|
||||
</div>`;
|
||||
} else if (ext.planType === 'iul') {
|
||||
const rate = d.index_accounts?.[0]?.current_assumed_rate || d.rates?.fixed_account_current_rate || '-';
|
||||
stats = `
|
||||
<div class="grid grid-cols-2 gap-2 mt-3">
|
||||
<div class="bg-surface-container-low rounded-lg p-2"><div class="text-[11px] text-text-secondary font-semibold tracking-wider">身故保障</div><div class="text-base font-bold text-on-surface">$${fmt(pol.sum_insured)}</div></div>
|
||||
<div class="bg-surface-container-low rounded-lg p-2"><div class="text-[11px] text-text-secondary font-semibold tracking-wider">演示利率</div><div class="text-base font-bold text-on-surface">${rate}</div></div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
card.innerHTML = `
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-[11px] font-semibold tracking-wider px-2 py-0.5 rounded-md" style="background:${color.bg};color:${color.fg}">${TYPE_LABEL[ext.planType]}</span>
|
||||
<span class="text-[11px] font-semibold" style="color:${ok ? '#34C759' : '#FF3B30'}">${ok ? '✓ 已解析' : '✗ 失败'}</span>
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-on-surface truncate">${ext.productName || ext.pdfName}</div>
|
||||
<div class="text-xs text-text-secondary mt-1">${ext.pdfName}${ext.yearCount ? ` · ${ext.yearCount} 年数据` : ''}</div>
|
||||
${stats}
|
||||
${ext.error ? `<div class="text-xs text-status-error mt-2">${ext.error}</div>` : ''}
|
||||
`;
|
||||
el.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function renderValidationSummary() {
|
||||
const summary = document.getElementById('chatValidationSummary');
|
||||
const list = document.getElementById('chatValidationList');
|
||||
if (!summary || !list) return;
|
||||
list.innerHTML = '';
|
||||
if (!state.validation) {
|
||||
summary.textContent = '正在加载校验结果...';
|
||||
return;
|
||||
}
|
||||
if (state.validation.validated) {
|
||||
summary.textContent = state.validation.warnCount > 0
|
||||
? `校验通过,但有 ${state.validation.warnCount} 条提示`
|
||||
: '校验通过,当前数据可用于正式生成';
|
||||
} else {
|
||||
summary.textContent = `存在 ${state.validation.errorCount} 项错误,生成前建议先处理`;
|
||||
}
|
||||
(state.validation.issues || []).slice(0, 6).forEach((issue) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = `rounded-xl px-4 py-3 border ${issue.severity === 'error' ? 'bg-red-50 border-red-200 text-red-700' : 'bg-amber-50 border-amber-200 text-amber-700'}`;
|
||||
row.innerHTML = `<div class="text-[11px] font-semibold tracking-[0.08em] mb-1">${issue.severity === 'error' ? 'ERROR' : 'WARN'} · ${issue.field}</div><div class="text-sm">${issue.message}</div>`;
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
/* —— 消息流管理 —— */
|
||||
function appendMessage(role, content) {
|
||||
const wrap = document.getElementById('chatMessages');
|
||||
if (!wrap) return;
|
||||
const div = document.createElement('div');
|
||||
div.className = role === 'user'
|
||||
? 'flex gap-3 max-w-[90%] self-end flex-row-reverse fade-enter'
|
||||
: 'flex gap-3 max-w-[95%] fade-enter';
|
||||
|
||||
const avatar = role === 'user'
|
||||
? `<div class="w-8 h-8 rounded-full bg-surface-container-high border border-border-subtle flex items-center justify-center shrink-0 mt-1">
|
||||
<span class="material-symbols-outlined text-[16px] text-secondary">person</span>
|
||||
</div>`
|
||||
: `<div class="w-8 h-8 rounded-full bg-primary-container flex items-center justify-center shrink-0 text-white mt-1">
|
||||
<span class="material-symbols-outlined text-[16px]" style="font-variation-settings:'FILL' 1;">auto_awesome</span>
|
||||
</div>`;
|
||||
|
||||
const bubble = role === 'user'
|
||||
? `<div class="flex flex-col gap-1 items-end">
|
||||
<span class="text-xs text-text-secondary mr-1">我</span>
|
||||
<div class="bg-primary-container text-white text-sm py-3 px-4 rounded-2xl rounded-tr-sm shadow-sm whitespace-pre-wrap">${escapeHtml(content)}</div>
|
||||
</div>`
|
||||
: `<div class="flex flex-col gap-1">
|
||||
<span class="text-xs text-text-secondary ml-1">AI 顾问</span>
|
||||
<div class="text-sm text-on-surface markdown-prose bg-surface-bright border border-border-subtle p-4 rounded-2xl rounded-tl-sm shadow-sm">${formatMarkdown(content)}</div>
|
||||
</div>`;
|
||||
|
||||
div.innerHTML = avatar + bubble;
|
||||
wrap.appendChild(div);
|
||||
wrap.scrollTop = wrap.scrollHeight;
|
||||
}
|
||||
|
||||
function appendTyping() {
|
||||
const wrap = document.getElementById('chatMessages');
|
||||
if (!wrap) return null;
|
||||
const div = document.createElement('div');
|
||||
div.id = 'typingIndicator';
|
||||
div.className = 'flex gap-3 max-w-[95%] fade-enter';
|
||||
div.innerHTML = `
|
||||
<div class="w-8 h-8 rounded-full bg-primary-container flex items-center justify-center shrink-0 text-white mt-1">
|
||||
<span class="material-symbols-outlined text-[16px]" style="font-variation-settings:'FILL' 1;">auto_awesome</span>
|
||||
</div>
|
||||
<div class="bg-surface-bright border border-border-subtle p-4 rounded-2xl rounded-tl-sm shadow-sm flex gap-1.5 items-center">
|
||||
<span class="w-2 h-2 bg-text-tertiary rounded-full animate-bounce" style="animation-delay:0s"></span>
|
||||
<span class="w-2 h-2 bg-text-tertiary rounded-full animate-bounce" style="animation-delay:0.2s"></span>
|
||||
<span class="w-2 h-2 bg-text-tertiary rounded-full animate-bounce" style="animation-delay:0.4s"></span>
|
||||
</div>
|
||||
`;
|
||||
wrap.appendChild(div);
|
||||
wrap.scrollTop = wrap.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
function removeTyping() {
|
||||
document.getElementById('typingIndicator')?.remove();
|
||||
}
|
||||
|
||||
function formatMarkdown(text) {
|
||||
if (!text) return '';
|
||||
// 极简: **加粗** + 换行 + 列表
|
||||
return escapeHtml(text)
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/^•\s?(.+)$/gm, '<div style="display:flex;gap:8px;margin:4px 0;"><span>•</span><span>$1</span></div>')
|
||||
.replace(/\n\n/g, '<br/><br/>')
|
||||
.replace(/\n/g, '<br/>');
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
function fmt(n) {
|
||||
if (!n && n !== 0) return '-';
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
||||
if (n >= 1000) return (n / 1000).toFixed(0) + 'K';
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
/* —— 初始消息 (后端给的) —— */
|
||||
function renderInitial() {
|
||||
const wrap = document.getElementById('chatMessages');
|
||||
if (!wrap) return;
|
||||
wrap.innerHTML = '';
|
||||
if (state.initialChatMsg) {
|
||||
appendMessage('assistant', state.initialChatMsg);
|
||||
} else {
|
||||
appendMessage('assistant', '解析已完成,我可以帮你解答关于这份计划书的任何问题。\n\n比如:\n• 这个产品的核心卖点是什么?\n• 20年后的预期回报是多少?\n• 和同类型产品相比有什么优势?');
|
||||
}
|
||||
}
|
||||
|
||||
async function sendUserMessage() {
|
||||
const input = document.getElementById('chatInput');
|
||||
const btn = document.getElementById('chatSendBtn');
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
appendMessage('user', text);
|
||||
input.value = '';
|
||||
input.style.height = 'auto';
|
||||
btn.disabled = true;
|
||||
appendTyping();
|
||||
|
||||
try {
|
||||
const { message } = await sendChat(state.sessionId, text);
|
||||
removeTyping();
|
||||
appendMessage('assistant', message);
|
||||
} catch (err) {
|
||||
removeTyping();
|
||||
appendMessage('assistant', '❌ ' + err.message);
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function initChat() {
|
||||
renderSummary();
|
||||
renderInitial();
|
||||
renderValidationSummary();
|
||||
|
||||
if (state.sessionId && !state.validation) {
|
||||
validateExtraction(state.sessionId)
|
||||
.then((result) => {
|
||||
state.validation = result;
|
||||
renderValidationSummary();
|
||||
})
|
||||
.catch((err) => {
|
||||
state.validation = {
|
||||
validated: false,
|
||||
errorCount: 1,
|
||||
warnCount: 0,
|
||||
issues: [{ field: 'system', severity: 'error', message: err.message || '校验接口失败' }],
|
||||
};
|
||||
renderValidationSummary();
|
||||
});
|
||||
}
|
||||
|
||||
const input = document.getElementById('chatInput');
|
||||
const btn = document.getElementById('chatSendBtn');
|
||||
const goBtn = document.getElementById('chatGoGenerateBtn');
|
||||
const summaryBtn = document.getElementById('chatSummaryToggle');
|
||||
|
||||
if (input && input.dataset.bound !== '1') {
|
||||
input.dataset.bound = '1';
|
||||
// 自适应高度
|
||||
input.addEventListener('input', () => {
|
||||
input.style.height = 'auto';
|
||||
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
|
||||
// 切换发送按钮态
|
||||
if (btn) {
|
||||
const has = input.value.trim().length > 0;
|
||||
btn.disabled = !has;
|
||||
btn.classList.toggle('bg-primary-container', has);
|
||||
btn.classList.toggle('text-white', has);
|
||||
btn.classList.toggle('bg-surface-container-high', !has);
|
||||
btn.classList.toggle('text-text-tertiary', !has);
|
||||
}
|
||||
});
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendUserMessage();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (btn) btn.onclick = sendUserMessage;
|
||||
if (goBtn) goBtn.onclick = () => goStep('generate');
|
||||
if (summaryBtn) summaryBtn.onclick = () => {
|
||||
const m = document.getElementById('summaryModal');
|
||||
m?.classList.toggle('hidden');
|
||||
};
|
||||
const closeBtn = document.getElementById('summaryModalClose');
|
||||
if (closeBtn) closeBtn.onclick = () => {
|
||||
document.getElementById('summaryModal')?.classList.add('hidden');
|
||||
};
|
||||
// 快速提问 chips
|
||||
document.querySelectorAll('.chat-quick-chip').forEach(chip => {
|
||||
chip.onclick = () => {
|
||||
const text = chip.textContent.trim();
|
||||
input.value = text;
|
||||
sendUserMessage();
|
||||
};
|
||||
});
|
||||
}
|
||||
254
baodanppt/public/js/screens/generate.js
Normal file
254
baodanppt/public/js/screens/generate.js
Normal file
@ -0,0 +1,254 @@
|
||||
/* =========================================================================
|
||||
Screen 4: Generate - 风格 + 公司选择
|
||||
========================================================================= */
|
||||
|
||||
import { state } from '../state.js';
|
||||
import { getRenderOptions, generatePPT, validateExtraction, sendChat } from '../api.js';
|
||||
import { goStep, toast } from '../steps.js';
|
||||
|
||||
const STYLE_PRESETS = [
|
||||
{ id: 'broker', name: '券商风', primary: 'linear-gradient(135deg,#0D1B2A,#1B2A4A)', accent: '#C8963E', tag: '专业高端' },
|
||||
{ id: 'business', name: '商务风', primary: 'linear-gradient(135deg,#17324D,#2A4866)', accent: '#C9A86A', tag: '稳重内敛' },
|
||||
{ id: 'minimal', name: '简洁风', primary: 'linear-gradient(135deg,#1A1A2E,#2D2D44)', accent: '#E94560', tag: '极简有力' },
|
||||
{ id: 'chinese', name: '中国风', primary: 'linear-gradient(135deg,#7B1E1E,#A02C2C)', accent: '#C8A24D', tag: '东方雅致' },
|
||||
{ id: 'ink', name: '水墨风', primary: 'linear-gradient(135deg,#1F2D3D,#3A4D63)', accent: '#8FA3B8', tag: '写意留白' },
|
||||
];
|
||||
|
||||
function renderValidation() {
|
||||
const badge = document.getElementById('validationStatusBadge');
|
||||
const summary = document.getElementById('validationSummary');
|
||||
const issuesEl = document.getElementById('validationIssues');
|
||||
if (!badge || !summary || !issuesEl) return;
|
||||
const result = state.validation;
|
||||
issuesEl.innerHTML = '';
|
||||
if (!result) {
|
||||
badge.textContent = '检查中';
|
||||
badge.className = 'text-caption text-text-secondary';
|
||||
summary.textContent = '正在检查年龄、保费、利益表与提领数据...';
|
||||
return;
|
||||
}
|
||||
if (result.validated) {
|
||||
badge.textContent = result.warnCount > 0 ? `通过 (${result.warnCount} 条提示)` : '通过';
|
||||
badge.className = `text-caption ${result.warnCount > 0 ? 'text-status-warning' : 'text-status-success'}`;
|
||||
summary.textContent = result.warnCount > 0 ? '数据可生成,但建议先看下面的提示。' : '核心数据校验通过,可以生成正式版。';
|
||||
} else {
|
||||
badge.textContent = `阻断 (${result.errorCount} 项错误)`;
|
||||
badge.className = 'text-caption text-status-error';
|
||||
summary.textContent = '存在会影响正式导出的数据问题,修复前不建议生成。';
|
||||
}
|
||||
const topIssues = (result.issues || []).slice(0, 6);
|
||||
topIssues.forEach((issue) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = `rounded-lg px-3 py-2 border ${issue.severity === 'error' ? 'bg-red-50 border-red-200 text-red-700' : 'bg-amber-50 border-amber-200 text-amber-700'}`;
|
||||
row.innerHTML = `<div class="text-[11px] font-semibold tracking-[0.08em] mb-1">${issue.severity === 'error' ? 'ERROR' : 'WARN'} · ${issue.field}</div><div class="text-sm">${issue.message}</div>`;
|
||||
issuesEl.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderStyles() {
|
||||
const el = document.getElementById('styleGrid');
|
||||
if (!el) return;
|
||||
el.innerHTML = '';
|
||||
STYLE_PRESETS.forEach(s => {
|
||||
const isActive = s.id === state.selectedStyle;
|
||||
const div = document.createElement('div');
|
||||
div.className = `bg-surface-container-lowest rounded-lg p-base border cursor-pointer transition-all ${
|
||||
isActive ? 'border-2 border-primary-container shadow-md' : 'border-border-subtle shadow-sm hover:shadow-md hover:-translate-y-0.5'
|
||||
}`;
|
||||
div.innerHTML = `
|
||||
<div class="aspect-video rounded mb-2 flex flex-col justify-center px-3 relative overflow-hidden" style="background:${s.primary}">
|
||||
<div class="w-2/3 h-1.5 rounded mb-1" style="background:${s.accent};opacity:0.9"></div>
|
||||
<div class="w-1/2 h-1 rounded" style="background:rgba(255,255,255,0.3)"></div>
|
||||
${isActive ? `<div class="absolute top-1 right-1 w-5 h-5 rounded-full flex items-center justify-center" style="background:${s.accent}">
|
||||
<span class="material-symbols-outlined text-white text-[14px]" style="font-variation-settings:'FILL' 1">check</span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
<div class="text-center text-xs font-bold ${isActive ? 'text-primary-container' : 'text-text-secondary'}">${s.name}</div>
|
||||
`;
|
||||
div.onclick = () => {
|
||||
state.selectedStyle = s.id;
|
||||
renderStyles();
|
||||
updatePreview();
|
||||
};
|
||||
el.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function renderCompanies(companies) {
|
||||
const el = document.getElementById('companyGrid');
|
||||
if (!el) return;
|
||||
el.innerHTML = '';
|
||||
if (!companies || companies.length === 0) {
|
||||
el.innerHTML = '<div class="col-span-full text-center text-text-secondary text-sm py-8">暂无可选公司</div>';
|
||||
return;
|
||||
}
|
||||
companies.forEach(c => {
|
||||
const isActive = c.id === state.selectedCompanyId;
|
||||
const initial = c.name?.[0] || c.id?.[0]?.toUpperCase() || '?';
|
||||
const div = document.createElement('div');
|
||||
div.className = `bg-surface-container-lowest rounded-lg p-3 border cursor-pointer transition-all flex items-center gap-3 ${
|
||||
isActive ? 'border-2 border-primary-container bg-brand-gold-soft' : 'border-border-subtle hover:shadow-md'
|
||||
}`;
|
||||
div.innerHTML = `
|
||||
<div class="w-10 h-10 rounded-lg flex items-center justify-center font-bold text-base shrink-0 ${
|
||||
isActive ? 'bg-primary-container text-white' : 'bg-surface-container-high text-on-surface'
|
||||
}">${initial}</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-semibold text-on-surface truncate">${c.name}</div>
|
||||
<div class="text-[11px] text-text-secondary">${c.id}</div>
|
||||
</div>
|
||||
${isActive ? `<span class="material-symbols-outlined text-primary-container text-[20px] shrink-0" style="font-variation-settings:'FILL' 1">check_circle</span>` : ''}
|
||||
`;
|
||||
div.onclick = () => {
|
||||
state.selectedCompanyId = c.id;
|
||||
renderCompanies(companies);
|
||||
updateGenerateBtn();
|
||||
updatePreview();
|
||||
};
|
||||
el.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
const style = STYLE_PRESETS.find(s => s.id === state.selectedStyle);
|
||||
const canvas = document.getElementById('previewCanvas');
|
||||
if (canvas && style) canvas.style.background = style.primary;
|
||||
}
|
||||
|
||||
function updateGenerateBtn() {
|
||||
const btn = document.getElementById('startGenerateBtn');
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
|
||||
function renderFormatAndQuality() {
|
||||
const formatButtons = document.querySelectorAll('[data-format]');
|
||||
formatButtons.forEach((btn) => {
|
||||
const active = btn.dataset.format === state.selectedFormat;
|
||||
btn.classList.toggle('bg-primary', active);
|
||||
btn.classList.toggle('text-on-primary', active);
|
||||
btn.classList.toggle('border-primary-container', active);
|
||||
btn.classList.toggle('bg-surface-container-lowest', !active);
|
||||
btn.classList.toggle('text-on-surface', !active);
|
||||
btn.onclick = () => {
|
||||
state.selectedFormat = btn.dataset.format;
|
||||
renderFormatAndQuality();
|
||||
const startBtn = document.getElementById('startGenerateBtn');
|
||||
if (startBtn) startBtn.innerHTML = `<span class="material-symbols-outlined text-[20px]">auto_awesome</span> 开始生成 ${state.selectedFormat.toUpperCase()}`;
|
||||
};
|
||||
});
|
||||
const qualityButtons = document.querySelectorAll('[data-quality]');
|
||||
qualityButtons.forEach((btn) => {
|
||||
const active = btn.dataset.quality === state.selectedQuality;
|
||||
btn.classList.toggle('bg-primary', active);
|
||||
btn.classList.toggle('text-on-primary', active);
|
||||
btn.classList.toggle('border-primary-container', active);
|
||||
btn.classList.toggle('bg-surface-container-lowest', !active);
|
||||
btn.classList.toggle('text-on-surface', !active);
|
||||
btn.onclick = () => {
|
||||
state.selectedQuality = btn.dataset.quality;
|
||||
renderFormatAndQuality();
|
||||
};
|
||||
});
|
||||
const startBtn = document.getElementById('startGenerateBtn');
|
||||
if (startBtn) startBtn.innerHTML = `<span class="material-symbols-outlined text-[20px]">auto_awesome</span> 开始生成 ${state.selectedFormat.toUpperCase()}`;
|
||||
}
|
||||
|
||||
async function onGenerate() {
|
||||
if (state.validation && !state.validation.validated) {
|
||||
toast(`存在 ${state.validation.errorCount} 项数据错误,请先修复后再生成`, 'error');
|
||||
return;
|
||||
}
|
||||
const btn = document.getElementById('startGenerateBtn');
|
||||
btn.disabled = true;
|
||||
const oldHtml = btn.innerHTML;
|
||||
btn.innerHTML = '<span class="material-symbols-outlined animate-spin">progress_activity</span> 生成中...';
|
||||
|
||||
try {
|
||||
let aiNarrative = '';
|
||||
if (state.useAiSummary) {
|
||||
try {
|
||||
const aiRes = await sendChat(state.sessionId, '请根据以上对话,为这份保险计划书写一段简短的总结建议(100字以内),包括产品组合的核心优势和适合场景。');
|
||||
aiNarrative = aiRes?.message || '';
|
||||
} catch (e) { console.warn('AI建议获取失败,使用默认总结', e); }
|
||||
}
|
||||
|
||||
const data = await generatePPT({
|
||||
sessionId: state.sessionId,
|
||||
style: state.selectedStyle,
|
||||
companyId: state.savingsCompany || state.ciCompany || state.iulCompany || 'ctf',
|
||||
savingsCompanyId: state.savingsCompany || '',
|
||||
ciCompanyId: state.ciCompany || '',
|
||||
iulCompanyId: state.iulCompany || '',
|
||||
companyInfo: state.companyInfo,
|
||||
format: state.selectedFormat,
|
||||
quality: state.selectedQuality,
|
||||
aiNarrative: aiNarrative,
|
||||
});
|
||||
state.downloadUrl = data.downloadUrl;
|
||||
state.markdownUrl = data.markdownUrl || '';
|
||||
state.previewUrls = data.previewUrls || [];
|
||||
state.previewPdfUrl = data.previewPdfUrl || '';
|
||||
state.slideCount = data.slideCount || 0;
|
||||
state.resultFilename = (() => {
|
||||
try {
|
||||
const parsed = new URL(data.downloadUrl, location.origin);
|
||||
return decodeURIComponent(parsed.pathname.split('/').pop() || `plan.${state.selectedFormat}`);
|
||||
} catch {
|
||||
return `plan.${state.selectedFormat}`;
|
||||
}
|
||||
})();
|
||||
toast('PPT 已生成!', 'success');
|
||||
goStep('result');
|
||||
} catch (err) {
|
||||
toast('生成失败: ' + err.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = oldHtml;
|
||||
}
|
||||
}
|
||||
|
||||
export async function initGenerate() {
|
||||
renderStyles();
|
||||
renderValidation();
|
||||
// 公司已经在上传时按产品选择,生成页不再需要选公司
|
||||
if (state.sessionId) {
|
||||
try {
|
||||
state.validation = await validateExtraction(state.sessionId);
|
||||
} catch (err) {
|
||||
state.validation = {
|
||||
validated: false,
|
||||
errorCount: 1,
|
||||
warnCount: 0,
|
||||
issues: [{ field: 'system', severity: 'error', message: err.message || '校验接口失败' }],
|
||||
};
|
||||
}
|
||||
renderValidation();
|
||||
}
|
||||
|
||||
const textarea = document.getElementById('companyInfoTextarea');
|
||||
if (textarea && textarea.dataset.bound !== '1') {
|
||||
textarea.dataset.bound = '1';
|
||||
textarea.addEventListener('input', () => {
|
||||
state.companyInfo = textarea.value;
|
||||
const count = document.getElementById('companyInfoCount');
|
||||
if (count) {
|
||||
count.textContent = `${textarea.value.length} / 300`;
|
||||
count.style.color = textarea.value.length > 300 ? '#FF9500' : '';
|
||||
}
|
||||
});
|
||||
}
|
||||
const btn = document.getElementById('startGenerateBtn');
|
||||
if (btn) btn.onclick = onGenerate;
|
||||
const back = document.getElementById('generateBackBtn');
|
||||
if (back) back.onclick = () => goStep('upload');
|
||||
|
||||
// AI 总结勾选框
|
||||
const aiCheck = document.getElementById('useAiSummary');
|
||||
if (aiCheck) {
|
||||
aiCheck.checked = state.useAiSummary || false;
|
||||
aiCheck.addEventListener('change', () => { state.useAiSummary = aiCheck.checked; });
|
||||
}
|
||||
|
||||
renderFormatAndQuality();
|
||||
updateGenerateBtn();
|
||||
updatePreview();
|
||||
}
|
||||
187
baodanppt/public/js/screens/parsing.js
Normal file
187
baodanppt/public/js/screens/parsing.js
Normal file
@ -0,0 +1,187 @@
|
||||
/* =========================================================================
|
||||
Screen 2: Parsing - 解析进度
|
||||
========================================================================= */
|
||||
|
||||
import { state } from '../state.js';
|
||||
import { parseSession, getSession } from '../api.js';
|
||||
import { goStep, toast } from '../steps.js';
|
||||
|
||||
const STAGES = [
|
||||
{ min: 0, headline: '正在提取数据对象', sub: '正在识别关键保险条款和数据表格...' },
|
||||
{ min: 40, headline: '构建实体', sub: '将数据映射到内部结构...' },
|
||||
{ min: 70, headline: '完成索引', sub: '为生成引擎准备上下文...' },
|
||||
{ min: 99, headline: '解析完成', sub: '所有文档均已成功处理,准备对话。' },
|
||||
];
|
||||
|
||||
const HEADLINE_EL = () => document.getElementById('parsingHeadline');
|
||||
const SUBTEXT_EL = () => document.getElementById('parsingSubtext');
|
||||
const PERCENT_EL = () => document.getElementById('parsingPercent');
|
||||
const CIRCLE_EL = () => document.getElementById('parsingCircle');
|
||||
const FILE_LIST_EL = () => document.getElementById('parsingFileList');
|
||||
const CANCEL_BTN = () => document.getElementById('parsingCancelBtn');
|
||||
const CONTINUE_BTN = () => document.getElementById('parsingContinueBtn');
|
||||
|
||||
let _polling = null;
|
||||
let _simInterval = null;
|
||||
|
||||
function setStage(percent) {
|
||||
const stage = [...STAGES].reverse().find(s => percent >= s.min) || STAGES[0];
|
||||
if (HEADLINE_EL()) HEADLINE_EL().textContent = stage.headline;
|
||||
if (SUBTEXT_EL()) SUBTEXT_EL().textContent = stage.sub;
|
||||
if (PERCENT_EL()) PERCENT_EL().textContent = Math.round(percent) + '%';
|
||||
if (CIRCLE_EL()) {
|
||||
const C = 339.292; // 2 * π * 54
|
||||
const offset = C - (percent / 100) * C;
|
||||
CIRCLE_EL().style.strokeDashoffset = offset;
|
||||
}
|
||||
}
|
||||
|
||||
function renderFiles() {
|
||||
const el = FILE_LIST_EL();
|
||||
if (!el) return;
|
||||
el.innerHTML = '';
|
||||
state.files.forEach((entry, idx) => {
|
||||
const sizeMB = (entry.file.size / 1024 / 1024).toFixed(1);
|
||||
const div = document.createElement('div');
|
||||
div.className = 'bg-surface-container-lowest border border-border-subtle rounded-xl p-3 flex items-center justify-between';
|
||||
div.dataset.idx = idx;
|
||||
div.innerHTML = `
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div class="w-10 h-10 rounded-lg bg-tertiary-fixed flex items-center justify-center text-tertiary shrink-0">
|
||||
<span class="material-symbols-outlined">description</span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-medium text-sm text-on-surface truncate">${entry.file.name}</div>
|
||||
<div class="text-xs text-text-secondary">${sizeMB} MB</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="text-xs font-medium" data-status>等待中...</span>
|
||||
<span class="material-symbols-outlined text-[20px]" data-icon>schedule</span>
|
||||
</div>
|
||||
`;
|
||||
el.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function setFileStatus(idx, status) {
|
||||
const el = FILE_LIST_EL()?.children[idx];
|
||||
if (!el) return;
|
||||
const statusEl = el.querySelector('[data-status]');
|
||||
const iconEl = el.querySelector('[data-icon]');
|
||||
if (status === 'parsing') {
|
||||
statusEl.textContent = '解析中...';
|
||||
statusEl.className = 'text-xs font-medium text-status-warning';
|
||||
iconEl.textContent = 'progress_activity';
|
||||
iconEl.className = 'material-symbols-outlined text-status-warning text-[20px] animate-spin';
|
||||
el.classList.add('bg-brand-gold-soft');
|
||||
} else if (status === 'success') {
|
||||
statusEl.textContent = '成功';
|
||||
statusEl.className = 'text-xs font-medium text-status-success';
|
||||
iconEl.textContent = 'check_circle';
|
||||
iconEl.className = 'material-symbols-outlined text-status-success text-[20px]';
|
||||
iconEl.style.fontVariationSettings = "'FILL' 1";
|
||||
el.classList.remove('bg-brand-gold-soft');
|
||||
} else if (status === 'fail') {
|
||||
statusEl.textContent = '失败';
|
||||
statusEl.className = 'text-xs font-medium text-status-error';
|
||||
iconEl.textContent = 'error';
|
||||
iconEl.className = 'material-symbols-outlined text-status-error text-[20px]';
|
||||
} else { // pending
|
||||
statusEl.textContent = '等待中...';
|
||||
statusEl.className = 'text-xs font-medium text-text-secondary';
|
||||
iconEl.textContent = 'schedule';
|
||||
iconEl.className = 'material-symbols-outlined text-text-secondary text-[20px]';
|
||||
}
|
||||
}
|
||||
|
||||
function completeAll() {
|
||||
state.files.forEach((_, i) => setFileStatus(i, 'success'));
|
||||
setStage(100);
|
||||
CANCEL_BTN()?.classList.add('hidden');
|
||||
CONTINUE_BTN()?.classList.remove('hidden');
|
||||
CONTINUE_BTN()?.classList.add('flex');
|
||||
if (_simInterval) { clearInterval(_simInterval); _simInterval = null; }
|
||||
}
|
||||
|
||||
async function pollBackend() {
|
||||
if (_polling) return;
|
||||
_polling = setInterval(async () => {
|
||||
try {
|
||||
const session = await getSession(state.sessionId);
|
||||
if (session.status === 'parsed') {
|
||||
clearInterval(_polling); _polling = null;
|
||||
state.extractions = session.extractions || [];
|
||||
completeAll();
|
||||
toast('解析完成!', 'success');
|
||||
} else if (session.status === 'error') {
|
||||
clearInterval(_polling); _polling = null;
|
||||
state.files.forEach((_, i) => setFileStatus(i, 'fail'));
|
||||
toast('解析失败,请重试', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('轮询失败:', err);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/* 启动一个简短的本地进度模拟 (与后端并行, 仅作视觉反馈) */
|
||||
function startLocalSim() {
|
||||
let progress = 5;
|
||||
setStage(progress);
|
||||
// 模拟文件状态切换
|
||||
state.files.forEach((_, i) => setFileStatus(i, 'pending'));
|
||||
if (state.files[0]) setFileStatus(0, 'parsing');
|
||||
|
||||
// 关键: files 为空时, 立即让 progress 跑 (避免 UI 卡 0%)
|
||||
if (state.files.length === 0) {
|
||||
setStage(10);
|
||||
}
|
||||
|
||||
_simInterval = setInterval(() => {
|
||||
progress += 1.5 + Math.random() * 2;
|
||||
if (progress > 95) progress = 95; // 留给后端确认
|
||||
setStage(progress);
|
||||
if (progress > 30 && state.files[1] && document.querySelector('[data-idx="1"] [data-status]')?.textContent === '等待中...') {
|
||||
setFileStatus(0, 'success');
|
||||
setFileStatus(1, 'parsing');
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/* 外部触发 (upload.js 调用) */
|
||||
window.__triggerParse = async function() {
|
||||
renderFiles();
|
||||
// 立即显示进度, 避免 0% 闪烁
|
||||
setStage(5);
|
||||
startLocalSim();
|
||||
pollBackend();
|
||||
// 关键: parseSession 不阻塞, 但收到响应后立即 completeAll
|
||||
// (pollBackend 仍会兜底, 但 explicit complete 避免 race condition)
|
||||
parseSession(state.sessionId).then((data) => {
|
||||
state.extractions = data.extractions || [];
|
||||
// 立即触发 completeAll, 不等 pollBackend
|
||||
if (_polling) { clearInterval(_polling); _polling = null; }
|
||||
completeAll();
|
||||
toast('解析完成!', 'success');
|
||||
}).catch((err) => {
|
||||
console.warn('parseSession 调用失败 (但 pollBackend 仍在跑):', err);
|
||||
});
|
||||
};
|
||||
|
||||
export function initParsing() {
|
||||
renderFiles();
|
||||
if (CANCEL_BTN()) CANCEL_BTN().onclick = () => {
|
||||
if (_polling) clearInterval(_polling); _polling = null;
|
||||
if (_simInterval) clearInterval(_simInterval); _simInterval = null;
|
||||
toast('已取消', 'info');
|
||||
goStep('upload');
|
||||
};
|
||||
if (CONTINUE_BTN()) CONTINUE_BTN().onclick = () => {
|
||||
if (!state.extractions || state.extractions.length === 0) {
|
||||
toast('暂未解析成功', 'warning');
|
||||
return;
|
||||
}
|
||||
goStep('generate');
|
||||
};
|
||||
}
|
||||
270
baodanppt/public/js/screens/result-summary.js
Normal file
270
baodanppt/public/js/screens/result-summary.js
Normal file
@ -0,0 +1,270 @@
|
||||
/* =========================================================================
|
||||
保单摘要长图生成器 - 关键指标 + 完整数据表
|
||||
========================================================================= */
|
||||
|
||||
import { state } from '../state.js';
|
||||
|
||||
function fmtNum(n) {
|
||||
if (n === null || n === undefined || isNaN(n)) return '—';
|
||||
if (typeof n === 'number') return n.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||
return n;
|
||||
}
|
||||
|
||||
function calcMilestones(bi, paidTotal) {
|
||||
let payback = null, double = null, triple = null;
|
||||
const sorted = [...bi].sort((a, b) => a.policy_year - b.policy_year);
|
||||
for (const r of sorted) {
|
||||
const y = r.policy_year;
|
||||
const total = r.total_surrender_value || 0;
|
||||
if (total <= 0) continue;
|
||||
const mult = paidTotal > 0 ? total / paidTotal : 0;
|
||||
if (payback === null && total >= paidTotal) payback = y;
|
||||
if (double === null && mult >= 2.0) double = y;
|
||||
if (triple === null && mult >= 3.0) triple = y;
|
||||
}
|
||||
return { payback, double, triple };
|
||||
}
|
||||
|
||||
function getCompanyId(planType) {
|
||||
if (planType === 'ci') return state.ciCompany;
|
||||
if (planType === 'iul') return state.iulCompany;
|
||||
return state.savingsCompany;
|
||||
}
|
||||
|
||||
// === M-A NPV IRR (与 TypeScript server.ts / Python savings_normalizer 完全一致) ===
|
||||
// 现金流: -P at t=0..n-1, +SV at t=year. 求解 NPV=0, 封顶 HK IA (港元 6.0% / 非港元 6.5%).
|
||||
function _maNpv(r, cf) { let s = 0; for (const [t, a] of cf) s += a / Math.pow(1 + r, t); return s; }
|
||||
function _maIrrBisect(cf) {
|
||||
let lo = -0.99, hi = 1.0;
|
||||
let fLo = _maNpv(lo, cf), fHi = _maNpv(hi, cf);
|
||||
if (fLo * fHi > 0) return null;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
const fMid = _maNpv(mid, cf);
|
||||
if (Math.abs(fMid) < 1e-6 || (hi - lo) < 1e-10) return mid;
|
||||
if (fLo * fMid < 0) { hi = mid; fHi = fMid; } else { lo = mid; fLo = fMid; }
|
||||
}
|
||||
return (lo + hi) / 2;
|
||||
}
|
||||
function _iaIrrCap(currency) {
|
||||
const c = String(currency || 'USD').toUpperCase().trim();
|
||||
return (c === 'HKD' || c === '港币' || c === '港元') ? 0.06 : 0.065;
|
||||
}
|
||||
function computeIrrMA(annualPrem, payYrs, sv, yr, currency) {
|
||||
if (yr <= 0 || annualPrem <= 0 || sv <= 0 || payYrs < 1) return null;
|
||||
const cf = [[0, -annualPrem]];
|
||||
for (let i = 1; i < payYrs; i++) cf.push([i, -annualPrem]);
|
||||
cf.push([yr, sv]);
|
||||
const r = _maIrrBisect(cf);
|
||||
if (r === null) return null;
|
||||
return Math.min(r, _iaIrrCap(currency));
|
||||
}
|
||||
|
||||
function buildFullSummaryHTML(interval = 5) {
|
||||
const extractions = state.extractions || [];
|
||||
if (!extractions.length) return '<div style="padding:40px;text-align:center;color:#999;">暂无提取数据</div>';
|
||||
|
||||
let allHtml = '';
|
||||
|
||||
extractions.forEach((extraction, idx) => {
|
||||
const data = extraction.data || {};
|
||||
const ins = data.insured || {};
|
||||
const pol = data.policy || {};
|
||||
const bi = (data.benefit_illustration || []).filter(r => r.total_surrender_value > 0);
|
||||
const payPeriod = Math.max(parseInt(String(pol.premium_payment_period || '5').replace('年','')) || 5, 5);
|
||||
const paidTotal = (pol.annual_premium || 0) * payPeriod;
|
||||
const milestones = calcMilestones(bi, paidTotal);
|
||||
const currency = pol.currency || 'USD';
|
||||
const productName = data.product_name || pol.product_name || '—';
|
||||
const planType = extraction.planType || 'savings';
|
||||
const typeLabel = { savings: '储蓄险', ci: '重疾险', iul: 'IUL' }[planType] || '保险';
|
||||
const companyId = getCompanyId(planType);
|
||||
const heroUrl = companyId ? `/assets/library/companies/${companyId}/company-hero-01.png` : '';
|
||||
|
||||
// 按间隔过滤
|
||||
let displayYears = bi
|
||||
.filter(r => r.policy_year === 1 || r.policy_year % interval === 0)
|
||||
.sort((a, b) => a.policy_year - b.policy_year);
|
||||
const lastYear = bi.length ? bi[bi.length - 1].policy_year : 0;
|
||||
if (lastYear > 0 && !displayYears.find(d => d.policy_year === lastYear)) {
|
||||
const last = bi.find(r => r.policy_year === lastYear);
|
||||
if (last) displayYears.push(last);
|
||||
}
|
||||
|
||||
const msItems = [
|
||||
milestones.payback ? `<div style="background:rgba(255,255,255,.18);border-radius:10px;padding:6px 10px;text-align:center;"><div style="font-size:9px;opacity:.7;">回本</div><div style="font-size:16px;font-weight:700;">第${milestones.payback}年</div></div>` : '',
|
||||
milestones.double ? `<div style="background:rgba(255,255,255,.18);border-radius:10px;padding:6px 10px;text-align:center;"><div style="font-size:9px;opacity:.7;">翻倍</div><div style="font-size:16px;font-weight:700;">第${milestones.double}年</div></div>` : '',
|
||||
milestones.triple ? `<div style="background:rgba(255,255,255,.18);border-radius:10px;padding:6px 10px;text-align:center;"><div style="font-size:9px;opacity:.7;">三倍</div><div style="font-size:16px;font-weight:700;">第${milestones.triple}年</div></div>` : '',
|
||||
].filter(Boolean).join('');
|
||||
|
||||
const intervalLabel = { 1: '每年', 5: '每5年', 10: '每10年' }[interval] || `每${interval}年`;
|
||||
const rowFontSize = displayYears.length > 80 ? '9px' : '10px';
|
||||
const tableRows = displayYears.map(r => {
|
||||
const y = r.policy_year;
|
||||
const prem = r.total_premium_paid || 0;
|
||||
const total = r.total_surrender_value || 0;
|
||||
const guar = r.guaranteed_cash_value || 0;
|
||||
const nonGuar = total - guar;
|
||||
const mult = paidTotal > 0 ? (total / paidTotal) : 0;
|
||||
// M-A NPV IRR (与 PPT/服务端一致) + HK IA 封顶
|
||||
const irr = (total > 0 && prem > 0 && y > 0)
|
||||
? computeIrrMA(pol.annual_premium || 0, payPeriod, total, y, currency)
|
||||
: null;
|
||||
const bg = y % 2 === 0 ? 'background:#f8f9fb;' : '';
|
||||
return `<tr style="${bg}">
|
||||
<td style="padding:2px 2px;border-bottom:1px solid #f0f0f0;font-size:${rowFontSize};text-align:center;color:#666;">${y}</td>
|
||||
<td style="padding:2px 2px;border-bottom:1px solid #f0f0f0;font-size:${rowFontSize};text-align:center;color:#666;">${ins.age ? Number(ins.age) + y - 1 : '—'}</td>
|
||||
<td style="padding:2px 2px;border-bottom:1px solid #f0f0f0;font-size:${rowFontSize};text-align:right;">${fmtNum(prem)}</td>
|
||||
<td style="padding:2px 2px;border-bottom:1px solid #f0f0f0;font-size:${rowFontSize};text-align:right;">${fmtNum(guar)}</td>
|
||||
<td style="padding:2px 2px;border-bottom:1px solid #f0f0f0;font-size:${rowFontSize};text-align:right;color:#2563eb;">${fmtNum(nonGuar > 0 ? nonGuar : 0)}</td>
|
||||
<td style="padding:2px 2px;border-bottom:1px solid #f0f0f0;font-size:${rowFontSize};text-align:right;font-weight:600;">${fmtNum(total)}</td>
|
||||
<td style="padding:2px 2px;border-bottom:1px solid #f0f0f0;font-size:${rowFontSize};text-align:center;font-weight:600;">${(mult > 0 && isFinite(mult)) ? mult.toFixed(2) + 'x' : '—'}</td>
|
||||
<td style="padding:2px 2px;border-bottom:1px solid #f0f0f0;font-size:${rowFontSize};text-align:center;color:#999;">${(irr && isFinite(irr)) ? (irr * 100).toFixed(2) + '%' : '—'}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
allHtml += `
|
||||
<div style="background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.08);margin-bottom:24px;">
|
||||
<div style="background:${heroUrl ? `linear-gradient(rgba(0,0,0,.55),rgba(0,0,0,.65)),url(${heroUrl})` : 'linear-gradient(135deg,#1a2a4a,#2d4a6a)'};background-size:cover;background-position:center;color:#fff;padding:20px 16px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px;">
|
||||
<div>
|
||||
<div style="font-size:9px;opacity:.6;letter-spacing:.1em;margin-bottom:3px;">${typeLabel.toUpperCase()} · 保单摘要 · ${intervalLabel}</div>
|
||||
<div style="font-size:18px;font-weight:700;line-height:1.3;">${productName}</div>
|
||||
</div>
|
||||
<div style="text-align:right;flex-shrink:0;">
|
||||
<div style="font-size:9px;opacity:.6;">受保人</div>
|
||||
<div style="font-size:14px;font-weight:600;">${ins.name || '—'}${ins.age ? `(${ins.age}岁)` : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-bottom:12px;">
|
||||
<div style="background:rgba(255,255,255,.12);border-radius:8px;padding:8px 6px;text-align:center;">
|
||||
<div style="font-size:9px;opacity:.7;margin-bottom:2px;">年缴保费</div>
|
||||
<div style="font-size:14px;font-weight:700;">${currency} ${(pol.annual_premium || 0).toLocaleString()}</div>
|
||||
</div>
|
||||
<div style="background:rgba(255,255,255,.12);border-radius:8px;padding:8px 6px;text-align:center;">
|
||||
<div style="font-size:9px;opacity:.7;margin-bottom:2px;">缴费年期</div>
|
||||
<div style="font-size:14px;font-weight:700;">${pol.premium_payment_period || '—'}</div>
|
||||
</div>
|
||||
<div style="background:rgba(255,255,255,.12);border-radius:8px;padding:8px 6px;text-align:center;">
|
||||
<div style="font-size:9px;opacity:.7;margin-bottom:2px;">总缴保费</div>
|
||||
<div style="font-size:14px;font-weight:700;">${currency} ${paidTotal.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
${msItems ? `<div style="display:grid;grid-template-columns:repeat(${Math.min(msItems.split('</div>').length - 1, 3)},1fr);gap:6px;">${msItems}</div>` : ''}
|
||||
</div>
|
||||
<div style="padding:12px 8px;">
|
||||
<div style="font-size:12px;font-weight:600;color:#1a1a2e;margin-bottom:8px;">📋 利益演示(${intervalLabel} · 共 ${displayYears.length} 行)</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table style="width:100%;border-collapse:collapse;font-family:monospace,'Courier New',sans-serif;">
|
||||
<thead>
|
||||
<tr style="background:#f0f2f5;">
|
||||
<th style="padding:4px 2px;font-size:9px;color:#666;font-weight:600;text-align:center;border-bottom:2px solid #ddd;">年度</th>
|
||||
<th style="padding:4px 2px;font-size:9px;color:#666;font-weight:600;text-align:center;border-bottom:2px solid #ddd;">年龄</th>
|
||||
<th style="padding:4px 2px;font-size:9px;color:#666;font-weight:600;text-align:right;border-bottom:2px solid #ddd;">已缴保费</th>
|
||||
<th style="padding:4px 2px;font-size:9px;color:#666;font-weight:600;text-align:right;border-bottom:2px solid #ddd;">保证现价</th>
|
||||
<th style="padding:4px 2px;font-size:9px;color:#666;font-weight:600;text-align:right;border-bottom:2px solid #ddd;">非保证</th>
|
||||
<th style="padding:4px 2px;font-size:9px;color:#666;font-weight:600;text-align:right;border-bottom:2px solid #ddd;">总退保价值</th>
|
||||
<th style="padding:4px 2px;font-size:9px;color:#666;font-weight:600;text-align:center;border-bottom:2px solid #ddd;">倍数</th>
|
||||
<th style="padding:4px 2px;font-size:9px;color:#666;font-weight:600;text-align:center;border-bottom:2px solid #ddd;">IRR</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${tableRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:8px 12px;border-top:1px solid #eee;display:flex;justify-content:space-between;font-size:8px;color:#999;">
|
||||
<span>由 AI Insurance 生成</span>
|
||||
<span>${new Date().toLocaleDateString('zh-CN')}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
return `<div style="padding:16px 10px;background:#f0f2f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">${allHtml}</div>`;
|
||||
}
|
||||
|
||||
export function renderSummaryTo(containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
container.innerHTML = buildFullSummaryHTML();
|
||||
}
|
||||
|
||||
/** 弹出间隔选择器 */
|
||||
export function showIntervalDialog() {
|
||||
const existing = document.getElementById('summaryIntervalOverlay');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'summaryIntervalOverlay';
|
||||
overlay.style.cssText = 'position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;';
|
||||
overlay.innerHTML = [
|
||||
'<div style="background:#fff;border-radius:20px;padding:28px 24px;width:320px;box-shadow:0 20px 60px rgba(0,0,0,.25);text-align:center;">',
|
||||
' <div style="font-size:18px;font-weight:700;color:#1a1a2e;margin-bottom:4px;">📸 导出摘要图</div>',
|
||||
' <div style="font-size:12px;color:#94a3b8;margin-bottom:20px;">选择数据显示间隔</div>',
|
||||
' <div style="display:flex;flex-direction:column;gap:10px;margin-bottom:20px;">',
|
||||
' <button class="interval-opt" data-interval="1" style="padding:14px;border:2px solid #e2e8f0;border-radius:12px;background:#fff;font-size:15px;font-weight:600;color:#0f172a;cursor:pointer;width:100%;">📋 每年显示<span style="font-weight:400;font-size:12px;color:#94a3b8;display:block;margin-top:2px;">完整展示所有年份</span></button>',
|
||||
' <button class="interval-opt" data-interval="5" style="padding:14px;border:2px solid #2563eb;border-radius:12px;background:#eff6ff;font-size:15px;font-weight:600;color:#1e40af;cursor:pointer;width:100%;">📊 每5年显示<span style="font-weight:400;font-size:12px;color:#64748b;display:block;margin-top:2px;">推荐,兼顾完整与简洁</span></button>',
|
||||
' <button class="interval-opt" data-interval="10" style="padding:14px;border:2px solid #e2e8f0;border-radius:12px;background:#fff;font-size:15px;font-weight:600;color:#0f172a;cursor:pointer;width:100%;">📈 每10年显示<span style="font-weight:400;font-size:12px;color:#94a3b8;display:block;margin-top:2px;">最简洁,突出趋势</span></button>',
|
||||
' </div>',
|
||||
' <button id="intervalCancelBtn" style="padding:8px 20px;border:none;border-radius:8px;background:#f1f5f9;font-size:13px;color:#64748b;cursor:pointer;">取消</button>',
|
||||
'</div>',
|
||||
].join('');
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
overlay.querySelectorAll('.interval-opt').forEach(btn => {
|
||||
btn.onclick = async () => {
|
||||
const interval = parseInt(btn.dataset.interval);
|
||||
overlay.remove();
|
||||
await exportSummaryAsImage(interval);
|
||||
};
|
||||
});
|
||||
document.getElementById('intervalCancelBtn').onclick = () => overlay.remove();
|
||||
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
|
||||
}
|
||||
|
||||
async function exportSummaryAsImage(interval = 5) {
|
||||
const container = document.getElementById('summaryExportArea');
|
||||
if (!container) { console.error('summaryExportArea not found'); return; }
|
||||
|
||||
container.innerHTML = buildFullSummaryHTML(interval);
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
|
||||
try {
|
||||
container.style.display = 'block';
|
||||
container.style.position = 'fixed';
|
||||
container.style.left = '-9999px';
|
||||
container.style.top = '0';
|
||||
container.style.zIndex = '-1';
|
||||
container.style.width = '460px';
|
||||
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
|
||||
const rowCount = container.querySelectorAll('tbody tr').length;
|
||||
const scale = rowCount > 100 ? 1.2 : rowCount > 60 ? 1.5 : 2.0;
|
||||
|
||||
const canvas = await html2canvas(container, {
|
||||
scale,
|
||||
useCORS: true,
|
||||
backgroundColor: '#f0f2f5',
|
||||
logging: false,
|
||||
width: 460,
|
||||
height: container.scrollHeight,
|
||||
});
|
||||
|
||||
container.style.display = 'none';
|
||||
container.style.position = '';
|
||||
container.style.left = '';
|
||||
container.style.top = '';
|
||||
container.style.zIndex = '';
|
||||
container.style.width = '';
|
||||
|
||||
const link = document.createElement('a');
|
||||
const suffix = interval === 1 ? '每年' : interval === 5 ? '每5年' : '每10年';
|
||||
link.download = `保单摘要_${suffix}.png`;
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
} catch (err) {
|
||||
console.error('导出摘要图失败:', err);
|
||||
container.style.display = 'none';
|
||||
alert('导出失败: ' + (err.message || '未知错误'));
|
||||
}
|
||||
}
|
||||
155
baodanppt/public/js/screens/result.js
Normal file
155
baodanppt/public/js/screens/result.js
Normal file
@ -0,0 +1,155 @@
|
||||
/* =========================================================================
|
||||
Screen 5: Result - 完成 + 下载
|
||||
========================================================================= */
|
||||
|
||||
import { state, resetState } from '../state.js';
|
||||
import { downloadSignedFile } from '../api.js';
|
||||
import { goStep, toast } from '../steps.js';
|
||||
import { renderSummaryTo, showIntervalDialog } from './result-summary.js';
|
||||
|
||||
const STYLE_NAMES = {
|
||||
broker: '专业券商风',
|
||||
business: '商务风',
|
||||
minimal: '简洁风',
|
||||
chinese: '中国风',
|
||||
ink: '水墨风',
|
||||
};
|
||||
|
||||
function setPreview(index = 0) {
|
||||
const image = document.getElementById('resultPreviewImage');
|
||||
const empty = document.getElementById('resultPreviewEmpty');
|
||||
const badge = document.getElementById('resultPreviewBadge');
|
||||
const urls = state.previewUrls || [];
|
||||
if (!image || !empty || !badge) return;
|
||||
if (!urls.length) {
|
||||
image.classList.add('hidden');
|
||||
empty.classList.remove('hidden');
|
||||
badge.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
const safeIndex = Math.max(0, Math.min(index, urls.length - 1));
|
||||
image.src = urls[safeIndex];
|
||||
image.classList.remove('hidden');
|
||||
empty.classList.add('hidden');
|
||||
badge.textContent = `${String(safeIndex + 1).padStart(2, '0')} / ${String(state.slideCount || urls.length).padStart(2, '0')}`;
|
||||
badge.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function renderThumbs() {
|
||||
const wrap = document.getElementById('resultThumbs');
|
||||
if (!wrap) return;
|
||||
wrap.innerHTML = '';
|
||||
const urls = state.previewUrls || [];
|
||||
if (!urls.length) return;
|
||||
urls.slice(0, 8).forEach((url, i) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = `result-thumb flex-shrink-0 w-24 aspect-video rounded-lg overflow-hidden border ${i === 0 ? 'border-2 border-primary-container' : 'border-border-subtle'} bg-surface-container-low shadow-sm`;
|
||||
btn.innerHTML = `<img src="${url}" alt="第 ${i + 1} 页缩略图" class="w-full h-full object-cover" />`;
|
||||
btn.onclick = () => {
|
||||
document.querySelectorAll('.result-thumb').forEach((x) => {
|
||||
x.classList.remove('border-2', 'border-primary-container');
|
||||
x.classList.add('border', 'border-border-subtle');
|
||||
});
|
||||
btn.classList.remove('border', 'border-border-subtle');
|
||||
btn.classList.add('border-2', 'border-primary-container');
|
||||
setPreview(i);
|
||||
};
|
||||
wrap.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
const fnEl = document.getElementById('resultFilename');
|
||||
if (fnEl) fnEl.textContent = state.resultFilename || 'plan.pptx';
|
||||
const ext = (state.resultFilename || '').toLowerCase().endsWith('.pdf') ? 'PDF' : 'PPTX';
|
||||
const styleName = STYLE_NAMES[state.selectedStyle] || '专业券商风';
|
||||
const styleEl = document.getElementById('resultStyleName');
|
||||
if (styleEl) styleEl.textContent = styleName;
|
||||
const summary = document.getElementById('resultSummary');
|
||||
if (summary) {
|
||||
const products = (state.extractions || []).map(e => e.productName).filter(Boolean);
|
||||
summary.textContent = products.length > 0
|
||||
? `已为 ${products.join(' + ')} 联合定制方案`
|
||||
: '已为您生成定制方案';
|
||||
}
|
||||
const metaValue = document.getElementById('resultMetaValue');
|
||||
if (metaValue) {
|
||||
const kinds = [...new Set((state.extractions || []).map((e) => e.planType).filter(Boolean))];
|
||||
metaValue.textContent = state.slideCount ? `${state.slideCount}页` : (kinds.length > 1 ? `${kinds.length}类产品` : '已完成');
|
||||
}
|
||||
const mdBtn = document.getElementById('resultDownloadMdBtn');
|
||||
if (mdBtn) mdBtn.style.display = state.markdownUrl ? 'flex' : 'none';
|
||||
const downloadBtn = document.getElementById('resultDownloadBtn');
|
||||
if (downloadBtn) {
|
||||
downloadBtn.innerHTML = `<span class="material-symbols-outlined" style="font-variation-settings:'FILL' 1">file_download</span> 下载 .${ext}`;
|
||||
}
|
||||
renderThumbs();
|
||||
setPreview(0);
|
||||
}
|
||||
|
||||
async function downloadFile(url, filename, btnId) {
|
||||
if (!url) { toast('下载地址无效', 'error'); return; }
|
||||
const btn = document.getElementById(btnId);
|
||||
const oldHtml = btn ? btn.innerHTML : '';
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="material-symbols-outlined animate-spin">progress_activity</span> 准备下载...';
|
||||
}
|
||||
try {
|
||||
const blob = await downloadSignedFile(url);
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
toast('已开始下载', 'success');
|
||||
if (btn) {
|
||||
btn.innerHTML = '<span class="material-symbols-outlined" style="font-variation-settings:\'FILL\' 1">check_circle</span> 已下载';
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = oldHtml;
|
||||
btn.disabled = false;
|
||||
}, 1800);
|
||||
}
|
||||
} catch (err) {
|
||||
toast('下载失败: ' + err.message, 'error');
|
||||
if (btn) {
|
||||
btn.innerHTML = oldHtml;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initResult() {
|
||||
render();
|
||||
|
||||
document.getElementById('resultDownloadBtn').onclick = () => {
|
||||
downloadFile(state.downloadUrl, state.resultFilename, 'resultDownloadBtn');
|
||||
};
|
||||
document.getElementById('resultDownloadMdBtn').onclick = () => {
|
||||
let fn = 'plan.md';
|
||||
try {
|
||||
fn = decodeURIComponent(new URL(state.markdownUrl, location.origin).pathname.split('/').pop() || 'plan.md');
|
||||
} catch {}
|
||||
downloadFile(state.markdownUrl, fn, 'resultDownloadMdBtn');
|
||||
};
|
||||
document.getElementById('resultNewBtn').onclick = () => {
|
||||
resetState();
|
||||
goStep('upload');
|
||||
};
|
||||
const backChatBtn = document.getElementById('resultBackChatBtn');
|
||||
if (backChatBtn) backChatBtn.onclick = () => { goStep('chat'); };
|
||||
const regenBtn = document.getElementById('resultRegenerateBtn');
|
||||
if (regenBtn) regenBtn.onclick = () => { goStep('generate'); };
|
||||
|
||||
// 仅储蓄险显示保单摘要图功能
|
||||
const summaryBtn = document.getElementById('resultSummaryBtn');
|
||||
if (summaryBtn) {
|
||||
const types = [...new Set((state.extractions || []).map(e => e.planType).filter(Boolean))];
|
||||
if (types.length === 1 && types[0] === 'savings') {
|
||||
summaryBtn.style.display = 'flex';
|
||||
summaryBtn.onclick = () => { showIntervalDialog(); };
|
||||
} else {
|
||||
summaryBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
148
baodanppt/public/js/screens/upload.js
Normal file
148
baodanppt/public/js/screens/upload.js
Normal file
@ -0,0 +1,148 @@
|
||||
/* Screen 1: Upload - 三端口上传 */
|
||||
|
||||
import { state } from '../state.js';
|
||||
import { uploadFiles, parseSession, getRenderOptions } from '../api.js';
|
||||
import { goStep, toast } from '../steps.js';
|
||||
|
||||
const PORTS = ['savings', 'ci', 'iul'];
|
||||
const PORT_LABELS = { savings: '储蓄险', ci: '重疾险', iul: 'IUL' };
|
||||
|
||||
// 当前各端口状态
|
||||
const portFiles = { savings: null, ci: null, iul: null };
|
||||
const portCompanies = { savings: '', ci: '', iul: '' };
|
||||
let companiesList = [];
|
||||
|
||||
function initCompanySelects() {
|
||||
getRenderOptions().then((data) => {
|
||||
companiesList = data.companies || [];
|
||||
// IUL 专属公司列表(新加坡IUL市场)
|
||||
const IUL_COMPANIES = ['transamerica', 'sunlife', 'manulife'];
|
||||
// 储蓄险白名单: 排除大东方人寿 (great-eastern)
|
||||
const SAVINGS_COMPANIES = companiesList
|
||||
.filter((c) => c.id !== 'great-eastern')
|
||||
.map((c) => `<option value="${c.id}">${c.name}</option>`).join('');
|
||||
// 重疾险仅保留友邦 (aia) + 周大福 (ctf)
|
||||
const CI_COMPANIES = ['aia', 'ctf'];
|
||||
const ciOpts = companiesList
|
||||
.filter((c) => CI_COMPANIES.includes(c.id))
|
||||
.map((c) => `<option value="${c.id}">${c.name}</option>`).join('');
|
||||
const iulOpts = companiesList
|
||||
.filter((c) => IUL_COMPANIES.includes(c.id))
|
||||
.map((c) => `<option value="${c.id}">${c.name}</option>`).join('');
|
||||
|
||||
PORTS.forEach((p) => {
|
||||
const sel = document.getElementById(`company${p.charAt(0).toUpperCase() + p.slice(1)}`);
|
||||
if (!sel) return;
|
||||
let opts = '';
|
||||
if (p === 'iul') opts = iulOpts;
|
||||
else if (p === 'ci') opts = ciOpts;
|
||||
else opts = SAVINGS_COMPANIES;
|
||||
sel.innerHTML = '<option value="">选择公司...</option>' + opts;
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
const count = PORTS.filter((p) => portFiles[p]).length;
|
||||
document.getElementById('uploadFileCount').textContent = count;
|
||||
const btn = document.getElementById('uploadStartBtn');
|
||||
if (btn) btn.disabled = count === 0;
|
||||
}
|
||||
|
||||
function setupPort(portType) {
|
||||
const cap = portType.charAt(0).toUpperCase() + portType.slice(1);
|
||||
const dz = document.getElementById(`dropzone${cap}`);
|
||||
const input = dz?.querySelector('input[type="file"]');
|
||||
if (!dz || dz.dataset.bound) return;
|
||||
dz.dataset.bound = '1';
|
||||
|
||||
dz.onclick = () => input?.click();
|
||||
input.onchange = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith('.pdf')) { toast(`${file.name} 不是PDF`, 'warning'); return; }
|
||||
if (file.size > 30 * 1024 * 1024) { toast(`${file.name} 超过30MB`, 'error'); return; }
|
||||
portFiles[portType] = file;
|
||||
document.getElementById(`file${cap}Name`).textContent = file.name;
|
||||
document.getElementById(`file${cap}`).classList.remove('hidden');
|
||||
dz.querySelector('p').textContent = '✅ ' + file.name;
|
||||
dz.classList.add('border-primary-container');
|
||||
updateUI();
|
||||
};
|
||||
|
||||
['dragenter', 'dragover'].forEach((ev) => dz.addEventListener(ev, (e) => { e.preventDefault(); dz.classList.add('drag-active'); }));
|
||||
['dragleave', 'drop'].forEach((ev) => dz.addEventListener(ev, (e) => { e.preventDefault(); dz.classList.remove('drag-active'); }));
|
||||
dz.addEventListener('drop', (e) => {
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) {
|
||||
input.files = e.dataTransfer.files;
|
||||
input.dispatchEvent(new Event('change'));
|
||||
}
|
||||
});
|
||||
|
||||
// Company selector
|
||||
const sel = document.getElementById(`company${cap}`);
|
||||
if (sel) sel.onchange = () => { portCompanies[portType] = sel.value; };
|
||||
}
|
||||
|
||||
export function initUpload() {
|
||||
PORTS.forEach(setupPort);
|
||||
initCompanySelects();
|
||||
|
||||
document.getElementById('uploadClearBtn').onclick = () => {
|
||||
PORTS.forEach((p) => {
|
||||
portFiles[p] = null;
|
||||
const cap = p.charAt(0).toUpperCase() + p.slice(1);
|
||||
const dz = document.getElementById(`dropzone${cap}`);
|
||||
if (dz) {
|
||||
dz.querySelector('p').textContent = '点击上传' + PORT_LABELS[p] + ' (PDF)';
|
||||
dz.classList.remove('border-primary-container', 'drag-active');
|
||||
}
|
||||
document.getElementById(`file${cap}`).classList.add('hidden');
|
||||
});
|
||||
updateUI();
|
||||
};
|
||||
|
||||
document.getElementById('uploadStartBtn').onclick = onStartParse;
|
||||
updateUI();
|
||||
}
|
||||
|
||||
async function onStartParse() {
|
||||
// 验证: 已上传文件的端口必须选择公司
|
||||
const missing = PORTS.filter((p) => portFiles[p] && !portCompanies[p]);
|
||||
if (missing.length) {
|
||||
const names = missing.map((p) => PORT_LABELS[p]).join('、');
|
||||
toast(`请为 ${names} 选择公司`, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('uploadStartBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="material-symbols-outlined animate-spin">progress_activity</span> 上传中...';
|
||||
|
||||
try {
|
||||
const files = PORTS.filter((p) => portFiles[p]).map((p) => ({ file: portFiles[p], type: p }));
|
||||
const { sessionId } = await uploadFiles(files, {
|
||||
savings: portCompanies.savings,
|
||||
ci: portCompanies.ci,
|
||||
iul: portCompanies.iul,
|
||||
});
|
||||
state.sessionId = sessionId;
|
||||
state.files = files.map((f) => ({ file: { name: f.file.name }, type: f.type }));
|
||||
// Save company selections
|
||||
state.savingsCompany = portCompanies.savings;
|
||||
state.ciCompany = portCompanies.ci;
|
||||
state.iulCompany = portCompanies.iul;
|
||||
|
||||
toast('文件已上传,开始 AI 解析...', 'success');
|
||||
goStep('parsing');
|
||||
const waitParse = setInterval(() => {
|
||||
if (window.__triggerParse) { clearInterval(waitParse); window.__triggerParse(); }
|
||||
}, 100);
|
||||
setTimeout(() => clearInterval(waitParse), 5000);
|
||||
} catch (err) {
|
||||
toast('上传失败: ' + err.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '开始 AI 解析';
|
||||
}
|
||||
}
|
||||
30
baodanppt/public/js/state.js
Normal file
30
baodanppt/public/js/state.js
Normal file
@ -0,0 +1,30 @@
|
||||
export const state = {
|
||||
sessionId: null,
|
||||
files: [],
|
||||
extractions: [],
|
||||
selectedStyle: 'broker',
|
||||
selectedFormat: 'pptx',
|
||||
selectedQuality: 'high',
|
||||
selectedCompanyId: '',
|
||||
companyInfo: '',
|
||||
downloadUrl: '',
|
||||
markdownUrl: '',
|
||||
previewUrls: [],
|
||||
previewPdfUrl: '',
|
||||
slideCount: 0,
|
||||
validation: null,
|
||||
resultFilename: '',
|
||||
// 三端口公司选择
|
||||
savingsCompany: '',
|
||||
ciCompany: '',
|
||||
iulCompany: '',
|
||||
};
|
||||
|
||||
export function resetState() {
|
||||
Object.assign(state, {
|
||||
sessionId: null, files: [], extractions: [],
|
||||
selectedStyle: 'broker', selectedFormat: 'pptx', selectedQuality: 'high',
|
||||
selectedCompanyId: '', companyInfo: '', downloadUrl: '', markdownUrl: '', previewUrls: [], previewPdfUrl: '', slideCount: 0, validation: null, resultFilename: '',
|
||||
savingsCompany: '', ciCompany: '', iulCompany: '',
|
||||
});
|
||||
}
|
||||
113
baodanppt/public/js/steps.js
Normal file
113
baodanppt/public/js/steps.js
Normal file
@ -0,0 +1,113 @@
|
||||
/* =========================================================================
|
||||
Insurance Plan AI - 状态机 + 屏幕切换
|
||||
========================================================================= */
|
||||
|
||||
import { state, resetState } from './state.js';
|
||||
|
||||
const STEPS = [
|
||||
{ id: 'upload', label: '上传' },
|
||||
{ id: 'parsing', label: '解析' },
|
||||
{ id: 'generate', label: '生成' },
|
||||
{ id: 'result', label: '完成' },
|
||||
];
|
||||
|
||||
const STEP_ORDER = STEPS.map(s => s.id);
|
||||
|
||||
// 各 screen 的 init 函数, 动态 import 避免一次性加载
|
||||
const INIT_FNS = {
|
||||
upload: () => import('./screens/upload.js').then(m => m.initUpload()).catch(e => console.error(e)),
|
||||
parsing: () => import('./screens/parsing.js').then(m => m.initParsing()).catch(e => console.error(e)),
|
||||
generate: () => import('./screens/generate.js').then(m => m.initGenerate()).catch(e => console.error(e)),
|
||||
result: () => import('./screens/result.js').then(m => m.initResult()).catch(e => console.error(e)),
|
||||
};
|
||||
|
||||
/* 切换到指定屏幕 */
|
||||
export function goStep(stepId) {
|
||||
// 隐藏所有 screen
|
||||
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
||||
const target = document.getElementById('screen-' + stepId);
|
||||
if (target) target.classList.add('active');
|
||||
|
||||
// 滚到顶
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
|
||||
// 触发 screen 的 init (动态加载, fire-and-forget, 不阻塞)
|
||||
if (INIT_FNS[stepId]) {
|
||||
try {
|
||||
INIT_FNS[stepId]();
|
||||
} catch (e) { console.error('[init]', stepId, e); }
|
||||
}
|
||||
|
||||
// 更新步骤指示器
|
||||
updateStepsNav(stepId);
|
||||
}
|
||||
|
||||
/* 更新顶部 5 步指示器 */
|
||||
function updateStepsNav(currentStep) {
|
||||
const currentIndex = STEP_ORDER.indexOf(currentStep);
|
||||
document.querySelectorAll('#stepsNav .step-item').forEach((el, i) => {
|
||||
el.classList.remove('active', 'done');
|
||||
if (i < currentIndex) el.classList.add('done');
|
||||
else if (i === currentIndex) el.classList.add('active');
|
||||
});
|
||||
document.querySelectorAll('#stepsNav .step-divider').forEach((el, i) => {
|
||||
el.classList.toggle('done', i < currentIndex);
|
||||
});
|
||||
}
|
||||
|
||||
/* 构建步骤指示器 HTML */
|
||||
export function buildStepsNav() {
|
||||
const items = STEPS.map((s, i) => {
|
||||
const isLast = i === STEPS.length - 1;
|
||||
return `
|
||||
<div class="step-item" data-step="${s.id}">
|
||||
<span class="step-dot">${i + 1}</span>
|
||||
<span>${s.label}</span>
|
||||
</div>
|
||||
${!isLast ? '<span class="step-divider"></span>' : ''}
|
||||
`;
|
||||
}).join('');
|
||||
return `<nav id="stepsNav" class="steps-nav">${items}</nav>`;
|
||||
}
|
||||
|
||||
/* 返回首页 (新建会话) */
|
||||
export function goHome() {
|
||||
if (confirm('确定要新建方案吗?当前所有数据将被清空。')) {
|
||||
resetState();
|
||||
goStep('upload');
|
||||
}
|
||||
}
|
||||
|
||||
/* 简单 toast 提示 (右上角浮窗) */
|
||||
export function toast(message, type = 'info') {
|
||||
const colors = {
|
||||
info: '#007AFF',
|
||||
success: '#34C759',
|
||||
warning: '#FF9500',
|
||||
error: '#FF3B30',
|
||||
};
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = `
|
||||
position: fixed; top: 80px; right: 24px; z-index: 9999;
|
||||
background: ${colors[type]}; color: white;
|
||||
padding: 12px 20px; border-radius: 14px;
|
||||
font-size: 14px; font-weight: 500;
|
||||
box-shadow: 0 12px 32px rgba(0,0,0,0.15);
|
||||
animation: toastIn 0.3s ease-out;
|
||||
max-width: 360px;
|
||||
`;
|
||||
el.textContent = message;
|
||||
document.body.appendChild(el);
|
||||
setTimeout(() => {
|
||||
el.style.animation = 'toastOut 0.3s ease-in forwards';
|
||||
setTimeout(() => el.remove(), 300);
|
||||
}, 2800);
|
||||
}
|
||||
|
||||
/* 注入 toast 动画 */
|
||||
const toastStyle = document.createElement('style');
|
||||
toastStyle.textContent = `
|
||||
@keyframes toastIn { from { opacity: 0; transform: translateX(40px); } to { opacity: 1; transform: translateX(0); } }
|
||||
@keyframes toastOut { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(40px); } }
|
||||
`;
|
||||
document.head.appendChild(toastStyle);
|
||||
Loading…
Reference in New Issue
Block a user