feat(ui): redesign PPT/poster generation pages for trust and delivery

PPT data review: 3-layer structure — summary bar with trust indicator
and key metrics, sorted issues checklist, collapsible detail sections.
Error state disables primary action with explanation.

Poster template: left-right layout with size selection moved upfront,
template grid with scene tags and preview, AI style presets, compliance
notice for AI copy, reference image moved to advanced settings.

PPT result: product summary chips, pre-download checklist, delivery
confirmation feel instead of bare download page.

Poster preview: fix duplicate size options, add polling timeout (3 min),
show delivery metadata (size, generation mode) on success.

PosterPage: pass size from template step to preview step, widen to 1180px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wsb1224 2026-07-27 14:24:20 +08:00
parent e3479f0546
commit 8a5d13fcde
5 changed files with 1157 additions and 434 deletions

View File

@ -1,16 +1,12 @@
<template>
<div class="step-preview">
<h3>预览导出</h3>
<!-- 等待生成 -->
<div v-if="taskStatus === 'idle'" class="generate-section">
<el-select v-model="selectedSize" style="width: 200px; margin-bottom: 16px">
<el-option label="竖版海报 1080x1920" value="1024x1792" />
<el-option label="横版海报 900x500" value="1792x1024" />
<el-option label="正方形 1080x1080" value="1024x1024" />
<el-option label="通用竖版 800x1200" value="1024x1792" />
</el-select>
<br />
<div class="generate-prompt">
<el-icon :size="48" color="#c0c4cc"><Picture /></el-icon>
<p class="prompt-text">点击下方按钮开始生成海报</p>
<p class="prompt-meta">尺寸{{ sizeLabel }} · 生成约需 10-30 </p>
</div>
<el-button type="primary" size="large" @click="onGenerate">
<el-icon><Picture /></el-icon>
</el-button>
@ -19,7 +15,8 @@
<!-- 生成中异步轮询 -->
<div v-else-if="taskStatus === 'queued' || taskStatus === 'generating'" class="generate-section">
<el-progress :percentage="taskProgress" :stroke-width="12" style="max-width: 400px; margin: 0 auto 16px" />
<p style="color: #909399">{{ taskStatus === 'queued' ? '任务排队中...' : '海报生成中(约 10-30 秒)...' }}</p>
<p class="status-text">{{ taskStatus === 'queued' ? '任务排队中...' : '海报生成中(约 10-30 秒)...' }}</p>
<p v-if="pollCount > 30" class="timeout-hint">生成时间较长请耐心等待或稍后查看历史记录</p>
</div>
<!-- 生成失败 -->
@ -31,25 +28,47 @@
</el-result>
</div>
<!-- 生成成功 -->
<!-- 生成成功交付确认 -->
<div v-else-if="taskStatus === 'done' && posterObjectUrl" class="result-section">
<!-- 大图预览居中 -->
<div class="poster-preview">
<el-image :src="posterObjectUrl" fit="contain" style="max-width: 100%; max-height: 500px; border-radius: 8px" />
<div class="result-actions">
<el-button type="primary" @click="onDownload">
<el-icon><Download /></el-icon>
</el-button>
<el-button @click="clearPoster">重新生成</el-button>
</div>
<!-- 交付元信息 -->
<div class="delivery-meta">
<div class="meta-row">
<span class="meta-label">尺寸</span>
<span class="meta-value">{{ sizeLabel }}</span>
</div>
<div class="meta-row">
<span class="meta-label">生成方式</span>
<span class="meta-value">{{ generationMode || 'AI 生图' }}</span>
</div>
</div>
<div class="step-actions">
<!-- 下载按钮唯一主按钮 -->
<div class="download-area">
<el-button type="primary" size="large" @click="onDownload" class="download-btn">
<el-icon><Download /></el-icon>
</el-button>
</div>
<!-- 次要操作 -->
<div class="secondary-actions">
<el-button plain @click="$emit('back')">返回修改文案</el-button>
<el-button plain @click="clearPoster">重新生成</el-button>
</div>
</div>
<div v-if="taskStatus === 'idle'" class="step-actions">
<el-button @click="$emit('back')">上一步</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onBeforeUnmount } from 'vue'
import { ref, computed, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { Picture, Download } from '@element-plus/icons-vue'
import { posterApi } from '@/utils/poster-api'
@ -60,17 +79,28 @@ const props = defineProps<{
templateId: number | null
copyContent: any
aiRawContent?: any
size?: string
}>()
defineEmits<{ back: [] }>()
const selectedSize = ref('1024x1792')
const taskStatus = ref<'idle' | 'queued' | 'generating' | 'done' | 'failed'>('idle')
const taskProgress = ref(0)
const taskError = ref<string | null>(null)
const posterObjectUrl = ref<string | null>(null)
const recordId = ref<number | null>(null)
const generationMode = ref<string | null>(null)
const pollCount = ref(0)
let pollTimer: ReturnType<typeof setInterval> | null = null
const MAX_POLL_RETRIES = 90 // 90 = 3
const sizeLabelMap: Record<string, string> = {
'1024x1792': '竖版 1080×1920',
'1792x1024': '横版 900×500',
'1024x1024': '方图 1080×1080',
}
const sizeLabel = computed(() => sizeLabelMap[props.size || ''] || props.size || '1024x1792')
function clearPoster() {
stopPolling()
if (posterObjectUrl.value) {
@ -81,6 +111,8 @@ function clearPoster() {
taskStatus.value = 'idle'
taskProgress.value = 0
taskError.value = null
generationMode.value = null
pollCount.value = 0
}
function stopPolling() {
@ -94,17 +126,16 @@ async function onGenerate() {
taskStatus.value = 'queued'
taskProgress.value = 0
taskError.value = null
pollCount.value = 0
try {
const res: any = await posterApi.generatePoster({
caseUploadId: props.caseUploadId ?? undefined,
productId: props.productId,
templateId: props.templateId ?? undefined,
copyContent: props.copyContent,
aiRawContent: props.aiRawContent,
size: selectedSize.value,
size: props.size || '1024x1792',
copyMode: 'ai',
})
// Axios {code: 0, data: {id, taskStatus, ...}}
} as any)
const record = res?.data
recordId.value = record?.id
if (record?.id) {
@ -118,17 +149,26 @@ async function onGenerate() {
function startPolling(id: number) {
stopPolling()
pollCount.value = 0
pollTimer = setInterval(async () => {
pollCount.value++
//
if (pollCount.value > MAX_POLL_RETRIES) {
stopPolling()
taskStatus.value = 'failed'
taskError.value = '生成超时,请查看历史记录或重新生成'
return
}
try {
const res: any = await posterApi.getRecord(id)
const data = res?.data
taskStatus.value = data?.taskStatus || 'generating'
taskProgress.value = data?.taskProgress || 0
taskError.value = data?.taskError || null
generationMode.value = data?.generationMode || null
if (taskStatus.value === 'done') {
stopPolling()
// Blob
try {
const blob = await posterApi.downloadPoster(id)
posterObjectUrl.value = URL.createObjectURL(blob)
@ -169,8 +209,98 @@ onBeforeUnmount(() => {
</script>
<style scoped>
.step-actions { margin-top: 24px; text-align: right; }
.generate-section { text-align: center; padding: 40px; }
.result-section { text-align: center; }
.result-actions { margin-top: 16px; display: flex; justify-content: center; gap: 12px; }
.step-preview {
width: 100%;
max-width: 700px;
}
.generate-section {
text-align: center;
padding: 40px 20px;
}
.generate-prompt {
margin-bottom: 24px;
}
.prompt-text {
font-size: 16px;
color: #606266;
margin-top: 12px;
}
.prompt-meta {
font-size: 13px;
color: #909399;
margin-top: 4px;
}
.status-text {
color: #909399;
font-size: 14px;
}
.timeout-hint {
color: #e6a23c;
font-size: 13px;
margin-top: 8px;
}
.result-section {
text-align: center;
}
.poster-preview {
margin-bottom: 16px;
padding: 16px;
background: #f5f7fa;
border-radius: 8px;
}
.delivery-meta {
display: flex;
justify-content: center;
gap: 24px;
margin-bottom: 20px;
padding: 12px 16px;
background: #f0f9eb;
border: 1px solid #e1f3d8;
border-radius: 8px;
}
.meta-row {
display: flex;
align-items: center;
gap: 6px;
}
.meta-label {
font-size: 13px;
color: #909399;
}
.meta-value {
font-size: 13px;
font-weight: 600;
color: #303133;
}
.download-area {
margin-bottom: 12px;
}
.download-btn {
min-width: 180px;
}
.secondary-actions {
display: flex;
justify-content: center;
gap: 12px;
}
.step-actions {
margin-top: 24px;
text-align: right;
}
@media (max-width: 768px) {
.delivery-meta {
flex-direction: column;
gap: 8px;
align-items: center;
}
}
</style>

View File

@ -1,53 +1,103 @@
<template>
<div class="step-template">
<h3>选择模板 + 文案</h3>
<!-- 尺寸选择前置 -->
<div class="size-bar">
<span class="size-label">海报尺寸</span>
<el-radio-group v-model="selectedSize" size="default">
<el-radio-button value="1024x1792">竖版 1080×1920</el-radio-button>
<el-radio-button value="1792x1024">横版 900×500</el-radio-button>
<el-radio-button value="1024x1024">方图 1080×1080</el-radio-button>
</el-radio-group>
</div>
<!-- 海报模板 -->
<h4>海报模板</h4>
<div class="template-grid">
<div class="template-layout">
<!-- 左侧模板选择 -->
<div class="left-panel">
<div class="panel-title">选择模板</div>
<div v-if="!templates.length" class="empty-hint">
<el-empty description="暂无模板" :image-size="60" />
</div>
<div class="template-grid" v-else>
<div v-for="t in templates" :key="t.id"
class="template-card" :class="{ active: selectedTemplateId === t.id }"
@click="selectedTemplateId = t.id">
<el-image v-if="t.previewImage" :src="t.previewImage" style="width: 100%; height: 120px" fit="cover" />
<div v-else class="template-placeholder" :style="{ background: t.colorScheme?.primary || '#1a1a2e' }">
<span style="color: white; font-size: 24px">🎨</span>
@click="selectedTemplateId = t.id" role="button" tabindex="0"
@keydown.enter="selectedTemplateId = t.id">
<el-image v-if="t.previewImage" :src="t.previewImage" style="width: 100%; aspect-ratio: 3/4" fit="cover" />
<div v-else class="template-placeholder" :style="{ background: t.colorScheme?.primary || '#1a3a5c' }">
<el-icon :size="28" color="rgba(255,255,255,0.6)"><Picture /></el-icon>
</div>
<div class="template-info">
<div class="template-name">{{ t.name }}</div>
<div class="template-style">{{ t.styleDescription?.substring(0, 50) }}...</div>
<div class="template-tags">
<el-tag v-if="t.scenarioTag" size="small" type="info" effect="plain">{{ sceneLabel(t.scenarioTag) }}</el-tag>
</div>
</div>
</div>
</div>
<!-- 文案模式 -->
<h4>文案生成</h4>
<el-radio-group v-model="copyMode" style="margin-bottom: 16px">
<el-radio-button value="template">模板模式</el-radio-button>
<!-- 选中模板预览 -->
<div v-if="selectedTemplate" class="selected-preview">
<div class="preview-label">当前模板</div>
<div class="preview-card">
<el-image v-if="selectedTemplate.previewImage" :src="selectedTemplate.previewImage" fit="cover" style="width: 100%; aspect-ratio: 3/4; border-radius: 8px" />
<div v-else class="preview-placeholder" :style="{ background: selectedTemplate.colorScheme?.primary || '#1a3a5c' }">
<el-icon :size="40" color="rgba(255,255,255,0.5)"><Picture /></el-icon>
</div>
<div class="preview-meta">
<div class="preview-name">{{ selectedTemplate.name }}</div>
<div class="preview-desc">{{ selectedTemplate.styleDescription }}</div>
</div>
</div>
</div>
</div>
<!-- 右侧文案编辑 -->
<div class="right-panel">
<div class="panel-title">编辑文案</div>
<!-- 文案模式切换 -->
<div class="copy-mode-bar">
<el-radio-group v-model="copyMode" size="default">
<el-radio-button value="template">模板文案</el-radio-button>
<el-radio-button value="ai">AI 生成</el-radio-button>
</el-radio-group>
</div>
<!-- 模板模式 -->
<div v-if="copyMode === 'template'" class="copy-template-section">
<el-select v-model="selectedCopyTemplateId" placeholder="选择文案模板" style="width: 100%; margin-bottom: 12px">
<el-option v-for="ct in copyTemplates" :key="ct.id" :label="ct.name" :value="ct.id" />
</el-select>
<div v-if="templatePreview" class="copy-preview">
<el-input v-model="templatePreview" type="textarea" :rows="4" readonly />
<el-input v-model="templatePreview" type="textarea" :rows="3" readonly />
</div>
<el-button type="primary" @click="onGenerateCopy" :loading="generatingCopy" :disabled="!selectedCopyTemplateId">
填充文案
</el-button>
</div>
<!-- AI 模式 -->
<div v-else class="copy-ai-section">
<el-input v-model="aiStyle" placeholder="风格描述(如:专业、温暖、高端)" style="margin-bottom: 12px" />
<div class="ai-style-presets">
<span class="preset-label">风格选择</span>
<div class="preset-chips">
<el-check-tag v-for="s in aiStylePresets" :key="s.value"
:checked="aiStyle === s.value"
@change="aiStyle = s.value">
{{ s.label }}
</el-check-tag>
</div>
</div>
<el-input v-model="aiStyle" placeholder="自定义风格描述" style="margin-bottom: 12px" />
<el-button type="primary" @click="onGenerateCopy" :loading="generatingCopy">
AI 生成文案
</el-button>
</div>
<!-- 生成结果 -->
<div v-if="copyResult" class="copy-result">
<el-divider>生成的文案</el-divider>
<el-form label-width="80px">
<el-form label-width="80px" size="default">
<el-form-item label="标题">
<el-input v-model="copyResult.headline" />
</el-form-item>
@ -58,41 +108,82 @@
<el-input v-model="copyResult.call_to_action" />
</el-form-item>
</el-form>
<div v-if="copyMode === 'ai'" class="compliance-notice">
<el-icon><InfoFilled /></el-icon>
AI 文案需人工确认后导出请核对产品名称金额等关键信息
</div>
</div>
<!-- 参考图 -->
<h4>参考图可选</h4>
<el-input v-model="referenceImage" placeholder="参考图 URL" />
<!-- 高级设置默认折叠 -->
<el-collapse style="margin-top: 16px">
<el-collapse-item title="高级设置" name="advanced">
<el-form label-width="80px" size="small">
<el-form-item label="参考图">
<el-input v-model="referenceImage" placeholder="参考图 URL可选" />
</el-form-item>
</el-form>
</el-collapse-item>
</el-collapse>
</div>
</div>
<!-- 底部操作 -->
<div class="step-actions">
<el-button @click="$emit('back')">上一步</el-button>
<el-button type="primary" :disabled="!selectedTemplateId || !copyResult" @click="onNext">
下一步
<el-button type="primary" size="large" :disabled="!selectedTemplateId || !copyResult" @click="onNext">
下一步预览导出
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Picture, InfoFilled } from '@element-plus/icons-vue'
import { posterApi } from '@/utils/poster-api'
const props = defineProps<{ caseUploadId: number | null; productId: string }>()
const emit = defineEmits<{ next: [data: { templateId: number; copyContent: any; aiRawContent: any }]; back: [] }>()
const emit = defineEmits<{ next: [data: { templateId: number; copyContent: any; aiRawContent: any; size: string }]; back: [] }>()
const templates = ref<any[]>([])
const copyTemplates = ref<any[]>([])
const selectedTemplateId = ref<number | null>(null)
const selectedSize = ref('1024x1792')
const copyMode = ref('template')
const selectedCopyTemplateId = ref<number | null>(null)
const templatePreview = ref('')
const aiStyle = ref('专业')
const aiStyle = ref('专业稳健')
const generatingCopy = ref(false)
const copyResult = ref<any>(null)
const aiRawContent = ref<any>(null)
const referenceImage = ref('')
const aiStylePresets = [
{ label: '专业稳健', value: '专业稳健' },
{ label: '温暖顾问', value: '温暖顾问' },
{ label: '高端资产配置', value: '高端资产配置' },
{ label: '简洁朋友圈', value: '简洁朋友圈' },
]
const sceneLabelMap: Record<string, string> = {
business: '商务',
family: '家庭',
savings: '储蓄',
ci: '重疾',
protection: '保障',
retirement: '养老',
education: '教育',
}
function sceneLabel(tag: string): string {
return sceneLabelMap[tag] || tag
}
const selectedTemplate = computed(() => {
return templates.value.find((t: any) => t.id === selectedTemplateId.value) || null
})
onMounted(async () => {
const [tRes, ctRes]: any[] = await Promise.all([posterApi.getTemplates(), posterApi.getCopyTemplates()])
templates.value = tRes?.data?.data ?? tRes?.data ?? []
@ -115,7 +206,6 @@ async function onGenerateCopy() {
style: aiStyle.value,
})
copyResult.value = res?.data?.data?.copy ?? res?.data?.copy
// AI
if (copyMode.value === 'ai') {
aiRawContent.value = { ...copyResult.value }
}
@ -127,20 +217,201 @@ async function onGenerateCopy() {
}
function onNext() {
emit('next', { templateId: selectedTemplateId.value!, copyContent: copyResult.value, aiRawContent: aiRawContent.value })
emit('next', {
templateId: selectedTemplateId.value!,
copyContent: copyResult.value,
aiRawContent: aiRawContent.value,
size: selectedSize.value,
})
}
</script>
<style scoped>
.step-actions { margin-top: 24px; text-align: right; }
.template-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; margin-bottom: 20px; }
.template-card { border: 2px solid #e4e7ed; border-radius: 8px; cursor: pointer; overflow: hidden; transition: all 0.2s; }
.template-card:hover { border-color: #409eff; }
.template-card.active { border-color: #409eff; background: #ecf5ff; }
.template-placeholder { height: 120px; display: flex; align-items: center; justify-content: center; }
.template-info { padding: 8px; }
.template-name { font-weight: 500; font-size: 14px; }
.template-style { font-size: 12px; color: #909399; }
.copy-preview { margin-bottom: 12px; }
.copy-result { margin-top: 16px; }
.step-template {
width: 100%;
max-width: 1180px;
}
.size-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
padding: 12px 16px;
background: #f5f7fa;
border-radius: 8px;
}
.size-label {
font-size: 14px;
font-weight: 600;
color: #303133;
flex-shrink: 0;
}
.template-layout {
display: flex;
gap: 24px;
align-items: flex-start;
}
.left-panel {
flex: 0 0 380px;
min-width: 0;
}
.right-panel {
flex: 1;
min-width: 0;
}
.panel-title {
font-size: 15px;
font-weight: 600;
color: #303133;
margin-bottom: 12px;
}
.template-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.template-card {
border: 2px solid #e4e7ed;
border-radius: 8px;
cursor: pointer;
overflow: hidden;
transition: border-color 0.2s;
}
.template-card:hover {
border-color: #409eff;
}
.template-card.active {
border-color: #409eff;
box-shadow: 0 0 0 1px #409eff;
}
.template-placeholder {
aspect-ratio: 3/4;
display: flex;
align-items: center;
justify-content: center;
}
.template-info {
padding: 8px 10px;
}
.template-name {
font-weight: 500;
font-size: 13px;
color: #303133;
}
.template-tags {
margin-top: 4px;
}
.selected-preview {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #ebeef5;
}
.preview-label {
font-size: 13px;
color: #909399;
margin-bottom: 8px;
}
.preview-card {
background: #f5f7fa;
border-radius: 8px;
padding: 12px;
}
.preview-placeholder {
width: 100%;
aspect-ratio: 3/4;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.preview-meta {
margin-top: 8px;
}
.preview-name {
font-weight: 600;
font-size: 14px;
color: #303133;
}
.preview-desc {
font-size: 12px;
color: #909399;
margin-top: 4px;
line-height: 1.5;
}
.copy-mode-bar {
margin-bottom: 16px;
}
.ai-style-presets {
margin-bottom: 12px;
}
.preset-label {
font-size: 13px;
color: #606266;
margin-bottom: 8px;
display: block;
}
.preset-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.copy-preview {
margin-bottom: 12px;
}
.copy-result {
margin-top: 16px;
}
.compliance-notice {
display: flex;
align-items: center;
gap: 6px;
margin-top: 12px;
padding: 8px 12px;
background: #fdf6ec;
border: 1px solid #faecd8;
border-radius: 6px;
font-size: 13px;
color: #e6a23c;
}
.empty-hint {
padding: 20px 0;
}
.step-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #ebeef5;
}
@media (max-width: 768px) {
.template-layout {
flex-direction: column;
}
.left-panel {
flex: none;
width: 100%;
}
.template-grid {
grid-template-columns: repeat(2, 1fr);
}
.size-bar {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
</style>

View File

@ -11,7 +11,7 @@
<PosterStepProduct v-if="currentStep === 0" @next="onStep1Next" />
<PosterStepUpload v-else-if="currentStep === 1" :product-id="selectedProductId" @next="onStep2Next" @back="currentStep--" />
<PosterStepTemplate v-else-if="currentStep === 2" :case-upload-id="caseUploadId" :product-id="selectedProductId" @next="onStep3Next" @back="currentStep--" />
<PosterStepPreview v-else-if="currentStep === 3" :case-upload-id="caseUploadId" :product-id="selectedProductId" :template-id="selectedTemplateId" :copy-content="copyContent" :ai-raw-content="aiRawContent" @back="currentStep--" />
<PosterStepPreview v-else-if="currentStep === 3" :case-upload-id="caseUploadId" :product-id="selectedProductId" :template-id="selectedTemplateId" :copy-content="copyContent" :ai-raw-content="aiRawContent" :size="selectedSize" @back="currentStep--" />
</div>
</div>
</template>
@ -29,6 +29,7 @@ const caseUploadId = ref<number | null>(null)
const selectedTemplateId = ref<number | null>(null)
const copyContent = ref<any>(null)
const aiRawContent = ref<any>(null)
const selectedSize = ref('1024x1792')
function onStep1Next(productId: string) {
selectedProductId.value = productId
@ -40,10 +41,11 @@ function onStep2Next(id: number) {
currentStep.value++
}
function onStep3Next(data: { templateId: number; copyContent: any; aiRawContent: any }) {
function onStep3Next(data: { templateId: number; copyContent: any; aiRawContent: any; size?: string }) {
selectedTemplateId.value = data.templateId
copyContent.value = data.copyContent
aiRawContent.value = data.aiRawContent
if (data.size) selectedSize.value = data.size
currentStep.value++
}
</script>
@ -51,7 +53,7 @@ function onStep3Next(data: { templateId: number; copyContent: any; aiRawContent:
<style scoped>
.poster-page {
padding: 20px;
max-width: 900px;
max-width: 1180px;
margin: 0 auto;
}
.poster-steps {

View File

@ -1,16 +1,5 @@
<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>
@ -18,10 +7,65 @@
</div>
<template v-else>
<!-- 验证问题列表 -->
<!--
第一层确认摘要 用户 5 秒内判断"能不能继续"
-->
<div class="summary-bar" :class="validationStatus">
<div class="summary-status">
<el-icon v-if="validationStatus === 'pass'" :size="20" color="#67c23a"><SuccessFilled /></el-icon>
<el-icon v-else-if="validationStatus === 'warn'" :size="20" color="#e6a23c"><WarningFilled /></el-icon>
<el-icon v-else :size="20" color="#f56c6c"><CircleCloseFilled /></el-icon>
<span class="summary-status-text">{{ statusText }}</span>
</div>
<!-- 汇总关键指标跨所有产品 -->
<div class="summary-metrics" v-if="summaryMetrics.length">
<div class="summary-metric" v-for="m in summaryMetrics" :key="m.label">
<span class="summary-metric-value">{{ m.value }}</span>
<span class="summary-metric-label">{{ m.label }}</span>
</div>
</div>
<!-- 主操作按钮组 -->
<div class="summary-actions">
<el-button @click="$emit('back')" plain>返回上传</el-button>
<el-button v-if="isDirty" @click="handleSave" :loading="saving" plain>
保存草稿
</el-button>
<el-button
type="primary"
size="large"
:disabled="validationStatus === 'error'"
:loading="saving"
@click="handleConfirm"
>
<el-icon v-if="validationStatus === 'error'" style="margin-right: 4px"><Lock /></el-icon>
确认数据生成 PPT
</el-button>
</div>
<!-- 错误禁用提示 -->
<div v-if="validationStatus === 'error'" class="summary-block-reason">
<el-icon><InfoFilled /></el-icon>
请先修正下方错误项再继续生成
</div>
<div v-else-if="validationStatus === 'warn' && isDirty" class="summary-block-reason warn">
<el-icon><InfoFilled /></el-icon>
有未保存的修改请先保存或直接确认
</div>
</div>
<!--
第二层问题清单 错误优先警告其次
-->
<div v-if="issues.length" class="issues-section">
<div class="issues-header">
<span class="issues-title">问题清单</span>
<el-tag v-if="errorCount > 0" type="danger" size="small">{{ errorCount }} 错误</el-tag>
<el-tag v-if="warnCount > 0" type="warning" size="small">{{ warnCount }} 警告</el-tag>
</div>
<div
v-for="(issue, idx) in issues"
v-for="(issue, idx) in sortedIssues"
:key="idx"
class="issue-item"
:class="issue.severity"
@ -33,7 +77,9 @@
</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">
@ -52,8 +98,8 @@
</div>
</div>
<!-- 基本信息可编辑 -->
<el-collapse>
<!-- 基本信息可编辑默认展开 -->
<el-collapse v-model="activeCollapse[ext.productName || idx]">
<el-collapse-item title="基本信息(可编辑)" name="basic">
<el-form label-width="120px" size="small" class="basic-form">
<el-form-item label="产品名称">
@ -87,7 +133,7 @@
</el-form>
</el-collapse-item>
<!-- 利益演示表可编辑 -->
<!-- 利益演示表可编辑默认折叠 -->
<el-collapse-item :title="`利益演示表(${getBenefitRows(ext).length} 行,可编辑)`" name="benefit">
<div class="table-wrapper">
<div class="table-actions">
@ -182,7 +228,7 @@
</div>
</el-collapse-item>
<!-- 退保提取表可编辑 -->
<!-- 退保提取表可编辑默认折叠 -->
<el-collapse-item
:title="`退保提取表(${getWithdrawalRows(ext).length} 行,可编辑)`"
name="withdrawal"
@ -244,24 +290,7 @@
<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>
@ -270,6 +299,7 @@ import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
DataAnalysis, Loading, CircleCloseFilled, WarningFilled,
SuccessFilled, Lock, InfoFilled,
} from '@element-plus/icons-vue'
import { pptApi } from '@/utils/ppt-api'
@ -309,6 +339,55 @@ const isDirty = computed(() => {
return JSON.stringify(extractions.value) !== originalJson.value
})
const errorCount = computed(() => issues.value.filter(i => i.severity === 'error').length)
const warnCount = computed(() => issues.value.filter(i => i.severity === 'warn').length)
const statusText = computed(() => {
if (validationStatus.value === 'pass') return '解析通过,数据完整'
if (validationStatus.value === 'warn') return `${warnCount.value} 项警告需确认`
return `${errorCount.value} 项错误,需修正后才能生成`
})
const sortedIssues = computed(() => {
return [...issues.value].sort((a, b) => {
if (a.severity === 'error' && b.severity !== 'error') return -1
if (a.severity !== 'error' && b.severity === 'error') return 1
return 0
})
})
//
const summaryMetrics = computed(() => {
if (!extractions.value.length) return []
const first = extractions.value[0]
if (!first?.data) return []
const d = first.data
const policy = d.policy || {}
const insured = d.insured || {}
const rows = d.benefit_illustration || []
const metrics: Array<{ label: string; value: string }> = []
if (first.productName) metrics.push({ label: '产品', value: first.productName })
if (insured.age) metrics.push({ label: '被保人年龄', value: `${insured.age}` })
if (policy.annual_premium) metrics.push({ label: '年缴保费', value: formatNum(policy.annual_premium) })
if (policy.premium_payment_period) metrics.push({ label: '缴费年期', value: `${policy.premium_payment_period}` })
const annualPremium = Number(policy.annual_premium) || 0
const payYears = Number(policy.premium_payment_period) || 0
if (annualPremium > 0 && payYears > 0) {
metrics.push({ label: '总投入', value: formatNum(annualPremium * payYears) })
}
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)
if (breakevenRow) metrics.push({ label: '回本年份', value: `${breakevenRow.policy_year}` })
}
return metrics
})
//
const activeCollapse = ref<Record<string, string[]>>({})
onMounted(async () => {
try {
//
@ -338,6 +417,12 @@ onMounted(async () => {
originalJson.value = JSON.stringify(extractions.value)
//
for (const ext of extractions.value) {
const key = ext.productName || String(extractions.value.indexOf(ext))
activeCollapse.value[key] = ['basic']
}
//
const validateData = validateRes?.data
issues.value = validateData?.issues || []
@ -541,23 +626,7 @@ async function handleConfirm() {
<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;
max-width: 1040px;
}
.loading-state {
@ -569,43 +638,140 @@ async function handleConfirm() {
color: #909399;
}
/* 验证问题 */
.issues-section {
margin-bottom: 20px;
padding: 12px;
background: #fdf6ec;
/* ═══ 第一层:确认摘要 ═══ */
.summary-bar {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
border: 1px solid #faecd8;
padding: 20px 24px;
margin-bottom: 16px;
}
.summary-bar.error {
border-color: #fde2e2;
background: #fef0f0;
}
.summary-bar.warn {
border-color: #faecd8;
background: #fdf6ec;
}
.summary-bar.pass {
border-color: #e1f3d8;
background: #f0f9eb;
}
.summary-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
margin-bottom: 16px;
}
.summary-status-text {
color: #303133;
}
.summary-metrics {
display: flex;
flex-wrap: wrap;
gap: 16px 24px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid #ebeef5;
}
.summary-metric {
display: flex;
flex-direction: column;
gap: 2px;
}
.summary-metric-value {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.summary-metric-label {
font-size: 12px;
color: #909399;
}
.summary-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.summary-actions .el-button:last-child {
margin-left: auto;
}
.summary-block-reason {
display: flex;
align-items: center;
gap: 6px;
margin-top: 12px;
font-size: 13px;
color: #f56c6c;
}
.summary-block-reason.warn {
color: #e6a23c;
}
/* ═══ 第二层:问题清单 ═══ */
.issues-section {
margin-bottom: 16px;
padding: 16px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
}
.issues-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.issues-title {
font-size: 14px;
font-weight: 600;
color: #303133;
flex: 1;
}
.issue-item {
display: flex;
align-items: center;
align-items: flex-start;
gap: 8px;
padding: 6px 0;
padding: 8px 0;
font-size: 13px;
border-bottom: 1px solid #f2f3f5;
}
.issue-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.issue-item.error {
color: #f56c6c;
}
.issue-item .issue-field {
font-weight: 600;
min-width: 120px;
flex-shrink: 0;
}
.issue-item .issue-msg {
flex: 1;
color: #606266;
}
.issue-item.error .issue-msg {
color: #f56c6c;
}
/* 产品块 */
/* ═══ 第三层:高级明细 ═══ */
.extraction-block {
margin-bottom: 20px;
margin-bottom: 16px;
padding: 16px;
border: 1px solid #ebeef5;
border-radius: 8px;
background: #fafafa;
background: #fff;
}
.extraction-header {
@ -616,9 +782,10 @@ async function handleConfirm() {
}
.product-name {
font-size: 16px;
font-size: 15px;
font-weight: 600;
flex: 1;
color: #303133;
}
/* 指标卡 */
@ -633,14 +800,13 @@ async function handleConfirm() {
flex: 1;
min-width: 100px;
padding: 12px;
background: #fff;
background: #f5f7fa;
border-radius: 8px;
border: 1px solid #ebeef5;
text-align: center;
}
.metric-value {
font-size: 18px;
font-size: 16px;
font-weight: 700;
color: #303133;
}
@ -661,13 +827,33 @@ async function handleConfirm() {
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;
/* 移动端 */
@media (max-width: 768px) {
.summary-metrics {
gap: 12px 16px;
}
.summary-actions {
flex-direction: column;
align-items: stretch;
}
.summary-actions .el-button:last-child {
margin-left: 0;
}
.metrics-row {
gap: 8px;
}
.metric-card {
min-width: 80px;
padding: 8px;
}
.metric-value {
font-size: 14px;
}
.issue-item {
flex-wrap: wrap;
}
.issue-item .issue-field {
min-width: auto;
}
}
</style>

View File

@ -1,44 +1,76 @@
<template>
<div class="ppt-result">
<el-card shadow="never" class="result-card">
<template #header>
<div class="card-header">
<el-icon><SuccessFilled /></el-icon>
<span>PPT 生成完成</span>
<!-- 交付确认区 -->
<div class="delivery-header">
<el-icon :size="28" color="#67c23a"><SuccessFilled /></el-icon>
<div class="delivery-title">
<div class="delivery-main">PPT 已生成完成</div>
<div class="delivery-sub"> {{ slideCount }} 页幻灯片 · {{ createdAt }}</div>
</div>
</div>
</template>
<el-result icon="success" title="PPT 已生成" :sub-title="` ${slideCount} 页幻灯片`">
<template #extra>
<div class="result-actions">
<el-button type="primary" size="large" @click="downloadPpt" :loading="downloading">
<!-- 产品摘要 -->
<div v-if="products.length" class="product-summary">
<div class="summary-label">生成内容</div>
<div class="product-chips">
<div v-for="(p, i) in products" :key="i" class="product-chip">
<el-tag :type="typeTagMap[p.planType] || 'info'" size="small">
{{ typeNameMap[p.planType] || p.planType }}
</el-tag>
<span class="chip-name">{{ p.productName || '未知产品' }}</span>
<span class="chip-meta" v-if="p.yearCount">{{ p.yearCount }} 年数据</span>
</div>
</div>
</div>
<!-- 下载前核对提示 -->
<div class="checklist-box">
<div class="checklist-title">
<el-icon><InfoFilled /></el-icon>
下载前请核对
</div>
<ul class="checklist-items">
<li>客户姓名被保人年龄是否正确</li>
<li>产品名称缴费年期是否匹配</li>
<li>利益演示数据与计划书一致</li>
</ul>
</div>
<!-- 下载按钮唯一主按钮 -->
<div class="download-area">
<el-button type="primary" size="large" @click="downloadPpt" :loading="downloading" class="download-btn">
<el-icon><Download /></el-icon>
{{ downloading ? `下载中 ${downloadProgress}%` : '下载 PPT' }}
{{ downloading ? `下载中 ${downloadProgress}%` : '下载 PPT 文件' }}
</el-button>
<el-progress v-if="downloading" :percentage="downloadProgress" style="width: 200px; margin-left: 12px" />
<el-button @click="$emit('regenerate')">重新生成</el-button>
<el-button @click="$emit('new-session')">新建会话</el-button>
<el-progress v-if="downloading" :percentage="downloadProgress" :stroke-width="6" style="max-width: 300px; margin-top: 12px" />
</div>
</template>
</el-result>
<!-- 会话信息 -->
<el-descriptions :column="2" border class="session-info">
<!-- 次要操作 -->
<div class="secondary-actions">
<el-button plain @click="$emit('regenerate')">重新生成</el-button>
<el-button plain @click="$emit('new-session')">新建会话</el-button>
</div>
<!-- 会话信息折叠 -->
<el-collapse style="margin-top: 16px">
<el-collapse-item title="会话详情" name="session">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="会话 ID">{{ sessionId }}</el-descriptions-item>
<el-descriptions-item label="幻灯片数">{{ slideCount }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag type="success">已完成</el-tag>
<el-tag type="success" size="small">已完成</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ createdAt }}</el-descriptions-item>
</el-descriptions>
</el-card>
</el-collapse-item>
</el-collapse>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { SuccessFilled, Download } from '@element-plus/icons-vue'
import { SuccessFilled, Download, InfoFilled } from '@element-plus/icons-vue'
import { pptApi } from '@/utils/ppt-api'
const props = defineProps<{
@ -54,6 +86,18 @@ const slideCount = ref(0)
const createdAt = ref('')
const downloading = ref(false)
const downloadProgress = ref(0)
const products = ref<Array<{ productName: string; planType: string; yearCount: number }>>([])
const typeTagMap: Record<string, string> = {
savings: 'success',
ci: 'warning',
iul: 'info',
}
const typeNameMap: Record<string, string> = {
savings: '储蓄险',
ci: '重疾险',
iul: 'IUL',
}
onMounted(async () => {
try {
@ -61,6 +105,14 @@ onMounted(async () => {
const data = res?.data
slideCount.value = data?.slide_count || 0
createdAt.value = data?.created_at || ''
//
const extractions = data?.extractions || []
products.value = extractions.map((e: any) => ({
productName: e.productName || e.data?.product_name || '',
planType: e.planType || e.data?.product_type || '',
yearCount: e.yearCount || e.data?.benefit_illustration?.length || 0,
}))
} catch {
//
}
@ -95,24 +147,106 @@ async function downloadPpt() {
max-width: 700px;
}
.result-card {
width: 100%;
.delivery-header {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 24px;
background: #f0f9eb;
border: 1px solid #e1f3d8;
border-radius: 8px;
margin-bottom: 16px;
}
.delivery-main {
font-size: 18px;
font-weight: 700;
color: #303133;
}
.delivery-sub {
font-size: 13px;
color: #909399;
margin-top: 4px;
}
.card-header {
.product-summary {
margin-bottom: 16px;
padding: 16px;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 8px;
}
.summary-label {
font-size: 13px;
color: #909399;
margin-bottom: 8px;
}
.product-chips {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.product-chip {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
}
.chip-name {
font-weight: 600;
font-size: 14px;
color: #303133;
}
.chip-meta {
font-size: 12px;
color: #909399;
}
.result-actions {
.checklist-box {
margin-bottom: 20px;
padding: 16px;
background: #fdf6ec;
border: 1px solid #faecd8;
border-radius: 8px;
}
.checklist-title {
display: flex;
gap: 12px;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #e6a23c;
margin-bottom: 8px;
}
.checklist-items {
margin: 0;
padding-left: 20px;
font-size: 13px;
color: #606266;
line-height: 2;
}
.session-info {
margin-top: 24px;
.download-area {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0;
}
.download-btn {
min-width: 200px;
}
.secondary-actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 8px;
}
@media (max-width: 768px) {
.delivery-header {
padding: 16px;
}
.delivery-main {
font-size: 16px;
}
}
</style>