Compare commits

...

2 Commits

Author SHA1 Message Date
5462433036 07-27 完善第一版的UI 2026-07-27 18:38:31 +08:00
0ffd9dffdf fix(llm): downgrade misleading 'mock mode' warning to debug level
The warning fired at init time when no env var API keys were set,
but the actual config was loaded from the database on first use.
Changed to debug to avoid confusion in logs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 15:52:29 +08:00
18 changed files with 2365 additions and 1233 deletions

View File

@ -323,7 +323,7 @@ class LLMClient:
self._limiters["gemini"] = RateLimiter(PROVIDERS["gemini"].rate_limit)
if not self._configs:
logger.warning("[LLMClient] 未配置任何 API Key使用 mock 模式")
logger.debug("[LLMClient] 未从环境变量加载 API Key将在首次调用时从数据库读取配置")
def _try_load_db_config(self):
"""尝试从数据库加载模型配置(带缓存,不阻塞)。"""

View File

@ -200,7 +200,7 @@ async function onCreateSession() {
if (creatingSession.value) return
creatingSession.value = true
try {
const res = await api.post('/chat/sessions')
const res: any = await api.post('/chat/sessions')
const sessionId = res.data?.session_id || res.data?.data?.session_id || res.session_id
if (!sessionId) {
throw new Error('服务端未返回 session_id')

View File

@ -1,68 +1,72 @@
<template>
<div class="step-preview">
<!-- 等待生成 -->
<div v-if="taskStatus === 'idle'" class="generate-section">
<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 class="step-head">
<div>
<h3>预览与导出</h3>
<p>生成前确认尺寸模板和文案生成完成后可直接下载图片</p>
</div>
<el-button type="primary" size="large" @click="onGenerate">
<el-icon><Picture /></el-icon>
</el-button>
<el-tag :type="taskStatus === 'done' ? 'success' : 'info'">{{ statusLabel }}</el-tag>
</div>
<!-- 生成中异步轮询 -->
<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 class="status-text">{{ taskStatus === 'queued' ? '任务排队中...' : '海报生成中(约 10-30 秒)...' }}</p>
<p v-if="pollCount > 30" class="timeout-hint">生成时间较长请耐心等待或稍后查看历史记录</p>
</div>
<!-- 生成失败 -->
<div v-else-if="taskStatus === 'failed'" class="generate-section">
<el-result icon="error" title="生成失败" :sub-title="taskError || '请重试'">
<template #extra>
<el-button type="primary" @click="onGenerate">重新生成</el-button>
</template>
</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>
<!-- 交付元信息 -->
<div class="delivery-meta">
<div class="meta-row">
<span class="meta-label">尺寸</span>
<span class="meta-value">{{ sizeLabel }}</span>
<div class="preview-workbench">
<section class="poster-stage">
<div v-if="taskStatus === 'idle'" class="draft-preview" :class="sizeClass">
<div class="draft-brand">{{ templateName || '海报模板' }}</div>
<div class="draft-title">{{ copyContent?.headline || '客户专属保障方案' }}</div>
<div class="draft-body">{{ copyContent?.body || '文案将在生成时写入海报。' }}</div>
<div class="draft-cta">{{ copyContent?.call_to_action || '联系顾问了解详情' }}</div>
</div>
<div class="meta-row">
<span class="meta-label">生成方式</span>
<span class="meta-value">{{ generationMode || 'AI 生图' }}</span>
<div v-else-if="taskStatus === 'queued' || taskStatus === 'generating'" class="generate-state">
<el-progress :percentage="taskProgress" :stroke-width="10" />
<p>{{ taskStatus === 'queued' ? '任务排队中...' : '海报生成中,预计 10 到 30 秒' }}</p>
</div>
</div>
<!-- 下载按钮唯一主按钮 -->
<div class="download-area">
<el-button type="primary" size="large" @click="onDownload" class="download-btn">
<el-icon><Download /></el-icon>
</el-button>
</div>
<el-result v-else-if="taskStatus === 'failed'" icon="error" title="生成失败" :sub-title="taskError || '请重试'">
<template #extra>
<el-button type="primary" @click="onGenerate">重新生成</el-button>
</template>
</el-result>
<!-- 次要操作 -->
<div class="secondary-actions">
<el-button plain @click="$emit('back')">返回修改文案</el-button>
<el-button plain @click="clearPoster">重新生成</el-button>
</div>
</div>
<div v-else-if="taskStatus === 'done' && posterObjectUrl" class="result-section">
<el-image :src="posterObjectUrl" fit="contain" class="poster-image" />
</div>
</section>
<div v-if="taskStatus === 'idle'" class="step-actions">
<el-button @click="$emit('back')">上一步</el-button>
<aside class="delivery-panel">
<div class="delivery-card">
<h4>生成设置</h4>
<el-descriptions :column="1" border>
<el-descriptions-item label="模板">{{ templateName || '-' }}</el-descriptions-item>
<el-descriptions-item label="销售场景">{{ scenarioLabel || '-' }}</el-descriptions-item>
<el-descriptions-item label="导出尺寸">{{ sizeText }}</el-descriptions-item>
<el-descriptions-item label="文案模式">{{ aiRawContent ? 'AI 生成' : '模板文案' }}</el-descriptions-item>
</el-descriptions>
</div>
<div class="delivery-card">
<h4>导出前核对</h4>
<ul class="check-list">
<li v-for="item in checklist" :key="item">
<span></span>
{{ item }}
</li>
</ul>
</div>
<div class="primary-actions">
<el-button v-if="taskStatus !== 'done'" type="primary" size="large" @click="onGenerate">
<el-icon><Picture /></el-icon>
生成海报
</el-button>
<el-button v-else type="primary" size="large" @click="onDownload">
<el-icon><Download /></el-icon>
下载海报
</el-button>
<el-button v-if="taskStatus === 'done'" @click="clearPoster">重新生成</el-button>
<el-button @click="$emit('back')">返回修改</el-button>
</div>
</aside>
</div>
</div>
</template>
@ -79,27 +83,43 @@ const props = defineProps<{
templateId: number | null
copyContent: any
aiRawContent?: any
size?: string
initialSize?: string
templateName?: string
scenarioLabel?: string
}>()
defineEmits<{ back: [] }>()
const selectedSize = ref(props.initialSize || '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')
const checklist = [
'标题和正文没有夸大收益',
'产品名称和客户场景匹配',
'尺寸适合本次投放渠道',
'行动号召清晰且合规',
]
const statusLabel = computed(() => {
if (taskStatus.value === 'queued') return '排队中'
if (taskStatus.value === 'generating') return '生成中'
if (taskStatus.value === 'done') return '已完成'
if (taskStatus.value === 'failed') return '失败'
return '待生成'
})
const sizeText = computed(() => {
if (selectedSize.value === '1792x1024') return '横版 1792x1024'
if (selectedSize.value === '1024x1024') return '方图 1024x1024'
return '竖版 1024x1792'
})
const sizeClass = computed(() => {
if (selectedSize.value === '1792x1024') return 'landscape'
if (selectedSize.value === '1024x1024') return 'square'
return 'portrait'
})
function clearPoster() {
stopPolling()
@ -111,8 +131,6 @@ function clearPoster() {
taskStatus.value = 'idle'
taskProgress.value = 0
taskError.value = null
generationMode.value = null
pollCount.value = 0
}
function stopPolling() {
@ -126,16 +144,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,
size: props.size || '1024x1792',
copyMode: 'ai',
} as any)
aiRawContent: props.aiRawContent,
size: selectedSize.value,
copyMode: props.aiRawContent ? 'ai' : 'template',
})
const record = res?.data
recordId.value = record?.id
if (record?.id) {
@ -149,23 +167,13 @@ 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()
@ -180,7 +188,7 @@ function startPolling(id: number) {
stopPolling()
}
} catch {
//
//
}
}, 2000)
}
@ -209,98 +217,198 @@ onBeforeUnmount(() => {
</script>
<style scoped>
.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 {
.step-head {
display: flex;
justify-content: center;
gap: 24px;
margin-bottom: 20px;
padding: 12px 16px;
background: #f0f9eb;
border: 1px solid #e1f3d8;
border-radius: 8px;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.meta-row {
.step-head h3 {
margin: 0;
color: #172033;
font-size: 20px;
}
.step-head p {
margin: 6px 0 0;
color: #667085;
font-size: 13px;
line-height: 1.6;
}
.preview-workbench {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 18px;
}
.poster-stage,
.delivery-panel {
border: 1px solid #d9e2ef;
border-radius: 8px;
background: #fff;
}
.poster-stage {
min-height: 560px;
padding: 24px;
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;
.draft-preview {
width: 320px;
min-height: 520px;
padding: 28px;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 16px;
border-radius: 8px;
background: linear-gradient(160deg, #172033, #2563eb);
color: #fff;
box-shadow: 0 22px 58px rgb(15 23 42 / 18%);
}
@media (max-width: 768px) {
.delivery-meta {
.draft-preview.landscape {
width: min(100%, 620px);
min-height: 360px;
}
.draft-preview.square {
width: 420px;
min-height: 420px;
}
.draft-brand {
font-size: 13px;
opacity: .76;
}
.draft-title {
font-size: 30px;
font-weight: 800;
line-height: 1.2;
}
.draft-body {
max-width: 32ch;
font-size: 15px;
line-height: 1.65;
opacity: .9;
}
.draft-cta {
align-self: flex-start;
padding: 9px 12px;
border-radius: 8px;
background: rgb(255 255 255 / 92%);
color: #172033;
font-weight: 700;
}
.generate-state {
width: min(420px, 100%);
text-align: center;
}
.generate-state p {
color: #667085;
margin-top: 14px;
}
.poster-image {
max-width: 100%;
max-height: 640px;
border-radius: 8px;
}
.delivery-panel {
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.delivery-card {
padding: 14px;
border: 1px solid #e5eaf3;
border-radius: 8px;
background: #f8fafc;
}
.delivery-card h4 {
margin: 0 0 12px;
color: #172033;
font-size: 15px;
}
.check-list {
display: grid;
gap: 10px;
padding: 0;
margin: 0;
list-style: none;
}
.check-list li {
display: flex;
gap: 10px;
color: #344054;
line-height: 1.5;
}
.check-list span {
width: 22px;
height: 22px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
background: #16a34a;
font-size: 13px;
font-weight: 800;
flex-shrink: 0;
}
.primary-actions {
display: grid;
gap: 10px;
}
:deep(.el-button) {
min-height: 40px;
}
@media (max-width: 900px) {
.preview-workbench {
grid-template-columns: 1fr;
}
}
@media (max-width: 767px) {
.step-head {
flex-direction: column;
gap: 8px;
align-items: center;
}
.poster-stage {
min-height: 420px;
padding: 16px;
}
.draft-preview,
.draft-preview.landscape,
.draft-preview.square {
width: min(100%, 300px);
min-height: 360px;
}
.draft-title {
font-size: 24px;
}
}
</style>

View File

@ -1,6 +1,11 @@
<template>
<div class="step-product">
<h3>选择产品</h3>
<div class="step-head">
<div>
<h3>选择产品</h3>
<p>选择本次海报要沟通的产品后续文案会围绕该产品生成</p>
</div>
</div>
<el-skeleton :loading="loading" animated :rows="3">
<template #template>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px">
@ -15,14 +20,15 @@
<div v-for="group in grouped" :key="group.companyId" class="product-group">
<h4 class="group-title">{{ group.companyName }}</h4>
<div class="product-cards">
<div v-for="p in group.items" :key="p.id"
<button v-for="p in group.items" :key="p.id"
type="button"
class="product-card" :class="{ active: selectedId === p.id }"
@click="selectedId = p.id">
<div class="product-name">{{ p.displayName }}</div>
<div class="product-type">
<el-tag size="small">{{ typeMap[p.planType] || p.planType }}</el-tag>
</div>
</div>
</button>
</div>
</div>
</div>
@ -72,15 +78,64 @@ onMounted(async () => {
</script>
<style scoped>
.step-actions { margin-top: 24px; text-align: right; }
.empty-tip { text-align: center; color: #909399; padding: 40px; }
.group-title { font-size: 14px; color: #606266; margin: 16px 0 8px; }
.product-cards { display: flex; flex-wrap: wrap; gap: 12px; }
.product-card {
padding: 12px 16px; border: 2px solid #e4e7ed; border-radius: 8px;
cursor: pointer; transition: all 0.2s; min-width: 140px;
.step-head {
margin-bottom: 16px;
}
.step-head h3 {
margin: 0;
color: #172033;
font-size: 20px;
}
.step-head p {
margin: 6px 0 0;
color: #667085;
font-size: 13px;
}
.step-actions { margin-top: 24px; text-align: right; }
.empty-tip {
text-align: center;
color: #667085;
padding: 40px;
border: 1px solid #d9e2ef;
border-radius: 8px;
background: #fff;
}
.group-title { font-size: 14px; color: #344054; margin: 16px 0 8px; }
.product-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.product-card {
padding: 14px 16px;
border: 2px solid #e4e7ed;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
min-height: 88px;
background: #fff;
text-align: left;
}
.product-card:hover,
.product-card:focus-visible {
border-color: #2563eb;
outline: none;
}
.product-card.active {
border-color: #2563eb;
background: #eff6ff;
}
.product-name {
color: #172033;
font-weight: 700;
margin-bottom: 8px;
}
@media (max-width: 767px) {
.step-actions {
text-align: left;
}
.step-actions :deep(.el-button) {
width: 100%;
}
}
.product-card:hover { border-color: #409eff; }
.product-card.active { border-color: #409eff; background: #ecf5ff; }
.product-name { font-weight: 500; margin-bottom: 4px; }
</style>

View File

@ -1,136 +1,130 @@
<template>
<div class="step-template">
<!-- 尺寸选择前置 -->
<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 class="step-head">
<div>
<h3>选择模板与文案</h3>
<p>先确定销售场景和海报比例再生成或填充文案</p>
</div>
<el-tag type="info">生成前确认</el-tag>
</div>
<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 class="template-workbench">
<section class="template-panel">
<div class="panel-title">
<strong>海报尺寸</strong>
<span>尺寸会影响模板构图请在生成前确认</span>
</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" 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-tags">
<el-tag v-if="t.scenarioTag" size="small" type="info" effect="plain">{{ sceneLabel(t.scenarioTag) }}</el-tag>
</div>
<el-segmented v-model="selectedSize" :options="sizeOptions" class="size-control" />
<div class="panel-title template-title">
<strong>销售场景</strong>
<span>场景会影响文案语气和用户预期</span>
</div>
<el-segmented v-model="selectedScenario" :options="scenarioOptions" class="scenario-control" />
<div class="panel-title template-title">
<strong>海报模板</strong>
<span>选择后右侧会展示模板气质和文案编辑区</span>
</div>
<el-skeleton :loading="loading" animated :rows="3">
<template #default>
<el-empty v-if="templates.length === 0" description="暂无可用海报模板,请先在后台启用模板" />
<div v-else class="template-grid">
<button
v-for="t in templates"
:key="t.id"
type="button"
class="template-card"
:class="{ active: selectedTemplateId === t.id }"
@click="selectedTemplateId = t.id"
>
<el-image v-if="t.previewImage" :src="t.previewImage" class="template-image" fit="cover" />
<div v-else class="template-placeholder" :style="{ background: previewBackground(t) }">
<span class="poster-lines"></span>
<span class="poster-lines short"></span>
<span class="poster-cta"></span>
</div>
<div class="template-info">
<div class="template-name">{{ t.name }}</div>
<div class="template-style">{{ formatStyle(t.styleDescription) }}</div>
</div>
</button>
</div>
</template>
</el-skeleton>
</section>
<aside class="copy-panel">
<div class="selected-preview">
<div class="preview-frame" :class="selectedSizeClass">
<div class="preview-brand">{{ selectedTemplate?.name || '请选择模板' }}</div>
<div class="preview-headline">{{ copyResult?.headline || '客户专属保障方案' }}</div>
<div class="preview-body">{{ copyResult?.body || '系统会根据计划书信息生成可编辑文案。' }}</div>
<div class="preview-action">{{ copyResult?.call_to_action || '联系顾问了解详情' }}</div>
</div>
<div class="preview-meta">
<span>{{ selectedSizeLabel }}</span>
<span>{{ selectedScenarioLabel }}</span>
</div>
</div>
<!-- 选中模板预览 -->
<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 class="copy-editor">
<div class="panel-title">
<strong>文案生成</strong>
<span>AI 文案需要人工确认后再导出</span>
</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-group v-model="copyMode" class="mode-control">
<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="3" readonly />
<div v-if="copyMode === 'template'" class="copy-source">
<el-select v-model="selectedCopyTemplateId" placeholder="选择文案模板" style="width: 100%">
<el-option v-for="ct in copyTemplates" :key="ct.id" :label="ct.name" :value="ct.id" />
</el-select>
<el-input v-if="templatePreview" v-model="templatePreview" type="textarea" :rows="4" readonly />
<el-button type="primary" @click="onGenerateCopy" :loading="generatingCopy" :disabled="!selectedCopyTemplateId">
填充文案
</el-button>
</div>
<el-button type="primary" @click="onGenerateCopy" :loading="generatingCopy" :disabled="!selectedCopyTemplateId">
填充文案
</el-button>
</div>
<!-- AI 模式 -->
<div v-else class="copy-ai-section">
<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 v-else class="copy-source">
<el-segmented v-model="aiStyle" :options="styleOptions" />
<el-button type="primary" @click="onGenerateCopy" :loading="generatingCopy">
AI 生成文案
</el-button>
</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" size="default">
<el-form-item label="标题">
<el-input v-model="copyResult.headline" />
</el-form-item>
<el-form-item label="正文">
<el-input v-model="copyResult.body" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="行动号召">
<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>
<el-alert
class="compliance-alert"
title="合规提示"
type="warning"
description="海报文案涉及保险利益和销售表达,导出前请人工确认标题、卖点和行动号召。"
show-icon
:closable="false"
/>
<!-- 高级设置默认折叠 -->
<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可选" />
<div v-if="copyResult" class="copy-result">
<el-form label-position="top">
<el-form-item label="标题">
<el-input v-model="copyResult.headline" />
</el-form-item>
<el-form-item label="正文">
<el-input v-model="copyResult.body" type="textarea" :rows="4" />
</el-form-item>
<el-form-item label="行动号召">
<el-input v-model="copyResult.call_to_action" />
</el-form-item>
</el-form>
</el-collapse-item>
</el-collapse>
</div>
</div>
</div>
</aside>
</div>
<!-- 底部操作 -->
<div class="step-actions">
<el-button @click="$emit('back')">上一步</el-button>
<el-button type="primary" size="large" :disabled="!selectedTemplateId || !copyResult" @click="onNext">
<el-button type="primary" :disabled="!selectedTemplateId || !copyResult" @click="onNext">
下一步预览导出
</el-button>
</div>
@ -140,54 +134,67 @@
<script setup lang="ts">
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; size: string }]; back: [] }>()
const emit = defineEmits<{
next: [data: {
templateId: number
templateName: string
scenarioLabel: string
exportSize: string
copyContent: any
aiRawContent: any
}]
back: []
}>()
const templates = ref<any[]>([])
const copyTemplates = ref<any[]>([])
const selectedTemplateId = ref<number | null>(null)
const selectedSize = ref('1024x1792')
const selectedScenario = ref('朋友圈沟通')
const copyMode = ref('template')
const selectedCopyTemplateId = ref<number | null>(null)
const templatePreview = ref('')
const aiStyle = ref('专业稳健')
const generatingCopy = ref(false)
const loading = 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 sizeOptions = [
{ label: '竖版', value: '1024x1792' },
{ label: '横版', value: '1792x1024' },
{ label: '方图', value: '1024x1024' },
]
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
const scenarioOptions = ['朋友圈沟通', '客户私聊', '讲座邀约', '产品卖点']
const styleOptions = ['专业稳健', '温暖顾问', '高端资产配置', '简洁朋友圈']
const selectedTemplate = computed(() => templates.value.find((t: any) => t.id === selectedTemplateId.value))
const selectedScenarioLabel = computed(() => selectedScenario.value)
const selectedSizeLabel = computed(() => {
return sizeOptions.find(item => item.value === selectedSize.value)?.label || '竖版'
})
const selectedSizeClass = computed(() => {
if (selectedSize.value === '1792x1024') return 'landscape'
if (selectedSize.value === '1024x1024') return 'square'
return 'portrait'
})
onMounted(async () => {
const [tRes, ctRes]: any[] = await Promise.all([posterApi.getTemplates(), posterApi.getCopyTemplates()])
templates.value = tRes?.data?.data ?? tRes?.data ?? []
copyTemplates.value = ctRes?.data?.data ?? ctRes?.data ?? []
loading.value = true
try {
const [tRes, ctRes]: any[] = await Promise.all([posterApi.getTemplates(), posterApi.getCopyTemplates()])
templates.value = tRes?.data?.data ?? tRes?.data ?? []
copyTemplates.value = ctRes?.data?.data ?? ctRes?.data ?? []
if (templates.value.length && !selectedTemplateId.value) {
selectedTemplateId.value = templates.value[0].id
}
} catch (e: any) {
ElMessage.error(e?.message || '模板加载失败')
} finally {
loading.value = false
}
})
watch(selectedCopyTemplateId, (id) => {
@ -195,6 +202,17 @@ watch(selectedCopyTemplateId, (id) => {
templatePreview.value = ct?.content || ''
})
function formatStyle(text?: string) {
if (!text) return '适合标准保险顾问场景'
return text.length > 42 ? `${text.slice(0, 42)}...` : text
}
function previewBackground(template: any) {
const primary = template.colorScheme?.primary || '#1e3a5f'
const accent = template.colorScheme?.accent || '#2563eb'
return `linear-gradient(135deg, ${primary}, ${accent})`
}
async function onGenerateCopy() {
generatingCopy.value = true
try {
@ -203,7 +221,7 @@ async function onGenerateCopy() {
caseUploadId: props.caseUploadId ?? undefined,
productId: props.productId,
templateId: copyMode.value === 'template' ? selectedCopyTemplateId.value ?? undefined : undefined,
style: aiStyle.value,
style: `${aiStyle.value},用于${selectedScenario.value}`,
})
copyResult.value = res?.data?.data?.copy ?? res?.data?.copy
if (copyMode.value === 'ai') {
@ -219,199 +237,303 @@ async function onGenerateCopy() {
function onNext() {
emit('next', {
templateId: selectedTemplateId.value!,
templateName: selectedTemplate.value?.name || '',
scenarioLabel: selectedScenario.value,
exportSize: selectedSize.value,
copyContent: copyResult.value,
aiRawContent: aiRawContent.value,
size: selectedSize.value,
})
}
</script>
<style scoped>
.step-template {
width: 100%;
max-width: 1180px;
padding-bottom: 12px;
}
.size-bar {
.step-head {
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;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.left-panel {
flex: 0 0 380px;
min-width: 0;
.step-head h3 {
margin: 0;
color: #172033;
font-size: 20px;
}
.right-panel {
flex: 1;
min-width: 0;
.step-head p,
.panel-title span,
.preview-meta {
margin: 6px 0 0;
color: #667085;
font-size: 13px;
line-height: 1.6;
}
.template-workbench {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(360px, .9fr);
gap: 18px;
}
.template-panel,
.copy-panel {
padding: 18px;
border: 1px solid #d9e2ef;
border-radius: 8px;
background: #fff;
}
.panel-title {
font-size: 15px;
font-weight: 600;
color: #303133;
margin-bottom: 12px;
}
.panel-title strong {
display: block;
color: #172033;
font-size: 15px;
}
.size-control,
.scenario-control {
width: 100%;
}
.template-title {
margin-top: 20px;
}
.template-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 12px;
}
.template-card {
padding: 0;
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;
transition: all 0.2s;
background: #fff;
text-align: left;
}
.selected-preview {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #ebeef5;
.template-card:hover {
border-color: #2563eb;
}
.preview-label {
font-size: 13px;
color: #909399;
margin-bottom: 8px;
.template-card.active {
border-color: #2563eb;
box-shadow: 0 10px 28px rgb(37 99 235 / 14%);
}
.preview-card {
background: #f5f7fa;
border-radius: 8px;
padding: 12px;
}
.preview-placeholder {
.template-image,
.template-placeholder {
width: 100%;
aspect-ratio: 3/4;
border-radius: 8px;
height: 154px;
}
.template-placeholder {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
justify-content: flex-end;
gap: 10px;
padding: 18px;
}
.preview-meta {
margin-top: 8px;
.poster-lines {
display: block;
width: 72%;
height: 8px;
border-radius: 8px;
background: rgb(255 255 255 / 86%);
}
.preview-name {
font-weight: 600;
.poster-lines.short {
width: 48%;
opacity: .7;
}
.poster-cta {
display: block;
width: 38%;
height: 22px;
border-radius: 8px;
background: rgb(255 255 255 / 92%);
}
.template-info {
padding: 10px;
}
.template-name {
color: #172033;
font-weight: 700;
font-size: 14px;
color: #303133;
}
.preview-desc {
font-size: 12px;
color: #909399;
.template-style {
margin-top: 4px;
color: #667085;
font-size: 12px;
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 {
.copy-panel {
display: flex;
flex-wrap: wrap;
gap: 8px;
flex-direction: column;
gap: 16px;
}
.copy-preview {
margin-bottom: 12px;
.selected-preview {
padding: 16px;
border-radius: 8px;
background: #f8fafc;
border: 1px solid #e5eaf3;
}
.preview-frame {
width: 210px;
min-height: 330px;
margin: 0 auto;
padding: 20px;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 12px;
border-radius: 8px;
background: linear-gradient(160deg, #172033, #2563eb);
color: #fff;
box-shadow: 0 18px 42px rgb(15 23 42 / 18%);
}
.preview-frame.landscape {
width: 320px;
min-height: 188px;
}
.preview-frame.square {
width: 248px;
min-height: 248px;
}
.preview-brand {
font-size: 12px;
opacity: .76;
}
.preview-headline {
font-size: 22px;
font-weight: 800;
line-height: 1.25;
}
.preview-body {
font-size: 13px;
line-height: 1.55;
opacity: .9;
}
.preview-action {
align-self: flex-start;
padding: 7px 10px;
border-radius: 8px;
background: rgb(255 255 255 / 92%);
color: #172033;
font-size: 12px;
font-weight: 700;
}
.preview-meta {
display: flex;
justify-content: center;
gap: 12px;
}
.copy-editor {
padding: 16px;
border: 1px solid #d9e2ef;
border-radius: 8px;
}
.mode-control {
margin-bottom: 14px;
}
.copy-source {
display: grid;
gap: 12px;
}
.compliance-alert {
margin: 14px 0;
}
.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;
margin-top: 14px;
}
.step-actions {
position: sticky;
bottom: 0;
z-index: 2;
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #ebeef5;
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid #d9e2ef;
background: #f5f7fa;
}
@media (max-width: 768px) {
.template-layout {
:deep(.el-button) {
min-height: 40px;
}
:deep(.el-input__inner),
:deep(.el-textarea__inner) {
font-size: 16px;
}
@media (max-width: 900px) {
.template-workbench {
grid-template-columns: 1fr;
}
}
@media (max-width: 767px) {
.step-head,
.step-actions {
flex-direction: column;
align-items: stretch;
}
.left-panel {
flex: none;
width: 100%;
.template-panel,
.copy-panel {
padding: 14px;
}
.template-grid {
grid-template-columns: repeat(2, 1fr);
.preview-frame,
.preview-frame.landscape,
.preview-frame.square {
width: min(100%, 280px);
min-height: 280px;
}
.size-bar {
flex-direction: column;
align-items: flex-start;
gap: 8px;
.step-actions {
position: fixed;
left: 12px;
right: 12px;
bottom: 12px;
padding: 12px;
border: 1px solid #d9e2ef;
border-radius: 8px;
box-shadow: 0 12px 36px rgb(15 23 42 / 16%);
}
}
</style>

View File

@ -1,61 +1,71 @@
<template>
<div class="step-upload">
<h3>上传计划书</h3>
<el-upload
v-if="!caseRecord"
drag
:auto-upload="false"
:limit="1"
accept=".pdf"
:on-change="handleFileChange"
class="upload-area"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">拖拽或 <em>点击上传</em> 计划书 PDF</div>
</el-upload>
<div v-if="parsing" class="parsing-status">
<el-progress type="circle" :percentage="progress" :width="100" />
<p>{{ parseMessage }}</p>
<div class="step-head">
<div>
<h3>上传计划书</h3>
<p>上传客户计划书 PDF系统会解析基础保障信息解析后请人工核对再继续</p>
</div>
</div>
<div v-if="parseFailed" class="parsing-status">
<el-result icon="error" title="解析失败" sub-title="请重新上传或联系管理员">
<template #extra>
<el-button type="primary" @click="resetUpload">重新上传</el-button>
<section class="upload-panel">
<el-upload
v-if="!caseRecord"
drag
:auto-upload="false"
:limit="1"
accept=".pdf"
:on-change="handleFileChange"
class="upload-area"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">拖拽或 <em>点击上传</em> 计划书 PDF</div>
<template #tip>
<div class="upload-tip">仅支持 PDF请上传最终客户计划书避免使用草稿版本</div>
</template>
</el-result>
</div>
</el-upload>
<div v-if="caseRecord && caseRecord.parseStatus === 'parsed' && !confirmed" class="parsed-form">
<el-alert title="解析完成,请核对以下数据" type="success" show-icon style="margin-bottom: 16px" />
<el-form label-width="120px">
<el-form-item label="年龄"><el-input-number v-model="formData.age" :min="0" :max="100" /></el-form-item>
<el-form-item label="性别">
<el-select v-model="formData.gender">
<el-option label="男" value="男" />
<el-option label="女" value="女" />
</el-select>
</el-form-item>
<el-form-item label="货币">
<el-select v-model="formData.currency">
<el-option label="USD" value="USD" />
<el-option label="HKD" value="HKD" />
<el-option label="RMB" value="RMB" />
</el-select>
</el-form-item>
<el-form-item label="保额"><el-input-number v-model="formData.sum_assured" :min="0" :step="10000" /></el-form-item>
<el-form-item label="缴费年期"><el-input-number v-model="formData.premium_term" :min="1" :max="30" /></el-form-item>
<el-form-item label="年缴保费"><el-input-number v-model="formData.annual_premium" :min="0" :step="1000" /></el-form-item>
<el-form-item label="保障期限"><el-input v-model="formData.coverage_period" /></el-form-item>
</el-form>
<el-button type="primary" @click="onConfirm">确认数据</el-button>
</div>
<div v-if="parsing" class="parsing-status">
<el-progress type="circle" :percentage="progress" :width="112" />
<p>{{ parseMessage }}</p>
</div>
<div v-if="confirmed" class="confirmed-tip">
<el-result icon="success" title="数据已确认" sub-title="可进入下一步选择模板" />
</div>
<div v-if="parseFailed" class="parsing-status">
<el-result icon="error" title="解析失败" sub-title="请重新上传或联系管理员">
<template #extra>
<el-button type="primary" @click="resetUpload">重新上传</el-button>
</template>
</el-result>
</div>
<div v-if="caseRecord && caseRecord.parseStatus === 'parsed' && !confirmed" class="parsed-form">
<el-alert title="解析完成,请核对以下数据" type="success" show-icon :closable="false" />
<el-form label-position="top" class="parsed-grid">
<el-form-item label="年龄"><el-input-number v-model="formData.age" :min="0" :max="100" /></el-form-item>
<el-form-item label="性别">
<el-select v-model="formData.gender">
<el-option label="男" value="男" />
<el-option label="女" value="女" />
</el-select>
</el-form-item>
<el-form-item label="货币">
<el-select v-model="formData.currency">
<el-option label="USD" value="USD" />
<el-option label="HKD" value="HKD" />
<el-option label="RMB" value="RMB" />
</el-select>
</el-form-item>
<el-form-item label="保额"><el-input-number v-model="formData.sum_assured" :min="0" :step="10000" /></el-form-item>
<el-form-item label="缴费年期"><el-input-number v-model="formData.premium_term" :min="1" :max="30" /></el-form-item>
<el-form-item label="年缴保费"><el-input-number v-model="formData.annual_premium" :min="0" :step="1000" /></el-form-item>
<el-form-item label="保障期限"><el-input v-model="formData.coverage_period" /></el-form-item>
</el-form>
<el-button type="primary" @click="onConfirm">确认数据</el-button>
</div>
<div v-if="confirmed" class="confirmed-tip">
<el-result icon="success" title="数据已确认" sub-title="可进入下一步选择模板" />
</div>
</section>
<div class="step-actions">
<el-button @click="$emit('back')">上一步</el-button>
@ -174,8 +184,57 @@ onBeforeUnmount(() => {
</script>
<style scoped>
.step-head {
margin-bottom: 16px;
}
.step-head h3 {
margin: 0;
color: #172033;
font-size: 20px;
}
.step-head p {
margin: 6px 0 0;
color: #667085;
font-size: 13px;
}
.upload-panel {
padding: 22px;
border: 1px solid #d9e2ef;
border-radius: 8px;
background: #fff;
}
.step-actions { margin-top: 24px; text-align: right; }
.upload-area { max-width: 500px; margin: 0 auto; }
.upload-area { max-width: 560px; margin: 0 auto; }
.upload-tip {
color: #667085;
font-size: 13px;
}
.parsing-status { text-align: center; padding: 40px; }
.parsed-form { max-width: 600px; margin: 16px auto; }
.parsed-form { max-width: 760px; margin: 16px auto; }
.parsed-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0 14px;
margin-top: 16px;
}
.parsed-grid :deep(.el-input-number),
.parsed-grid :deep(.el-select) {
width: 100%;
}
@media (max-width: 767px) {
.upload-panel {
padding: 14px;
}
.parsed-grid {
grid-template-columns: 1fr;
}
.step-actions {
display: grid;
gap: 10px;
}
.step-actions :deep(.el-button) {
width: 100%;
margin-left: 0;
}
}
</style>

View File

@ -18,7 +18,7 @@ export function useDraft<T extends object>(key: string, formData: { value: T })
} catch {}
}
watch(formData, debounce((val: T) => {
watch(() => formData.value, debounce((val: T) => {
localStorage.setItem(`draft_${key}`, JSON.stringify(val))
}, 500), { deep: true })

View File

@ -1,35 +1,88 @@
<template>
<div class="poster-page">
<el-steps :active="currentStep" finish-status="success" align-center class="poster-steps">
<el-step title="选择产品" />
<el-step title="上传计划书" />
<el-step title="选模板 + 文案" />
<el-step title="预览导出" />
</el-steps>
<div class="poster-page generator-page">
<section class="generator-header">
<div>
<p class="section-label">海报生成工作台</p>
<h1>为客户沟通生成可核对的营销海报</h1>
<p class="header-copy">先确定产品和销售场景再编辑文案并预览成品减少反复生成和合规风险</p>
</div>
<div class="header-actions">
<el-button :icon="Clock" @click="router.push('/poster/history')">历史记录</el-button>
<el-button :icon="RefreshLeft" :disabled="currentStep === 0" @click="resetAll">重新开始</el-button>
</div>
</section>
<section class="generator-progress">
<div class="mobile-step-summary">
{{ currentStep + 1 }}/{{ steps.length }} {{ steps[currentStep].title }}
<span>{{ steps[currentStep].description }}</span>
</div>
<el-steps :active="currentStep" finish-status="success" align-center class="poster-steps">
<el-step
v-for="step in steps"
:key="step.title"
:title="step.title"
:description="step.description"
/>
</el-steps>
</section>
<div class="step-content">
<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" :size="selectedSize" @back="currentStep--" />
<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"
:initial-size="selectedSize"
:template-name="selectedTemplateName"
:scenario-label="selectedScenarioLabel"
@back="currentStep--"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { Clock, RefreshLeft } from '@element-plus/icons-vue'
import PosterStepProduct from '@/components/poster/PosterStepProduct.vue'
import PosterStepUpload from '@/components/poster/PosterStepUpload.vue'
import PosterStepTemplate from '@/components/poster/PosterStepTemplate.vue'
import PosterStepPreview from '@/components/poster/PosterStepPreview.vue'
const router = useRouter()
const currentStep = ref(0)
const selectedProductId = ref('')
const caseUploadId = ref<number | null>(null)
const selectedTemplateId = ref<number | null>(null)
const selectedTemplateName = ref('')
const selectedScenarioLabel = ref('')
const selectedSize = ref('1024x1792')
const copyContent = ref<any>(null)
const aiRawContent = ref<any>(null)
const selectedSize = ref('1024x1792')
const steps = [
{ title: '选择产品', description: '确定客户沟通对象' },
{ title: '上传', description: '解析计划书数据' },
{ title: '模板文案', description: '选择场景并编辑' },
{ title: '预览导出', description: '生成并下载海报' },
]
function onStep1Next(productId: string) {
selectedProductId.value = productId
@ -41,25 +94,157 @@ function onStep2Next(id: number) {
currentStep.value++
}
function onStep3Next(data: { templateId: number; copyContent: any; aiRawContent: any; size?: string }) {
function onStep3Next(data: {
templateId: number
templateName: string
scenarioLabel: string
exportSize: string
copyContent: any
aiRawContent: any
}) {
selectedTemplateId.value = data.templateId
selectedTemplateName.value = data.templateName
selectedScenarioLabel.value = data.scenarioLabel
selectedSize.value = data.exportSize
copyContent.value = data.copyContent
aiRawContent.value = data.aiRawContent
if (data.size) selectedSize.value = data.size
currentStep.value++
}
function resetAll() {
currentStep.value = 0
selectedProductId.value = ''
caseUploadId.value = null
selectedTemplateId.value = null
selectedTemplateName.value = ''
selectedScenarioLabel.value = ''
selectedSize.value = '1024x1792'
copyContent.value = null
aiRawContent.value = null
}
</script>
<style scoped>
.poster-page {
padding: 20px;
max-width: 1180px;
padding: 24px;
height: 100%;
overflow: auto;
display: flex;
flex-direction: column;
gap: 18px;
}
.generator-page {
--generator-accent: #2563eb;
--generator-border: #d9e2ef;
--generator-text: #172033;
--generator-muted: #667085;
}
.generator-header {
width: min(1180px, 100%);
margin: 0 auto;
padding: 20px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
border: 1px solid var(--generator-border);
border-radius: 8px;
background: #fff;
}
.section-label {
margin: 0 0 6px;
color: var(--generator-accent);
font-size: 13px;
font-weight: 600;
}
.generator-header h1 {
margin: 0;
color: var(--generator-text);
font-size: 22px;
line-height: 1.3;
}
.header-copy {
margin: 8px 0 0;
color: var(--generator-muted);
font-size: 14px;
line-height: 1.6;
}
.header-actions {
display: flex;
gap: 10px;
flex-shrink: 0;
}
.generator-progress {
width: min(1180px, 100%);
margin: 0 auto;
padding: 18px 24px;
border: 1px solid var(--generator-border);
border-radius: 8px;
background: #fff;
}
.poster-steps {
margin-bottom: 32px;
margin-bottom: 0;
}
.mobile-step-summary {
display: none;
color: var(--generator-text);
font-weight: 600;
}
.mobile-step-summary span {
display: block;
margin-top: 4px;
color: var(--generator-muted);
font-size: 13px;
font-weight: 400;
}
.step-content {
width: min(1180px, 100%);
margin: 0 auto;
min-height: 400px;
}
@media (max-width: 767px) {
.poster-page {
padding: 12px;
padding-bottom: 88px;
}
.generator-header {
align-items: stretch;
flex-direction: column;
padding: 16px;
}
.generator-header h1 {
font-size: 18px;
}
.header-actions {
display: grid;
grid-template-columns: 1fr 1fr;
}
.generator-progress {
padding: 14px 16px;
}
.poster-steps {
display: none;
}
.mobile-step-summary {
display: block;
}
}
</style>

View File

@ -1,15 +1,32 @@
<template>
<div class="ppt-page">
<!-- 步骤条 -->
<el-steps :active="currentStep" finish-status="success" align-center class="ppt-steps">
<el-step title="上传" description="选择PDF计划书" />
<el-step title="解析" description="AI智能识别" />
<el-step title="校验" description="查看并确认数据" />
<el-step title="生成" description="选择风格并生成" />
<el-step title="结果" description="下载PPT" />
</el-steps>
<div class="ppt-page generator-page">
<section class="generator-header">
<div>
<p class="section-label">PPT 生成工作台</p>
<h1>把计划书转成可交付的客户方案</h1>
<p class="header-copy">先确认数据可信再选择版式生成避免把未经核对的利益演示交到客户手里</p>
</div>
<div class="header-actions">
<el-button :icon="Clock" @click="router.push('/ppt/history')">历史记录</el-button>
<el-button :icon="RefreshLeft" :disabled="currentStep === 0 && !sessionId" @click="resetAll">重新开始</el-button>
</div>
</section>
<section class="generator-progress">
<div class="mobile-step-summary">
{{ currentStep + 1 }}/{{ steps.length }} {{ steps[currentStep].title }}
<span>{{ steps[currentStep].description }}</span>
</div>
<el-steps :active="currentStep" finish-status="success" align-center class="ppt-steps">
<el-step
v-for="step in steps"
:key="step.title"
:title="step.title"
:description="step.description"
/>
</el-steps>
</section>
<!-- 内容区域 -->
<div class="ppt-content">
<PptUpload
v-if="currentStep === 0"
@ -45,14 +62,24 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { Clock, RefreshLeft } from '@element-plus/icons-vue'
import PptUpload from './components/ppt/PptUpload.vue'
import PptParsing from './components/ppt/PptParsing.vue'
import PptDataReview from './components/ppt/PptDataReview.vue'
import PptGenerate from './components/ppt/PptGenerate.vue'
import PptResult from './components/ppt/PptResult.vue'
const router = useRouter()
const currentStep = ref(0)
const sessionId = ref('')
const steps = [
{ title: '上传', description: '选择 PDF 计划书' },
{ title: '解析', description: 'AI 识别关键信息' },
{ title: '校验', description: '确认风险和数据' },
{ title: '生成', description: '选择版式和公司' },
{ title: '结果', description: '核对并下载' },
]
function onUploaded(id: string) {
sessionId.value = id
@ -79,14 +106,83 @@ function resetAll() {
<style scoped>
.ppt-page {
padding: 20px;
padding: 24px;
height: 100%;
overflow: auto;
display: flex;
flex-direction: column;
gap: 18px;
}
.ppt-steps {
margin-bottom: 24px;
.generator-page {
--generator-accent: #2563eb;
--generator-accent-soft: #eff6ff;
--generator-border: #d9e2ef;
--generator-text: #172033;
--generator-muted: #667085;
}
.generator-header {
width: min(1180px, 100%);
margin: 0 auto;
padding: 20px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
border: 1px solid var(--generator-border);
border-radius: 8px;
background: #fff;
}
.section-label {
margin: 0 0 6px;
color: var(--generator-accent);
font-size: 13px;
font-weight: 600;
}
.generator-header h1 {
margin: 0;
color: var(--generator-text);
font-size: 22px;
line-height: 1.3;
}
.header-copy {
margin: 8px 0 0;
color: var(--generator-muted);
font-size: 14px;
line-height: 1.6;
}
.header-actions {
display: flex;
gap: 10px;
flex-shrink: 0;
}
.generator-progress {
width: min(1180px, 100%);
margin: 0 auto;
padding: 18px 24px;
border: 1px solid var(--generator-border);
border-radius: 8px;
background: #fff;
}
.mobile-step-summary {
display: none;
color: var(--generator-text);
font-weight: 600;
}
.mobile-step-summary span {
display: block;
margin-top: 4px;
color: var(--generator-muted);
font-size: 13px;
font-weight: 400;
}
.ppt-content {
@ -95,4 +191,38 @@ function resetAll() {
align-items: flex-start;
justify-content: center;
}
@media (max-width: 767px) {
.ppt-page {
padding: 12px;
padding-bottom: 88px;
}
.generator-header {
align-items: stretch;
flex-direction: column;
padding: 16px;
}
.generator-header h1 {
font-size: 18px;
}
.header-actions {
display: grid;
grid-template-columns: 1fr 1fr;
}
.generator-progress {
padding: 14px 16px;
}
.ppt-steps {
display: none;
}
.mobile-step-summary {
display: block;
}
}
</style>

View File

@ -60,6 +60,18 @@
<el-input v-model="form.previewImage" />
</el-form-item>
</el-form>
<div class="poster-preview-card">
<div class="preview-frame" :style="{ background: previewBackground }">
<span class="preview-kicker">{{ form.scenarioTag || '销售场景' }}</span>
<strong>{{ form.name || '模板名称' }}</strong>
<p>{{ form.styleDescription || '保存前预览模板风格描述和配色气质。' }}</p>
<span class="preview-cta">行动号召</span>
</div>
<div class="preview-copy">
<h4>模板预览</h4>
<p>配色方案建议使用 JSON例如 {"primary":"#172033","accent":"#2563eb"}</p>
</div>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="onSave" :loading="saving">保存</el-button>
@ -69,7 +81,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { pptAdminApi } from '@/utils/ppt-admin-api'
@ -83,6 +95,10 @@ const dialogVisible = ref(false)
const editRow = ref<any>(null)
const form = ref<any>({})
const saving = ref(false)
const previewBackground = computed(() => {
const colors = parseColorScheme(form.value.colorSchemeStr)
return `linear-gradient(150deg, ${colors.primary}, ${colors.accent})`
})
onMounted(() => loadData())
@ -112,7 +128,12 @@ async function onSave() {
try {
const payload: any = { ...form.value }
if (payload.colorSchemeStr) {
try { payload.colorScheme = JSON.parse(payload.colorSchemeStr) } catch { /* keep string */ }
try {
payload.colorScheme = JSON.parse(payload.colorSchemeStr)
} catch {
ElMessage.error('配色方案不是有效 JSON请修正后保存')
return
}
}
delete payload.colorSchemeStr
if (editRow.value) {
@ -135,4 +156,87 @@ async function onToggleStatus(row: any) {
ElMessage.success('操作成功')
loadData()
}
function parseColorScheme(source?: string) {
if (!source) return { primary: '#172033', accent: '#2563eb' }
try {
const value = JSON.parse(source)
return {
primary: value?.primary || '#172033',
accent: value?.accent || '#2563eb',
}
} catch {
return { primary: '#172033', accent: '#dc2626' }
}
}
</script>
<style scoped>
.poster-preview-card {
margin-top: 16px;
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 16px;
padding: 16px;
border: 1px solid #d9e2ef;
border-radius: 8px;
background: #f8fafc;
}
.preview-frame {
min-height: 240px;
padding: 18px;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 10px;
border-radius: 8px;
color: #fff;
}
.preview-kicker {
font-size: 12px;
opacity: .78;
}
.preview-frame strong {
font-size: 22px;
line-height: 1.25;
}
.preview-frame p {
margin: 0;
font-size: 12px;
line-height: 1.55;
opacity: .9;
}
.preview-cta {
align-self: flex-start;
padding: 7px 10px;
border-radius: 8px;
color: #172033;
background: rgb(255 255 255 / 92%);
font-size: 12px;
font-weight: 700;
}
.preview-copy h4 {
margin: 0 0 8px;
color: #172033;
font-size: 15px;
}
.preview-copy p {
margin: 0;
color: #667085;
font-size: 13px;
line-height: 1.6;
}
@media (max-width: 767px) {
.poster-preview-card {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -1,10 +1,28 @@
<template>
<div class="page-container">
<div class="page-header">
<h2>PPT/海报模型设置</h2>
<div>
<h2>PPT/海报模型设置</h2>
<p>按用途配置模型并用同步模型检查供应商和 API Key 是否可用</p>
</div>
</div>
<el-form v-loading="loading" :model="form" label-width="140px" style="max-width: 680px">
<div class="settings-summary">
<div class="summary-card">
<strong>PPT 解析</strong>
<span>{{ form.ppt_llm_provider }} / {{ form.ppt_llm_model || '未设置模型' }}</span>
</div>
<div class="summary-card">
<strong>海报文案</strong>
<span>{{ form.poster_llm_provider }} / {{ form.poster_llm_model || '未设置模型' }}</span>
</div>
<div class="summary-card">
<strong>海报图片</strong>
<span>{{ form.poster_image_provider }} / {{ form.poster_image_model || '未设置模型' }}</span>
</div>
</div>
<el-form v-loading="loading" :model="form" label-width="140px" class="settings-form">
<!-- PPT 解析模型 -->
<el-divider content-position="left">PPT 解析模型</el-divider>
@ -306,9 +324,56 @@ onMounted(loadSettings)
margin: 0;
font-size: 20px;
}
.page-header p {
margin: 8px 0 0;
color: #667085;
font-size: 13px;
}
.settings-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-bottom: 20px;
}
.summary-card {
padding: 16px;
border: 1px solid #d9e2ef;
border-radius: 8px;
background: #fff;
}
.summary-card strong {
display: block;
color: #172033;
font-size: 15px;
}
.summary-card span {
display: block;
margin-top: 8px;
color: #667085;
font-size: 13px;
word-break: break-all;
}
.settings-form {
max-width: 880px;
padding: 24px;
border: 1px solid #d9e2ef;
border-radius: 8px;
background: #fff;
}
.form-tip {
margin-left: 12px;
color: #909399;
font-size: 13px;
}
@media (max-width: 767px) {
.page-container {
padding: 12px;
}
.settings-summary {
grid-template-columns: 1fr;
}
.settings-form {
padding: 16px;
}
}
</style>

View File

@ -12,7 +12,7 @@
<el-table-column prop="name" label="名称" min-width="150" />
<el-table-column prop="planType" label="类型" width="80">
<template #default="{ row }">
<el-tag size="small">{{ { savings: '储蓄', ci: '重疾', iul: 'IUL' }[row.planType] || row.planType }}</el-tag>
<el-tag size="small">{{ planTypeLabel(row.planType) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="stylePreset" label="风格" width="100" />
@ -85,8 +85,8 @@
</el-select>
<el-input v-model="slide.title" size="small" placeholder="页面标题" style="flex:1; margin: 0 8px" />
<el-button size="small" type="danger" text @click="slidesList.splice(idx, 1)">删除</el-button>
<el-button size="small" text :disabled="idx === 0" @click="moveSlide(idx, -1)"></el-button>
<el-button size="small" text :disabled="idx === slidesList.length - 1" @click="moveSlide(idx, 1)"></el-button>
<el-button size="small" text :disabled="idx === 0" @click="moveSlide(idx, -1)">上移</el-button>
<el-button size="small" text :disabled="idx === slidesList.length - 1" @click="moveSlide(idx, 1)">下移</el-button>
</div>
<div class="slide-item-detail">
<el-input v-model="slide.narrativeHint" size="small" placeholder="叙事提示30字内" style="width: 50%" />
@ -107,6 +107,24 @@
</div>
</div>
</el-form>
<div class="template-preview-card">
<div class="preview-head">
<div>
<h4>页面结构预览</h4>
<p>保存前检查页数页面类型和叙事顺序</p>
</div>
<el-tag type="info">{{ slidesList.length }} </el-tag>
</div>
<div class="preview-slides">
<div v-for="(slide, idx) in slidesList" :key="idx" class="preview-slide">
<span class="preview-index">{{ idx + 1 }}</span>
<div>
<strong>{{ slide.title || '未命名页面' }}</strong>
<p>{{ slideTypeLabel(slide.pageType) }}{{ slide.narrativeHint ? `${slide.narrativeHint}` : '' }}</p>
</div>
</div>
</div>
</div>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="onSave" :loading="saving">保存</el-button>
@ -219,6 +237,14 @@ function resetSlides() {
slidesList.value = JSON.parse(JSON.stringify(defaultSlides[form.value.planType] || defaultSlides.savings))
}
function slideTypeLabel(value: string) {
return pageTypes.find(item => item.value === value)?.label || value || '未设置类型'
}
function planTypeLabel(value: string) {
return ({ savings: '储蓄', ci: '重疾', iul: 'IUL' } as Record<string, string>)[value] || value
}
async function onSave() {
saving.value = true
try {
@ -280,4 +306,68 @@ async function onToggleStatus(row: any) {
display: flex;
align-items: center;
}
.template-preview-card {
margin-top: 16px;
padding: 16px;
border: 1px solid #d9e2ef;
border-radius: 8px;
background: #f8fafc;
}
.preview-head {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.preview-head h4 {
margin: 0;
color: #172033;
font-size: 15px;
}
.preview-head p,
.preview-slide p {
margin: 4px 0 0;
color: #667085;
font-size: 12px;
line-height: 1.5;
}
.preview-slides {
display: grid;
gap: 8px;
max-height: 240px;
overflow: auto;
}
.preview-slide {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
padding: 10px;
border: 1px solid #e5eaf3;
border-radius: 8px;
background: #fff;
}
.preview-index {
width: 26px;
height: 26px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
background: #2563eb;
font-size: 12px;
font-weight: 700;
}
.preview-slide strong {
color: #172033;
font-size: 13px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -12,18 +12,21 @@
<div class="style-section">
<h4>选择视觉风格</h4>
<div class="style-grid">
<div
<button
v-for="style in styles"
:key="style.id"
type="button"
class="style-item"
:class="{ active: selectedStyle === style.id }"
@click="selectedStyle = style.id"
>
<div class="style-preview" :style="{ background: style.gradient }">
<span class="style-icon">{{ style.icon }}</span>
<span class="style-line"></span>
<span class="style-line short"></span>
</div>
<span class="style-name">{{ style.name }}</span>
</div>
<span class="style-desc">{{ style.description }}</span>
</button>
</div>
</div>
@ -80,11 +83,11 @@ const emit = defineEmits<{
}>()
const styles = [
{ id: 'deepblue', name: '深海蓝', icon: '🌊', gradient: 'linear-gradient(135deg, #0a1c32, #0f2846)' },
{ id: 'caramel', name: '焦糖色', icon: '☕', gradient: 'linear-gradient(135deg, #f8f5ef, #f0ebe1)' },
{ id: 'chinese', name: '中国红', icon: '🏮', gradient: 'linear-gradient(135deg, #b41e1e, #8c1414)' },
{ id: 'broker', name: '券商风', icon: '💎', gradient: 'linear-gradient(135deg, #1a1a2e, #16213e)' },
{ id: 'modern', name: '现代风', icon: '🏢', gradient: 'linear-gradient(135deg, #2c3e50, #3498db)' },
{ id: 'deepblue', name: '深海蓝', description: '稳健信任', gradient: 'linear-gradient(135deg, #0a1c32, #0f2846)' },
{ id: 'caramel', name: '焦糖色', description: '温暖柔和', gradient: 'linear-gradient(135deg, #f8f5ef, #d7c7ac)' },
{ id: 'chinese', name: '中国红', description: '节庆正式', gradient: 'linear-gradient(135deg, #b41e1e, #8c1414)' },
{ id: 'broker', name: '券商风', description: '专业路演', gradient: 'linear-gradient(135deg, #172033, #28364f)' },
{ id: 'modern', name: '现代风', description: '清晰简洁', gradient: 'linear-gradient(135deg, #1e3a5f, #2563eb)' },
]
const selectedStyle = ref('broker')
@ -152,7 +155,7 @@ async function handleGenerate() {
.style-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
gap: 12px;
}
@ -162,10 +165,12 @@ async function handleGenerate() {
align-items: center;
gap: 8px;
cursor: pointer;
padding: 8px;
padding: 10px;
border-radius: 8px;
border: 2px solid transparent;
background: #fff;
transition: all 0.2s;
text-align: center;
}
.style-item:hover {
@ -183,14 +188,12 @@ async function handleGenerate() {
min-width: 60px;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
justify-content: center;
}
.style-item {
min-width: 100px;
}
:deep(.el-button) {
min-height: 44px;
}
@ -199,15 +202,29 @@ async function handleGenerate() {
font-size: 16px;
}
.style-icon {
font-size: 24px;
.style-line {
width: 34px;
height: 4px;
border-radius: 4px;
background: rgb(255 255 255 / 82%);
}
.style-line.short {
width: 22px;
opacity: .7;
}
.style-name {
font-size: 12px;
font-weight: 700;
color: #606266;
}
.style-desc {
color: #909399;
font-size: 11px;
}
.generate-actions {
display: flex;
justify-content: space-between;

View File

@ -1,76 +1,73 @@
<template>
<div class="ppt-result">
<!-- 交付确认区 -->
<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>
<!-- 产品摘要 -->
<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>
<el-card shadow="never" class="result-card">
<template #header>
<div class="card-header">
<div>
<div class="title-row">
<el-icon><SuccessFilled /></el-icon>
<span>PPT 已生成</span>
</div>
<p>下载前请完成最后核对确保客户版材料可以直接交付</p>
</div>
<el-tag type="success" size="large">已完成</el-tag>
</div>
</div>
</div>
</template>
<!-- 下载前核对提示 -->
<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>
<section class="delivery-hero">
<div class="deck-preview" aria-hidden="true">
<div class="slide-sheet slide-main">
<span>客户方案</span>
<strong>{{ slideCount || '-' }} </strong>
</div>
<div class="slide-sheet slide-back"></div>
<div class="slide-sheet slide-tail"></div>
</div>
<div class="delivery-summary">
<h3>这份 PPT 已准备好下载</h3>
<p>系统已完成生成建议先核对产品名称客户信息利益演示数据和页数结构再发给客户</p>
<div class="result-actions">
<el-button type="primary" size="large" @click="downloadPpt" :loading="downloading">
<el-icon><Download /></el-icon>
{{ downloading ? `下载中 ${downloadProgress}%` : '下载 PPT' }}
</el-button>
<el-button @click="$emit('regenerate')">重新选择样式</el-button>
<el-button @click="$emit('new-session')">新建材料</el-button>
</div>
<el-progress v-if="downloading" :percentage="downloadProgress" class="download-progress" />
</div>
</section>
<!-- 下载按钮唯一主按钮 -->
<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 文件' }}
</el-button>
<el-progress v-if="downloading" :percentage="downloadProgress" :stroke-width="6" style="max-width: 300px; margin-top: 12px" />
</div>
<!-- 次要操作 -->
<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" size="small">已完成</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ createdAt }}</el-descriptions-item>
</el-descriptions>
</el-collapse-item>
</el-collapse>
<section class="delivery-grid">
<div class="delivery-card">
<h4>交付核对</h4>
<ul class="check-list">
<li v-for="item in checklist" :key="item">
<span class="check-mark"></span>
{{ item }}
</li>
</ul>
</div>
<div class="delivery-card">
<h4>文件信息</h4>
<el-descriptions :column="1" border class="session-info">
<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-descriptions-item>
<el-descriptions-item label="创建时间">{{ createdAt || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
</section>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { SuccessFilled, Download, InfoFilled } from '@element-plus/icons-vue'
import { SuccessFilled, Download } from '@element-plus/icons-vue'
import { pptApi } from '@/utils/ppt-api'
const props = defineProps<{
@ -86,18 +83,12 @@ 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',
}
const checklist = [
'客户姓名和产品名称已核对',
'保费、缴费年期和回本年份已核对',
'利益演示表与原计划书一致',
'生成内容已完成合规人工确认',
]
onMounted(async () => {
try {
@ -105,16 +96,8 @@ 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 {
//
ElMessage.warning('文件信息暂时无法加载,仍可尝试下载')
}
})
@ -132,6 +115,7 @@ async function downloadPpt() {
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('PPT 下载已开始')
} catch (error) {
console.error('下载失败:', error)
ElMessage.error('PPT 下载失败,请重试')
@ -144,109 +128,187 @@ async function downloadPpt() {
<style scoped>
.ppt-result {
width: 100%;
max-width: 700px;
max-width: 960px;
}
.delivery-header {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 24px;
background: #f0f9eb;
border: 1px solid #e1f3d8;
.result-card {
width: 100%;
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;
border-color: #d9e2ef;
}
.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 {
.card-header {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.product-chip {
.title-row {
display: flex;
align-items: center;
gap: 8px;
}
.chip-name {
font-weight: 600;
font-size: 14px;
color: #303133;
}
.chip-meta {
font-size: 12px;
color: #909399;
color: #172033;
font-size: 18px;
font-weight: 700;
}
.checklist-box {
margin-bottom: 20px;
padding: 16px;
background: #fdf6ec;
border: 1px solid #faecd8;
border-radius: 8px;
.card-header p,
.delivery-summary p {
margin: 6px 0 0;
color: #667085;
line-height: 1.6;
}
.checklist-title {
display: flex;
.delivery-hero {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 28px;
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;
padding: 22px;
border: 1px solid #d9e2ef;
border-radius: 8px;
background: #f8fafc;
}
.download-area {
.deck-preview {
position: relative;
height: 180px;
}
.slide-sheet {
position: absolute;
width: 220px;
height: 140px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #fff;
box-shadow: 0 16px 36px rgb(15 23 42 / 10%);
}
.slide-main {
z-index: 3;
left: 0;
top: 8px;
padding: 22px;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0;
}
.download-btn {
min-width: 200px;
justify-content: space-between;
background:
linear-gradient(90deg, #2563eb 0 8px, transparent 8px),
linear-gradient(135deg, #ffffff, #eff6ff);
}
.secondary-actions {
.slide-main span {
color: #667085;
font-size: 13px;
}
.slide-main strong {
color: #172033;
font-size: 34px;
}
.slide-back {
z-index: 2;
left: 22px;
top: 24px;
}
.slide-tail {
z-index: 1;
left: 44px;
top: 40px;
}
.delivery-summary h3 {
margin: 0;
color: #172033;
font-size: 24px;
}
.result-actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 8px;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
}
@media (max-width: 768px) {
.delivery-header {
padding: 16px;
.download-progress {
max-width: 360px;
margin-top: 14px;
}
.delivery-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, .9fr);
gap: 16px;
margin-top: 18px;
}
.delivery-card {
padding: 18px;
border: 1px solid #d9e2ef;
border-radius: 8px;
background: #fff;
}
.delivery-card h4 {
margin: 0 0 14px;
color: #172033;
font-size: 16px;
}
.check-list {
display: grid;
gap: 10px;
padding: 0;
margin: 0;
list-style: none;
}
.check-list li {
display: flex;
align-items: center;
gap: 10px;
color: #344054;
}
.check-mark {
width: 22px;
height: 22px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
background: #16a34a;
font-size: 13px;
font-weight: 800;
flex-shrink: 0;
}
:deep(.el-button) {
min-height: 40px;
}
@media (max-width: 767px) {
.delivery-hero,
.delivery-grid {
grid-template-columns: 1fr;
}
.delivery-main {
font-size: 16px;
.card-header {
flex-direction: column;
}
.deck-preview {
height: 156px;
}
.result-actions {
display: grid;
grid-template-columns: 1fr;
}
}
</style>

View File

@ -28,7 +28,7 @@
:auto-upload="false"
:limit="1"
accept=".pdf"
:on-change="(file) => handleFileChange(file, 'savings')"
:on-change="(file: any) => handleFileChange(file, 'savings')"
:on-remove="() => removeFile('savings')"
class="port-upload"
>
@ -63,7 +63,7 @@
:auto-upload="false"
:limit="1"
accept=".pdf"
:on-change="(file) => handleFileChange(file, 'ci')"
:on-change="(file: any) => handleFileChange(file, 'ci')"
:on-remove="() => removeFile('ci')"
class="port-upload"
>
@ -97,7 +97,7 @@
:auto-upload="false"
:limit="1"
accept=".pdf"
:on-change="(file) => handleFileChange(file, 'iul')"
:on-change="(file: any) => handleFileChange(file, 'iul')"
:on-remove="() => removeFile('iul')"
class="port-upload"
>

View File

@ -45,7 +45,7 @@ export const posterApi = {
},
/** 生成海报 */
generatePoster(params: { caseUploadId?: number; templateId?: number; copyContent?: any; size?: string; productId?: string; referenceImage?: string; copyMode?: string }) {
generatePoster(params: { caseUploadId?: number; templateId?: number; copyContent?: any; aiRawContent?: any; size?: string; productId?: string; referenceImage?: string; copyMode?: string }) {
return api.post('/poster/generate', params)
},

View File

@ -99,7 +99,7 @@ export const pptAdminApi = {
getSettings() {
return api.get('/admin/ppt/settings')
},
updateSettings(data: Record<string, string>) {
updateSettings(data: Record<string, any>) {
return api.put('/admin/ppt/settings', data)
},
getAvailableModels() {