feat(ppt): add data review step between parsing and generation

Add a mandatory data verification step so users can inspect and edit
LLM-extracted data before generating the PPT.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wsb1224 2026-07-24 18:22:14 +08:00
parent 1debd7df39
commit 63c7e87ea7
5 changed files with 575 additions and 6 deletions

View File

@ -450,6 +450,61 @@ def validate_extraction(session_id):
})
# ─── 更新提取数据 ─────────────────────────────────────────
@ppt_bp.route("/session/<session_id>/extractions", methods=["PUT"])
@jwt_required
def update_extractions(session_id):
"""保存用户修改后的提取数据。"""
user_id = str(getattr(request, "user_id", "guest"))
session = _get_session(session_id, user_id)
if not session:
return error(ErrorCode.NOT_FOUND, "会话不存在")
data = request.get_json(silent=True) or {}
extractions = data.get("extractions")
if not isinstance(extractions, list):
return error(ErrorCode.PARAM_ERROR, "extractions 必须是数组")
# 合并更新:只更新 data 字段,保留 pdfPath/status 等元信息
existing = json.loads(session.extractions_json) if session.extractions_json else []
existing_map = {e["pdfName"]: e for e in existing}
for ext in extractions:
pdf_name = ext.get("pdfName")
if not pdf_name or pdf_name not in existing_map:
continue
# 更新数据字段
if "data" in ext:
existing_map[pdf_name]["data"] = ext["data"]
if "productName" in ext:
existing_map[pdf_name]["productName"] = ext["productName"]
if "planType" in ext:
existing_map[pdf_name]["planType"] = ext["planType"]
# 重新计算行数
d = existing_map[pdf_name].get("data")
if d:
rows = d.get("benefit_illustration") or d.get("benefitRows") or []
existing_map[pdf_name]["yearCount"] = len(rows)
updated = list(existing_map.values())
session.extractions_json = json.dumps(updated, ensure_ascii=False)
session.status = "parsed" # 回到 parsed 状态,需要重新生成
_save_session(session)
return success({
"sessionId": session_id,
"status": "updated",
"extractions": [{
"pdfName": e["pdfName"],
"planType": e["planType"],
"status": e["status"],
"productName": e["productName"],
"yearCount": e["yearCount"],
} for e in updated],
})
# ─── 公司知识库匹配 ───────────────────────────────────────
@ppt_bp.route("/company-kb/match", methods=["POST"])

View File

@ -4,6 +4,7 @@
<el-steps :active="currentStep" finish-status="success" align-center class="ppt-steps">
<el-step title="上传" description="选择PDF计划书" />
<el-step title="解析" description="AI智能识别" />
<el-step title="校验" description="查看并确认数据" />
<el-step title="生成" description="选择风格并生成" />
<el-step title="结果" description="下载PPT" />
</el-steps>
@ -20,16 +21,22 @@
@parsed="onParsed"
@back="currentStep = 0"
/>
<PptGenerate
<PptDataReview
v-else-if="currentStep === 2"
:session-id="sessionId"
@generated="onGenerated"
@confirmed="onDataConfirmed"
@back="currentStep = 1"
/>
<PptResult
<PptGenerate
v-else-if="currentStep === 3"
:session-id="sessionId"
@regenerate="currentStep = 2"
@generated="onGenerated"
@back="currentStep = 2"
/>
<PptResult
v-else-if="currentStep === 4"
:session-id="sessionId"
@regenerate="currentStep = 3"
@new-session="resetAll"
/>
</div>
@ -40,6 +47,7 @@
import { ref } from 'vue'
import PptUpload from './components/ppt/PptUpload.vue'
import PptParsing from './components/ppt/PptParsing.vue'
import PptDataReview from './components/ppt/PptDataReview.vue'
import PptGenerate from './components/ppt/PptGenerate.vue'
import PptResult from './components/ppt/PptResult.vue'
@ -55,10 +63,14 @@ function onParsed() {
currentStep.value = 2
}
function onGenerated() {
function onDataConfirmed() {
currentStep.value = 3
}
function onGenerated() {
currentStep.value = 4
}
function resetAll() {
currentStep.value = 0
sessionId.value = ''

View File

@ -0,0 +1,497 @@
<template>
<div class="ppt-data-review">
<el-card shadow="never" class="review-card">
<template #header>
<div class="card-header">
<el-icon><DataAnalysis /></el-icon>
<span>数据校验</span>
<el-tag v-if="validationStatus === 'pass'" type="success" size="small" class="header-tag">全部通过</el-tag>
<el-tag v-else-if="validationStatus === 'warn'" type="warning" size="small" class="header-tag">有警告</el-tag>
<el-tag v-else-if="validationStatus === 'error'" type="danger" size="small" class="header-tag">有错误</el-tag>
</div>
</template>
<!-- 加载中 -->
<div v-if="loading" class="loading-state">
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
<p>正在校验数据...</p>
</div>
<template v-else>
<!-- 验证问题列表 -->
<div v-if="issues.length" class="issues-section">
<div
v-for="(issue, idx) in issues"
:key="idx"
class="issue-item"
:class="issue.severity"
>
<el-icon v-if="issue.severity === 'error'" color="#f56c6c"><CircleCloseFilled /></el-icon>
<el-icon v-else color="#e6a23c"><WarningFilled /></el-icon>
<span class="issue-field">{{ issue.field }}</span>
<span class="issue-msg">{{ issue.message }}</span>
</div>
</div>
<!-- 逐个产品展示 -->
<div v-for="(ext, idx) in extractions" :key="idx" class="extraction-block">
<div class="extraction-header">
<el-tag :type="typeTagMap[ext.planType] || 'info'" size="small">
{{ typeNameMap[ext.planType] || ext.planType }}
</el-tag>
<span class="product-name">{{ ext.productName }}</span>
<el-tag v-if="ext.status === 'success' || ext.status === 'cached'" type="success" size="small">解析成功</el-tag>
<el-tag v-else type="danger" size="small">解析失败</el-tag>
</div>
<template v-if="ext.data && (ext.status === 'success' || ext.status === 'cached')">
<!-- 关键指标卡 -->
<div class="metrics-row">
<div class="metric-card" v-for="m in getKeyMetrics(ext)" :key="m.label">
<div class="metric-value">{{ m.value }}</div>
<div class="metric-label">{{ m.label }}</div>
</div>
</div>
<!-- 基本信息可编辑 -->
<el-collapse>
<el-collapse-item title="基本信息(可编辑)" name="basic">
<el-form label-width="120px" size="small" class="basic-form">
<el-form-item label="产品名称">
<el-input v-model="ext.data.product_name" />
</el-form-item>
<el-form-item label="产品类型">
<el-select v-model="ext.data.product_type" style="width: 100%">
<el-option label="储蓄险" value="savings" />
<el-option label="重疾险" value="ci" />
<el-option label="IUL" value="iul" />
</el-select>
</el-form-item>
<el-form-item label="被保人年龄">
<el-input-number v-model="ext.data.insured.age" :min="0" :max="120" />
</el-form-item>
<el-form-item label="被保人性别">
<el-select v-model="ext.data.insured.gender" style="width: 100%">
<el-option label="男" value="male" />
<el-option label="女" value="female" />
</el-select>
</el-form-item>
<el-form-item label="年缴保费">
<el-input-number v-model="ext.data.policy.annual_premium" :min="0" :step="1000" />
</el-form-item>
<el-form-item label="缴费年期">
<el-input-number v-model="ext.data.policy.premium_payment_period" :min="1" :max="50" />
</el-form-item>
<el-form-item v-if="ext.data.policy.sum_insured" label="保额">
<el-input-number v-model="ext.data.policy.sum_insured" :min="0" :step="10000" />
</el-form-item>
</el-form>
</el-collapse-item>
<!-- 利益演示表可编辑 -->
<el-collapse-item :title="`利益演示表(${ext.yearCount} 行,可编辑)`" name="benefit">
<div class="table-wrapper">
<el-table
:data="ext.data.benefit_illustration || []"
stripe
border
size="small"
max-height="400"
style="width: 100%"
>
<el-table-column prop="policy_year" label="保单年度" width="80">
<template #default="{ row }">
<el-input-number v-model="row.policy_year" :min="1" :max="100" size="small" controls-position="right" style="width: 70px" />
</template>
</el-table-column>
<el-table-column prop="age" label="年龄" width="70">
<template #default="{ row }">
<el-input-number v-model="row.age" :min="0" :max="130" size="small" controls-position="right" style="width: 60px" />
</template>
</el-table-column>
<el-table-column prop="total_premium_paid" label="累计保费" min-width="110">
<template #default="{ row }">
<el-input-number v-model="row.total_premium_paid" :min="0" :step="1000" size="small" controls-position="right" style="width: 100px" />
</template>
</el-table-column>
<el-table-column prop="guaranteed_cash_value" label="保证现金价值" min-width="120">
<template #default="{ row }">
<el-input-number v-model="row.guaranteed_cash_value" :min="0" :step="1000" size="small" controls-position="right" style="width: 110px" />
</template>
</el-table-column>
<el-table-column v-if="hasField(ext.data.benefit_illustration, 'reversionary_bonus')" prop="reversionary_bonus" label="归原红利" min-width="110">
<template #default="{ row }">
<el-input-number v-model="row.reversionary_bonus" :min="0" :step="1000" size="small" controls-position="right" style="width: 100px" />
</template>
</el-table-column>
<el-table-column v-if="hasField(ext.data.benefit_illustration, 'terminal_dividend')" prop="terminal_dividend" label="终期红利" min-width="110">
<template #default="{ row }">
<el-input-number v-model="row.terminal_dividend" :min="0" :step="1000" size="small" controls-position="right" style="width: 100px" />
</template>
</el-table-column>
<el-table-column prop="total_surrender_value" label="退保总值" min-width="110">
<template #default="{ row }">
<el-input-number v-model="row.total_surrender_value" :min="0" :step="1000" size="small" controls-position="right" style="width: 100px" />
</template>
</el-table-column>
<el-table-column v-if="hasField(ext.data.benefit_illustration, 'death_benefit')" prop="death_benefit" label="身故赔偿" min-width="110">
<template #default="{ row }">
<el-input-number v-model="row.death_benefit" :min="0" :step="1000" size="small" controls-position="right" style="width: 100px" />
</template>
</el-table-column>
</el-table>
</div>
</el-collapse-item>
<!-- 退保提取表可编辑如果有 -->
<el-collapse-item
v-if="ext.data.withdrawal_illustration && ext.data.withdrawal_illustration.length"
:title="`退保提取表(${ext.data.withdrawal_illustration.length} 行,可编辑)`"
name="withdrawal"
>
<div class="table-wrapper">
<el-table
:data="ext.data.withdrawal_illustration"
stripe
border
size="small"
max-height="300"
style="width: 100%"
>
<el-table-column prop="policy_year" label="保单年度" width="80">
<template #default="{ row }">
<el-input-number v-model="row.policy_year" :min="1" :max="100" size="small" controls-position="right" style="width: 70px" />
</template>
</el-table-column>
<el-table-column prop="withdrawal_amount" label="提取金额" min-width="110">
<template #default="{ row }">
<el-input-number v-model="row.withdrawal_amount" :min="0" :step="1000" size="small" controls-position="right" style="width: 100px" />
</template>
</el-table-column>
<el-table-column v-if="hasField(ext.data.withdrawal_illustration, 'cumulative_withdrawal')" prop="cumulative_withdrawal" label="累计提取" min-width="110">
<template #default="{ row }">
<el-input-number v-model="row.cumulative_withdrawal" :min="0" :step="1000" size="small" controls-position="right" style="width: 100px" />
</template>
</el-table-column>
<el-table-column prop="remaining_surrender_value" label="剩余退保价值" min-width="120">
<template #default="{ row }">
<el-input-number v-model="row.remaining_surrender_value" :min="0" :step="1000" size="small" controls-position="right" style="width: 110px" />
</template>
</el-table-column>
</el-table>
</div>
</el-collapse-item>
</el-collapse>
</template>
<el-empty v-else-if="ext.status === 'error'" :description="ext.error || '解析失败'" />
</div>
<!-- 底部操作 -->
<div class="review-actions">
<el-button @click="$emit('back')">返回上传</el-button>
<el-button v-if="isDirty" @click="handleSave" :loading="saving">
保存修改
</el-button>
<el-button
type="primary"
size="large"
:disabled="validationStatus === 'error'"
@click="handleConfirm"
>
确认数据生成 PPT
</el-button>
</div>
</template>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
DataAnalysis, Loading, CircleCloseFilled, WarningFilled,
} from '@element-plus/icons-vue'
import { pptApi } from '@/utils/ppt-api'
const props = defineProps<{
sessionId: string
}>()
const emit = defineEmits<{
confirmed: []
back: []
}>()
const loading = ref(true)
const saving = ref(false)
const extractions = ref<any[]>([])
const issues = ref<Array<{ field: string; severity: string; message: string }>>([])
const originalJson = ref('')
const typeTagMap: Record<string, string> = {
savings: 'success',
ci: 'warning',
iul: 'info',
}
const typeNameMap: Record<string, string> = {
savings: '储蓄险',
ci: '重疾险',
iul: 'IUL',
}
const validationStatus = computed(() => {
if (issues.value.some(i => i.severity === 'error')) return 'error'
if (issues.value.some(i => i.severity === 'warn')) return 'warn'
return 'pass'
})
const isDirty = computed(() => {
return JSON.stringify(extractions.value) !== originalJson.value
})
onMounted(async () => {
try {
//
const [sessionRes, validateRes]: any[] = await Promise.all([
pptApi.getSession(props.sessionId),
pptApi.validate(props.sessionId),
])
//
const sessionData = sessionRes?.data
if (sessionData?.extractions_json) {
extractions.value = JSON.parse(sessionData.extractions_json)
} else if (sessionData?.extractions) {
extractions.value = sessionData.extractions
}
// data
for (const ext of extractions.value) {
if (ext.data) {
ext.data.insured = ext.data.insured || {}
ext.data.policy = ext.data.policy || {}
}
}
originalJson.value = JSON.stringify(extractions.value)
//
const validateData = validateRes?.data
issues.value = validateData?.issues || []
} catch (e: any) {
ElMessage.error('加载数据失败: ' + (e?.message || '未知错误'))
} finally {
loading.value = false
}
})
function getKeyMetrics(ext: any) {
const d = ext.data
if (!d) return []
const policy = d.policy || {}
const insured = d.insured || {}
const rows = d.benefit_illustration || []
const metrics = [
{ label: '被保人年龄', value: insured.age ? `${insured.age}` : '-' },
{ label: '年缴保费', value: policy.annual_premium ? formatNum(policy.annual_premium) : '-' },
{ label: '缴费年期', value: policy.premium_payment_period ? `${policy.premium_payment_period}` : '-' },
]
//
const annualPremium = Number(policy.annual_premium) || 0
const payYears = Number(policy.premium_payment_period) || 0
if (annualPremium > 0 && payYears > 0 && rows.length > 0) {
const totalInvest = annualPremium * payYears
const breakevenRow = rows.find((r: any) => (Number(r.total_surrender_value) || 0) >= totalInvest)
metrics.push({ label: '总投入', value: formatNum(totalInvest) })
metrics.push({ label: '回本年份', value: breakevenRow ? `${breakevenRow.policy_year}` : '未回本' })
// 20
const row20 = rows.find((r: any) => r.policy_year === 20)
if (row20) {
const sv = Number(row20.total_surrender_value) || 0
const multiple = totalInvest > 0 ? (sv / totalInvest).toFixed(2) : '-'
metrics.push({ label: '20年倍数', value: `${multiple}x` })
}
}
if (rows.length > 0) {
metrics.push({ label: '数据行数', value: `${rows.length}` })
}
return metrics
}
function formatNum(n: number) {
return Number(n).toLocaleString('en-US')
}
function hasField(rows: any[], field: string): boolean {
if (!rows || !rows.length) return false
return rows.some(r => r[field] !== undefined && r[field] !== null)
}
async function handleSave() {
saving.value = true
try {
await pptApi.updateExtractions(props.sessionId, extractions.value)
originalJson.value = JSON.stringify(extractions.value)
ElMessage.success('数据已保存')
//
const validateRes: any = await pptApi.validate(props.sessionId)
issues.value = validateRes?.data?.issues || []
} catch (e: any) {
ElMessage.error('保存失败: ' + (e?.message || '未知错误'))
} finally {
saving.value = false
}
}
function handleConfirm() {
if (isDirty.value) {
//
handleSave().then(() => emit('confirmed'))
} else {
emit('confirmed')
}
}
</script>
<style scoped>
.ppt-data-review {
width: 100%;
max-width: 900px;
}
.review-card {
width: 100%;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
}
.header-tag {
margin-left: auto;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 60px 0;
color: #909399;
}
/* 验证问题 */
.issues-section {
margin-bottom: 20px;
padding: 12px;
background: #fdf6ec;
border-radius: 8px;
border: 1px solid #faecd8;
}
.issue-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
font-size: 13px;
}
.issue-item.error {
color: #f56c6c;
}
.issue-item .issue-field {
font-weight: 600;
min-width: 120px;
}
.issue-item .issue-msg {
flex: 1;
}
/* 产品块 */
.extraction-block {
margin-bottom: 20px;
padding: 16px;
border: 1px solid #ebeef5;
border-radius: 8px;
background: #fafafa;
}
.extraction-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.product-name {
font-size: 16px;
font-weight: 600;
flex: 1;
}
/* 指标卡 */
.metrics-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.metric-card {
flex: 1;
min-width: 100px;
padding: 12px;
background: #fff;
border-radius: 8px;
border: 1px solid #ebeef5;
text-align: center;
}
.metric-value {
font-size: 18px;
font-weight: 700;
color: #303133;
}
.metric-label {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
/* 表格 */
.table-wrapper {
overflow-x: auto;
}
/* 基本信息表单 */
.basic-form {
max-width: 500px;
}
/* 底部操作 */
.review-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #ebeef5;
}
</style>

View File

@ -35,7 +35,7 @@
>
<template #extra>
<el-button @click="$emit('back')">返回上传</el-button>
<el-button v-if="hasSuccess" type="primary" @click="$emit('parsed')">下一步生成 PPT</el-button>
<el-button v-if="hasSuccess" type="primary" @click="$emit('parsed')">下一步校验数据</el-button>
</template>
</el-result>

View File

@ -81,6 +81,11 @@ export const pptApi = {
return api.get(`/ppt/validate/${sessionId}`)
},
/** 保存用户修改后的提取数据 */
updateExtractions(sessionId: string, extractions: any[]) {
return api.put(`/ppt/session/${sessionId}/extractions`, { extractions })
},
/** 获取渲染选项 */
getRenderOptions() {
return api.get('/ppt/render-options')