修复海报生成过程的BUG

This commit is contained in:
wsb1224 2026-07-30 11:02:26 +08:00
parent 573210f9c3
commit 38a25fbe4a
6 changed files with 177 additions and 41 deletions

2
.gitignore vendored
View File

@ -13,6 +13,8 @@ deploy/
api_storage
api_storage_backup
data_bak
dify-main/docker

View File

@ -211,8 +211,7 @@ def build_scenario_slides(products: list[dict], scenario: str) -> list[dict]:
page_types = ["cover", "company", "narrative", "chart"]
if len(products) > 1:
page_types.append("compare")
kinds = {str(p.get("kind") or "").lower() for p in products}
if len(kinds) > 1:
if scenario != SCENARIO_GENERIC_COMPARE:
page_types.append("synergy")
page_types.extend(["table", "conclusion", "closing"])
return [{"pageType": page_type} for page_type in page_types]

View File

@ -1297,31 +1297,90 @@ def add_slide_alignment_table(prs, deck, colors, meta):
)
def _compare_card_body(product, comparison_product):
"""按险种生成对比卡片正文。CI/IUL 使用与储蓄险不同的指标。"""
kind = str(product.get("kind") or "savings").lower()
policy = product.get("policy") or {}
ap = policy.get("annualPremium", 0)
if kind == "ci":
si = policy.get("sumInsured") or 0
br = product.get("benefitRows") or []
period = int(br[-1].get("policyYear", 0)) if br else 0
return (f"年缴 ${money(ap)}\n"
f"基本保额 ${money(si)}\n"
f"保障期 {period}")
if kind == "iul":
# 优先使用比较契约中已计算的年份数据
yv = (comparison_product or {}).get("yearValues") or {}
g10 = None
ng10 = None
for yr_key in ("10", "20", "30"):
vals = yv.get(yr_key)
if vals:
g10 = vals.get("guaranteedValue")
ng10 = vals.get("totalValue")
break
if g10 is None:
br = product.get("benefitRows") or []
row10 = next((r for r in br if int(r.get("policyYear", 0)) == 10), None)
if row10:
g10 = row10.get("guaranteedCashValue", 0)
ng10 = row10.get("totalSurrenderValue", 0)
return (f"年缴 ${money(ap)}\n"
f"第10年保证 ${money(g10 or 0)}\n"
f"第10年非保证 ${money(ng10 or 0)}")
# 储蓄险(默认)
s = _product_summary(product)
return (f"年缴 ${money(s['annualPremium'])}\n"
f"总投入 ${money(s['totalPremium'])}\n"
f"回本约第 {s['paybackYear'] or '?'}")
def add_slide_compare(prs, deck, colors, meta):
"""对比页:多产品对比,支持 110 份。"""
"""对比页:多产品对比,支持 110 份。按险种显示不同指标。"""
slide = add_blank_slide(prs)
add_bg(slide, colors)
products = deck.get("products", [])
comparison = deck.get("comparison") or {}
comp_products = comparison.get("products") or []
title = meta.get("title", "产品对比")
add_title(slide, title, meta.get("narrativeHint", ""), colors=colors)
if len(products) < 2:
# 单产品时显示保证 vs 非保证对比
product = products[0] if products else {}
kind = str(product.get("kind") or "savings").lower()
s = _product_summary(product)
add_fact_card(slide, Inches(0.8), Inches(1.45), Inches(5.45), Inches(2.5),
"保证部分", f"保证现金价值\n回本约第 {s['paybackYear'] or '?'}\n确定性高",
fill_color=colors["accent_light"], colors=colors)
add_fact_card(slide, Inches(6.95), Inches(1.45), Inches(5.45), Inches(2.5),
"非保证部分", f"归原红利 + 终期分红\n长期弹性空间大\n取决于公司投资表现",
fill_color=colors["good_light"], colors=colors)
if kind == "ci":
add_fact_card(slide, Inches(0.8), Inches(1.45), Inches(5.45), Inches(2.5),
"重疾保障", f"基本保额 ${money(s.get('annualPremium', 0) * 20)}\n保障期内确诊即赔\n多次赔付视条款而定",
fill_color=colors["accent_light"], colors=colors)
add_fact_card(slide, Inches(6.95), Inches(1.45), Inches(5.45), Inches(2.5),
"早期重疾", f"原位癌、早期危疾等\n赔付比例通常 20%25%\n不终止主保单",
fill_color=colors["good_light"], colors=colors)
elif kind == "iul":
add_fact_card(slide, Inches(0.8), Inches(1.45), Inches(5.45), Inches(2.5),
"保证部分", f"保证现金价值\n最低身故保障\n不受投资表现影响",
fill_color=colors["accent_light"], colors=colors)
add_fact_card(slide, Inches(6.95), Inches(1.45), Inches(5.45), Inches(2.5),
"非保证部分", f"指数账户收益\n演示利率仅供参考\n实际取决于市场表现",
fill_color=colors["good_light"], colors=colors)
else:
add_fact_card(slide, Inches(0.8), Inches(1.45), Inches(5.45), Inches(2.5),
"保证部分", f"保证现金价值\n回本约第 {s['paybackYear'] or '?'}\n确定性高",
fill_color=colors["accent_light"], colors=colors)
add_fact_card(slide, Inches(6.95), Inches(1.45), Inches(5.45), Inches(2.5),
"非保证部分", f"归原红利 + 终期分红\n长期弹性空间大\n取决于公司投资表现",
fill_color=colors["good_light"], colors=colors)
add_paragraphs(slide, Inches(0.85), Inches(4.5), Inches(11.4), Inches(1.5),
["保证部分是底线,非保证部分是弹性。两者合计才是长期回报的完整图景。"],
size=15, bullet=True, colors=colors)
elif len(products) <= 4:
# 24 产品:卡片网格布局
card_w = Inches(5.45) if len(products) == 2 else Inches(5.45)
# 24 产品:卡片网格,按险种显示不同指标
card_h = Inches(2.5) if len(products) <= 2 else Inches(2.0)
fill_colors = [colors["accent_light"], colors["good_light"],
colors["accent_light"], colors["good_light"]]
@ -1332,40 +1391,59 @@ def add_slide_compare(prs, deck, colors, meta):
(Inches(6.95), Inches(3.7)),
]
for i, product in enumerate(products):
s = _product_summary(product)
cp = comp_products[i] if i < len(comp_products) else None
body = _compare_card_body(product, cp)
x, y = positions[i]
add_fact_card(slide, x, y, card_w, card_h,
s["productName"] or f"产品 {i + 1}",
f"年缴 ${money(s['annualPremium'])}\n"
f"总投入 ${money(s['totalPremium'])}\n"
f"回本约第 {s['paybackYear'] or '?'}",
name = product.get("productName", "") or f"产品 {i + 1}"
kind_label = _kind_label(product.get("kind", "savings"))
add_fact_card(slide, x, y, Inches(5.45), card_h,
f"{name}{kind_label}", body,
fill_color=fill_colors[i % len(fill_colors)], colors=colors)
bottom_y = Inches(6.0) if len(products) <= 2 else Inches(6.0)
add_paragraphs(slide, Inches(0.85), bottom_y, Inches(11.4), Inches(1.0),
["对比重点在相同保单年度下的保证价值、总退保价值和回本时间。"],
size=15, bullet=True, colors=colors)
else:
# 5+ 产品:表格布局
from pptx.util import Emu
# 5+ 产品:表格布局,按险种调整列
kinds = {str(p.get("kind") or "savings").lower() for p in products}
if len(kinds) == 1 and "ci" in kinds:
headers = ["产品", "年缴保费", "基本保额", "保障期", "缴费年期"]
elif len(kinds) == 1 and "iul" in kinds:
headers = ["产品", "年缴保费", "第10年保证", "第10年非保证", "缴费年期"]
else:
headers = ["产品", "险种", "年缴保费", "回本年", "期末倍数"]
rows = len(products) + 1
cols = 5
cols = len(headers)
table_left, table_top = Inches(0.6), Inches(1.5)
table_w, table_h = Inches(12.0), Inches(0.4 * rows + 0.2)
table_shape = slide.shapes.add_table(rows, cols, table_left, table_top, table_w, table_h)
table = table_shape.table
headers = ["产品", "年缴保费", "总投入", "回本年", "期末倍数"]
for c, h in enumerate(headers):
table.cell(0, c).text = h
_style_cell(table.cell(0, c), bold=True, bg=colors.get("accent", RGBColor(59, 122, 87)))
for r, product in enumerate(products, 1):
kind = str(product.get("kind") or "savings").lower()
policy = product.get("policy") or {}
s = _product_summary(product)
values = [
s["productName"] or f"产品 {r}",
f"${money(s['annualPremium'])}",
f"${money(s['totalPremium'])}",
str(s["paybackYear"] or "-"),
s["multiple"],
]
name = product.get("productName", "") or f"产品 {r}"
if len(kinds) == 1 and "ci" in kinds:
br = product.get("benefitRows") or []
period = int(br[-1].get("policyYear", 0)) if br else 0
values = [name, f"${money(policy.get('annualPremium', 0))}",
f"${money(policy.get('sumInsured', 0))}",
f"{period}", f"{policy.get('payYears', '-')}"]
elif len(kinds) == 1 and "iul" in kinds:
cp = comp_products[r - 1] if (r - 1) < len(comp_products) else None
yv = (cp or {}).get("yearValues") or {}
v10 = yv.get("10") or {}
values = [name, f"${money(policy.get('annualPremium', 0))}",
f"${money(v10.get('guaranteedValue', 0))}",
f"${money(v10.get('totalValue', 0))}",
f"{policy.get('payYears', '-')}"]
else:
values = [name, _kind_label(kind),
f"${money(s['annualPremium'])}",
str(s["paybackYear"] or "-"), s["multiple"]]
for c, v in enumerate(values):
table.cell(r, c).text = v
_style_cell(table.cell(r, c), bold=(c == 0))

View File

@ -134,7 +134,12 @@ export function usePosterWorkspace() {
async function restore(): Promise<boolean> {
const urlRecordId = route.params.recordId as string
if (urlRecordId) {
recordId.value = Number(urlRecordId)
const nextRecordId = Number(urlRecordId)
if (!Number.isFinite(nextRecordId)) return false
if (recordId.value !== nextRecordId) {
recordId.value = nextRecordId
draft.value = createEmptyDraft()
}
}
// 先尝试 localStorage 快恢复
@ -147,9 +152,8 @@ export function usePosterWorkspace() {
loading.value = true
try {
const res: any = await api.get(`/poster/records/${recordId.value}`)
const data = res?.data ?? res
if (data?.code === 0 && data?.data) {
const record = data.data
const record = res?.data ?? res
if (record?.id) {
// 用后端数据填充缺失字段
if (!draft.value.productId && record.productId) {
draft.value.productId = record.productId
@ -166,9 +170,18 @@ export function usePosterWorkspace() {
if (record.copyContent) {
draft.value.copyContent = record.copyContent
}
if (record.aiRawContent) {
draft.value.aiRawContent = record.aiRawContent
}
if (record.extraData?.useMaskedData !== undefined) {
draft.value.useMaskedData = record.extraData.useMaskedData
}
draft.value.taskRecordId = record.id
draft.value.taskStatus = record.taskStatus === 'pending'
? 'queued'
: (record.taskStatus || 'idle')
draft.value.taskProgress = record.taskProgress ?? 0
draft.value.taskError = record.taskError || null
restored.value = true
return true
}

View File

@ -62,9 +62,8 @@ export function usePptWorkspace() {
loading.value = true
try {
const res: any = await api.get(`/ppt/session/${urlSessionId}`)
const data = res?.data ?? res
if (data?.code === 0 && data?.data) {
const session = data.data
const session = res?.data ?? res
if (session?.id) {
sessionId.value = session.id
serverStatus.value = session.status
parseProgress.value = session.parse_progress || 0

View File

@ -84,14 +84,14 @@
<!-- 任务抽屉 -->
<el-drawer v-model="showTasksDrawer" title="任务中心" size="400px">
<TasksPage artifact-type="poster" embedded />
<TasksPage artifact-type="poster" embedded @open-workspace="showTasksDrawer = false" />
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { EditPen } from '@element-plus/icons-vue'
import { usePosterWorkspace } from '@/composables/usePosterWorkspace'
@ -105,6 +105,7 @@ import PosterActionBar from '@/components/poster/workspace/PosterActionBar.vue'
import TasksPage from './TasksPage.vue'
const router = useRouter()
const route = useRoute()
const ws = usePosterWorkspace()
const { isMobile } = useMobile()
@ -315,10 +316,54 @@ async function onReset() {
}
}
//
onMounted(async () => {
//
async function restoreWorkspace() {
await ws.restore()
})
// URL
const d = ws.draft.value
if (d.taskRecordId || ws.recordId.value) {
const pollId = d.taskRecordId || ws.recordId.value!
try {
const res: any = await posterApi.getRecord(pollId)
const record = res?.data ?? res
const realStatus = record?.taskStatus || record?.task_status
d.taskStatus = realStatus === 'pending' ? 'queued' : (realStatus || 'idle')
d.taskProgress = record?.taskProgress ?? record?.task_progress ?? 0
d.taskError = record?.taskError || record?.task_error || null
if (realStatus === 'done') {
try {
const blob = await posterApi.downloadPoster(pollId)
d.posterUrl = URL.createObjectURL(blob)
} catch {
d.taskStatus = 'failed'
d.taskError = '海报图片加载失败,请刷新页面重试'
}
} else if (realStatus !== 'failed') {
startPolling(pollId)
}
} catch {
d.taskStatus = 'failed'
d.taskError = '任务内容恢复失败,请稍后重试'
}
}
}
//
onMounted(restoreWorkspace)
watch(
() => route.params.recordId,
async (nextRecordId, previousRecordId) => {
if (nextRecordId && nextRecordId !== previousRecordId) {
stopPolling()
if (ws.draft.value.posterUrl) {
URL.revokeObjectURL(ws.draft.value.posterUrl)
}
await restoreWorkspace()
}
},
)
onBeforeUnmount(() => {
stopPolling()