0723 代码整理

This commit is contained in:
wsb1224 2026-07-23 08:52:59 +08:00
parent 01ebaeb57c
commit c32efbaed9
38 changed files with 1903 additions and 275 deletions

109
CLAUDE.md
View File

@ -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 源码清单

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View 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
View 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();
}

View 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

File diff suppressed because one or more lines are too long

View 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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();
};
});
}

View 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();
}

View 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');
};
}

View 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 || '未知错误'));
}
}

View 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';
}
}
}

View 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 解析';
}
}

View 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: '',
});
}

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