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:
parent
e3479f0546
commit
8a5d13fcde
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user