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

View File

@ -200,7 +200,7 @@ async function onCreateSession() {
if (creatingSession.value) return if (creatingSession.value) return
creatingSession.value = true creatingSession.value = true
try { 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 const sessionId = res.data?.session_id || res.data?.data?.session_id || res.session_id
if (!sessionId) { if (!sessionId) {
throw new Error('服务端未返回 session_id') throw new Error('服务端未返回 session_id')

View File

@ -1,68 +1,72 @@
<template> <template>
<div class="step-preview"> <div class="step-preview">
<!-- 等待生成 --> <div class="step-head">
<div v-if="taskStatus === 'idle'" class="generate-section"> <div>
<div class="generate-prompt"> <h3>预览与导出</h3>
<el-icon :size="48" color="#c0c4cc"><Picture /></el-icon> <p>生成前确认尺寸模板和文案生成完成后可直接下载图片</p>
<p class="prompt-text">点击下方按钮开始生成海报</p>
<p class="prompt-meta">尺寸{{ sizeLabel }} · 生成约需 10-30 </p>
</div> </div>
<el-button type="primary" size="large" @click="onGenerate"> <el-tag :type="taskStatus === 'done' ? 'success' : 'info'">{{ statusLabel }}</el-tag>
<el-icon><Picture /></el-icon>
</el-button>
</div> </div>
<!-- 生成中异步轮询 --> <div class="preview-workbench">
<div v-else-if="taskStatus === 'queued' || taskStatus === 'generating'" class="generate-section"> <section class="poster-stage">
<el-progress :percentage="taskProgress" :stroke-width="12" style="max-width: 400px; margin: 0 auto 16px" /> <div v-if="taskStatus === 'idle'" class="draft-preview" :class="sizeClass">
<p class="status-text">{{ taskStatus === 'queued' ? '任务排队中...' : '海报生成中(约 10-30 秒)...' }}</p> <div class="draft-brand">{{ templateName || '海报模板' }}</div>
<p v-if="pollCount > 30" class="timeout-hint">生成时间较长请耐心等待或稍后查看历史记录</p> <div class="draft-title">{{ copyContent?.headline || '客户专属保障方案' }}</div>
</div> <div class="draft-body">{{ copyContent?.body || '文案将在生成时写入海报。' }}</div>
<div class="draft-cta">{{ copyContent?.call_to_action || '联系顾问了解详情' }}</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> </div>
<div class="meta-row">
<span class="meta-label">生成方式</span> <div v-else-if="taskStatus === 'queued' || taskStatus === 'generating'" class="generate-state">
<span class="meta-value">{{ generationMode || 'AI 生图' }}</span> <el-progress :percentage="taskProgress" :stroke-width="10" />
<p>{{ taskStatus === 'queued' ? '任务排队中...' : '海报生成中,预计 10 到 30 秒' }}</p>
</div> </div>
</div>
<!-- 下载按钮唯一主按钮 --> <el-result v-else-if="taskStatus === 'failed'" icon="error" title="生成失败" :sub-title="taskError || '请重试'">
<div class="download-area"> <template #extra>
<el-button type="primary" size="large" @click="onDownload" class="download-btn"> <el-button type="primary" @click="onGenerate">重新生成</el-button>
<el-icon><Download /></el-icon> </template>
</el-button> </el-result>
</div>
<!-- 次要操作 --> <div v-else-if="taskStatus === 'done' && posterObjectUrl" class="result-section">
<div class="secondary-actions"> <el-image :src="posterObjectUrl" fit="contain" class="poster-image" />
<el-button plain @click="$emit('back')">返回修改文案</el-button> </div>
<el-button plain @click="clearPoster">重新生成</el-button> </section>
</div>
</div>
<div v-if="taskStatus === 'idle'" class="step-actions"> <aside class="delivery-panel">
<el-button @click="$emit('back')">上一步</el-button> <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>
</div> </div>
</template> </template>
@ -79,27 +83,43 @@ const props = defineProps<{
templateId: number | null templateId: number | null
copyContent: any copyContent: any
aiRawContent?: any aiRawContent?: any
size?: string initialSize?: string
templateName?: string
scenarioLabel?: string
}>() }>()
defineEmits<{ back: [] }>() defineEmits<{ back: [] }>()
const selectedSize = ref(props.initialSize || '1024x1792')
const taskStatus = ref<'idle' | 'queued' | 'generating' | 'done' | 'failed'>('idle') const taskStatus = ref<'idle' | 'queued' | 'generating' | 'done' | 'failed'>('idle')
const taskProgress = ref(0) const taskProgress = ref(0)
const taskError = ref<string | null>(null) const taskError = ref<string | null>(null)
const posterObjectUrl = ref<string | null>(null) const posterObjectUrl = ref<string | null>(null)
const recordId = ref<number | 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 let pollTimer: ReturnType<typeof setInterval> | null = null
const MAX_POLL_RETRIES = 90 // 90 = 3 const checklist = [
'标题和正文没有夸大收益',
const sizeLabelMap: Record<string, string> = { '产品名称和客户场景匹配',
'1024x1792': '竖版 1080×1920', '尺寸适合本次投放渠道',
'1792x1024': '横版 900×500', '行动号召清晰且合规',
'1024x1024': '方图 1080×1080', ]
} const statusLabel = computed(() => {
const sizeLabel = computed(() => sizeLabelMap[props.size || ''] || props.size || '1024x1792') 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() { function clearPoster() {
stopPolling() stopPolling()
@ -111,8 +131,6 @@ function clearPoster() {
taskStatus.value = 'idle' taskStatus.value = 'idle'
taskProgress.value = 0 taskProgress.value = 0
taskError.value = null taskError.value = null
generationMode.value = null
pollCount.value = 0
} }
function stopPolling() { function stopPolling() {
@ -126,16 +144,16 @@ async function onGenerate() {
taskStatus.value = 'queued' taskStatus.value = 'queued'
taskProgress.value = 0 taskProgress.value = 0
taskError.value = null taskError.value = null
pollCount.value = 0
try { try {
const res: any = await posterApi.generatePoster({ const res: any = await posterApi.generatePoster({
caseUploadId: props.caseUploadId ?? undefined, caseUploadId: props.caseUploadId ?? undefined,
productId: props.productId, productId: props.productId,
templateId: props.templateId ?? undefined, templateId: props.templateId ?? undefined,
copyContent: props.copyContent, copyContent: props.copyContent,
size: props.size || '1024x1792', aiRawContent: props.aiRawContent,
copyMode: 'ai', size: selectedSize.value,
} as any) copyMode: props.aiRawContent ? 'ai' : 'template',
})
const record = res?.data const record = res?.data
recordId.value = record?.id recordId.value = record?.id
if (record?.id) { if (record?.id) {
@ -149,23 +167,13 @@ async function onGenerate() {
function startPolling(id: number) { function startPolling(id: number) {
stopPolling() stopPolling()
pollCount.value = 0
pollTimer = setInterval(async () => { pollTimer = setInterval(async () => {
pollCount.value++
//
if (pollCount.value > MAX_POLL_RETRIES) {
stopPolling()
taskStatus.value = 'failed'
taskError.value = '生成超时,请查看历史记录或重新生成'
return
}
try { try {
const res: any = await posterApi.getRecord(id) const res: any = await posterApi.getRecord(id)
const data = res?.data const data = res?.data
taskStatus.value = data?.taskStatus || 'generating' taskStatus.value = data?.taskStatus || 'generating'
taskProgress.value = data?.taskProgress || 0 taskProgress.value = data?.taskProgress || 0
taskError.value = data?.taskError || null taskError.value = data?.taskError || null
generationMode.value = data?.generationMode || null
if (taskStatus.value === 'done') { if (taskStatus.value === 'done') {
stopPolling() stopPolling()
@ -180,7 +188,7 @@ function startPolling(id: number) {
stopPolling() stopPolling()
} }
} catch { } catch {
// //
} }
}, 2000) }, 2000)
} }
@ -209,98 +217,198 @@ onBeforeUnmount(() => {
</script> </script>
<style scoped> <style scoped>
.step-preview { .step-head {
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; display: flex;
justify-content: center; justify-content: space-between;
gap: 24px; align-items: flex-start;
margin-bottom: 20px; gap: 16px;
padding: 12px 16px; margin-bottom: 16px;
background: #f0f9eb;
border: 1px solid #e1f3d8;
border-radius: 8px;
} }
.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; display: flex;
align-items: center; 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; justify-content: center;
gap: 12px;
} }
.step-actions { .draft-preview {
margin-top: 24px; width: 320px;
text-align: right; 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) { .draft-preview.landscape {
.delivery-meta { 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; 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> </style>

View File

@ -1,6 +1,11 @@
<template> <template>
<div class="step-product"> <div class="step-product">
<h3>选择产品</h3> <div class="step-head">
<div>
<h3>选择产品</h3>
<p>选择本次海报要沟通的产品后续文案会围绕该产品生成</p>
</div>
</div>
<el-skeleton :loading="loading" animated :rows="3"> <el-skeleton :loading="loading" animated :rows="3">
<template #template> <template #template>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px"> <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"> <div v-for="group in grouped" :key="group.companyId" class="product-group">
<h4 class="group-title">{{ group.companyName }}</h4> <h4 class="group-title">{{ group.companyName }}</h4>
<div class="product-cards"> <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 }" class="product-card" :class="{ active: selectedId === p.id }"
@click="selectedId = p.id"> @click="selectedId = p.id">
<div class="product-name">{{ p.displayName }}</div> <div class="product-name">{{ p.displayName }}</div>
<div class="product-type"> <div class="product-type">
<el-tag size="small">{{ typeMap[p.planType] || p.planType }}</el-tag> <el-tag size="small">{{ typeMap[p.planType] || p.planType }}</el-tag>
</div> </div>
</div> </button>
</div> </div>
</div> </div>
</div> </div>
@ -72,15 +78,64 @@ onMounted(async () => {
</script> </script>
<style scoped> <style scoped>
.step-actions { margin-top: 24px; text-align: right; } .step-head {
.empty-tip { text-align: center; color: #909399; padding: 40px; } margin-bottom: 16px;
.group-title { font-size: 14px; color: #606266; margin: 16px 0 8px; } }
.product-cards { display: flex; flex-wrap: wrap; gap: 12px; } .step-head h3 {
.product-card { margin: 0;
padding: 12px 16px; border: 2px solid #e4e7ed; border-radius: 8px; color: #172033;
cursor: pointer; transition: all 0.2s; min-width: 140px; 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> </style>

View File

@ -1,136 +1,130 @@
<template> <template>
<div class="step-template"> <div class="step-template">
<!-- 尺寸选择前置 --> <div class="step-head">
<div class="size-bar"> <div>
<span class="size-label">海报尺寸</span> <h3>选择模板与文案</h3>
<el-radio-group v-model="selectedSize" size="default"> <p>先确定销售场景和海报比例再生成或填充文案</p>
<el-radio-button value="1024x1792">竖版 1080×1920</el-radio-button> </div>
<el-radio-button value="1792x1024">横版 900×500</el-radio-button> <el-tag type="info">生成前确认</el-tag>
<el-radio-button value="1024x1024">方图 1080×1080</el-radio-button>
</el-radio-group>
</div> </div>
<div class="template-layout"> <div class="template-workbench">
<!-- 左侧模板选择 --> <section class="template-panel">
<div class="left-panel"> <div class="panel-title">
<div class="panel-title">选择模板</div> <strong>海报尺寸</strong>
<div v-if="!templates.length" class="empty-hint"> <span>尺寸会影响模板构图请在生成前确认</span>
<el-empty description="暂无模板" :image-size="60" />
</div> </div>
<div class="template-grid" v-else> <el-segmented v-model="selectedSize" :options="sizeOptions" class="size-control" />
<div v-for="t in templates" :key="t.id"
class="template-card" :class="{ active: selectedTemplateId === t.id }" <div class="panel-title template-title">
@click="selectedTemplateId = t.id" role="button" tabindex="0" <strong>销售场景</strong>
@keydown.enter="selectedTemplateId = t.id"> <span>场景会影响文案语气和用户预期</span>
<el-image v-if="t.previewImage" :src="t.previewImage" style="width: 100%; aspect-ratio: 3/4" fit="cover" /> </div>
<div v-else class="template-placeholder" :style="{ background: t.colorScheme?.primary || '#1a3a5c' }"> <el-segmented v-model="selectedScenario" :options="scenarioOptions" class="scenario-control" />
<el-icon :size="28" color="rgba(255,255,255,0.6)"><Picture /></el-icon>
</div> <div class="panel-title template-title">
<div class="template-info"> <strong>海报模板</strong>
<div class="template-name">{{ t.name }}</div> <span>选择后右侧会展示模板气质和文案编辑区</span>
<div class="template-tags"> </div>
<el-tag v-if="t.scenarioTag" size="small" type="info" effect="plain">{{ sceneLabel(t.scenarioTag) }}</el-tag>
</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> </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> </div>
<!-- 选中模板预览 --> <div class="copy-editor">
<div v-if="selectedTemplate" class="selected-preview"> <div class="panel-title">
<div class="preview-label">当前模板</div> <strong>文案生成</strong>
<div class="preview-card"> <span>AI 文案需要人工确认后再导出</span>
<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> <el-radio-group v-model="copyMode" class="mode-control">
</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="template">模板文案</el-radio-button>
<el-radio-button value="ai">AI 生成</el-radio-button> <el-radio-button value="ai">AI 生成</el-radio-button>
</el-radio-group> </el-radio-group>
</div>
<!-- 模板模式 --> <div v-if="copyMode === 'template'" class="copy-source">
<div v-if="copyMode === 'template'" class="copy-template-section"> <el-select v-model="selectedCopyTemplateId" placeholder="选择文案模板" style="width: 100%">
<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-option v-for="ct in copyTemplates" :key="ct.id" :label="ct.name" :value="ct.id" /> </el-select>
</el-select> <el-input v-if="templatePreview" v-model="templatePreview" type="textarea" :rows="4" readonly />
<div v-if="templatePreview" class="copy-preview"> <el-button type="primary" @click="onGenerateCopy" :loading="generatingCopy" :disabled="!selectedCopyTemplateId">
<el-input v-model="templatePreview" type="textarea" :rows="3" readonly /> 填充文案
</el-button>
</div> </div>
<el-button type="primary" @click="onGenerateCopy" :loading="generatingCopy" :disabled="!selectedCopyTemplateId">
填充文案
</el-button>
</div>
<!-- AI 模式 --> <div v-else class="copy-source">
<div v-else class="copy-ai-section"> <el-segmented v-model="aiStyle" :options="styleOptions" />
<div class="ai-style-presets"> <el-button type="primary" @click="onGenerateCopy" :loading="generatingCopy">
<span class="preset-label">风格选择</span> AI 生成文案
<div class="preset-chips"> </el-button>
<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> </div>
<el-input v-model="aiStyle" placeholder="自定义风格描述" style="margin-bottom: 12px" />
<el-button type="primary" @click="onGenerateCopy" :loading="generatingCopy">
AI 生成文案
</el-button>
</div>
<!-- 生成结果 --> <el-alert
<div v-if="copyResult" class="copy-result"> class="compliance-alert"
<el-divider>生成的文案</el-divider> title="合规提示"
<el-form label-width="80px" size="default"> type="warning"
<el-form-item label="标题"> description="海报文案涉及保险利益和销售表达,导出前请人工确认标题、卖点和行动号召。"
<el-input v-model="copyResult.headline" /> show-icon
</el-form-item> :closable="false"
<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>
<!-- 高级设置默认折叠 --> <div v-if="copyResult" class="copy-result">
<el-collapse style="margin-top: 16px"> <el-form label-position="top">
<el-collapse-item title="高级设置" name="advanced"> <el-form-item label="标题">
<el-form label-width="80px" size="small"> <el-input v-model="copyResult.headline" />
<el-form-item label="参考图"> </el-form-item>
<el-input v-model="referenceImage" placeholder="参考图 URL可选" /> <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-item>
</el-form> </el-form>
</el-collapse-item> </div>
</el-collapse> </div>
</div> </aside>
</div> </div>
<!-- 底部操作 -->
<div class="step-actions"> <div class="step-actions">
<el-button @click="$emit('back')">上一步</el-button> <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> </el-button>
</div> </div>
@ -140,54 +134,67 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Picture, InfoFilled } from '@element-plus/icons-vue'
import { posterApi } from '@/utils/poster-api' import { posterApi } from '@/utils/poster-api'
const props = defineProps<{ caseUploadId: number | null; productId: string }>() 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 templates = ref<any[]>([])
const copyTemplates = ref<any[]>([]) const copyTemplates = ref<any[]>([])
const selectedTemplateId = ref<number | null>(null) const selectedTemplateId = ref<number | null>(null)
const selectedSize = ref('1024x1792') const selectedSize = ref('1024x1792')
const selectedScenario = ref('朋友圈沟通')
const copyMode = ref('template') const copyMode = ref('template')
const selectedCopyTemplateId = ref<number | null>(null) const selectedCopyTemplateId = ref<number | null>(null)
const templatePreview = ref('') const templatePreview = ref('')
const aiStyle = ref('专业稳健') const aiStyle = ref('专业稳健')
const generatingCopy = ref(false) const generatingCopy = ref(false)
const loading = ref(false)
const copyResult = ref<any>(null) const copyResult = ref<any>(null)
const aiRawContent = ref<any>(null) const aiRawContent = ref<any>(null)
const referenceImage = ref('')
const aiStylePresets = [ const sizeOptions = [
{ label: '专业稳健', value: '专业稳健' }, { label: '竖版', value: '1024x1792' },
{ label: '温暖顾问', value: '温暖顾问' }, { label: '横版', value: '1792x1024' },
{ label: '高端资产配置', value: '高端资产配置' }, { label: '方图', value: '1024x1024' },
{ label: '简洁朋友圈', value: '简洁朋友圈' },
] ]
const scenarioOptions = ['朋友圈沟通', '客户私聊', '讲座邀约', '产品卖点']
const sceneLabelMap: Record<string, string> = { const styleOptions = ['专业稳健', '温暖顾问', '高端资产配置', '简洁朋友圈']
business: '商务', const selectedTemplate = computed(() => templates.value.find((t: any) => t.id === selectedTemplateId.value))
family: '家庭', const selectedScenarioLabel = computed(() => selectedScenario.value)
savings: '储蓄', const selectedSizeLabel = computed(() => {
ci: '重疾', return sizeOptions.find(item => item.value === selectedSize.value)?.label || '竖版'
protection: '保障', })
retirement: '养老', const selectedSizeClass = computed(() => {
education: '教育', if (selectedSize.value === '1792x1024') return 'landscape'
} if (selectedSize.value === '1024x1024') return 'square'
return 'portrait'
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 () => { onMounted(async () => {
const [tRes, ctRes]: any[] = await Promise.all([posterApi.getTemplates(), posterApi.getCopyTemplates()]) loading.value = true
templates.value = tRes?.data?.data ?? tRes?.data ?? [] try {
copyTemplates.value = ctRes?.data?.data ?? ctRes?.data ?? [] 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) => { watch(selectedCopyTemplateId, (id) => {
@ -195,6 +202,17 @@ watch(selectedCopyTemplateId, (id) => {
templatePreview.value = ct?.content || '' 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() { async function onGenerateCopy() {
generatingCopy.value = true generatingCopy.value = true
try { try {
@ -203,7 +221,7 @@ async function onGenerateCopy() {
caseUploadId: props.caseUploadId ?? undefined, caseUploadId: props.caseUploadId ?? undefined,
productId: props.productId, productId: props.productId,
templateId: copyMode.value === 'template' ? selectedCopyTemplateId.value ?? undefined : undefined, 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 copyResult.value = res?.data?.data?.copy ?? res?.data?.copy
if (copyMode.value === 'ai') { if (copyMode.value === 'ai') {
@ -219,199 +237,303 @@ async function onGenerateCopy() {
function onNext() { function onNext() {
emit('next', { emit('next', {
templateId: selectedTemplateId.value!, templateId: selectedTemplateId.value!,
templateName: selectedTemplate.value?.name || '',
scenarioLabel: selectedScenario.value,
exportSize: selectedSize.value,
copyContent: copyResult.value, copyContent: copyResult.value,
aiRawContent: aiRawContent.value, aiRawContent: aiRawContent.value,
size: selectedSize.value,
}) })
} }
</script> </script>
<style scoped> <style scoped>
.step-template { .step-template {
width: 100%; padding-bottom: 12px;
max-width: 1180px;
} }
.size-bar { .step-head {
display: flex; display: flex;
align-items: center; justify-content: space-between;
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; align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
} }
.left-panel { .step-head h3 {
flex: 0 0 380px; margin: 0;
min-width: 0; color: #172033;
font-size: 20px;
} }
.right-panel {
flex: 1; .step-head p,
min-width: 0; .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 { .panel-title {
font-size: 15px;
font-weight: 600;
color: #303133;
margin-bottom: 12px; 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 { .template-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 12px; gap: 12px;
} }
.template-card { .template-card {
padding: 0;
border: 2px solid #e4e7ed; border: 2px solid #e4e7ed;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
transition: border-color 0.2s; transition: all 0.2s;
} background: #fff;
.template-card:hover { text-align: left;
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 { .template-card:hover {
margin-top: 16px; border-color: #2563eb;
padding-top: 16px;
border-top: 1px solid #ebeef5;
} }
.preview-label {
font-size: 13px; .template-card.active {
color: #909399; border-color: #2563eb;
margin-bottom: 8px; box-shadow: 0 10px 28px rgb(37 99 235 / 14%);
} }
.preview-card {
background: #f5f7fa; .template-image,
border-radius: 8px; .template-placeholder {
padding: 12px;
}
.preview-placeholder {
width: 100%; width: 100%;
aspect-ratio: 3/4; height: 154px;
border-radius: 8px; }
.template-placeholder {
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: center; 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; font-size: 14px;
color: #303133;
} }
.preview-desc {
font-size: 12px; .template-style {
color: #909399;
margin-top: 4px; margin-top: 4px;
color: #667085;
font-size: 12px;
line-height: 1.5; line-height: 1.5;
} }
.copy-mode-bar { .copy-panel {
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; display: flex;
flex-wrap: wrap; flex-direction: column;
gap: 8px; gap: 16px;
} }
.copy-preview { .selected-preview {
margin-bottom: 12px; 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 { .copy-result {
margin-top: 16px; margin-top: 14px;
}
.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 { .step-actions {
position: sticky;
bottom: 0;
z-index: 2;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; margin-top: 18px;
margin-top: 24px; padding-top: 14px;
padding-top: 16px; border-top: 1px solid #d9e2ef;
border-top: 1px solid #ebeef5; background: #f5f7fa;
} }
@media (max-width: 768px) { :deep(.el-button) {
.template-layout { 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; flex-direction: column;
align-items: stretch;
} }
.left-panel {
flex: none; .template-panel,
width: 100%; .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; .step-actions {
align-items: flex-start; position: fixed;
gap: 8px; 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> </style>

View File

@ -1,61 +1,71 @@
<template> <template>
<div class="step-upload"> <div class="step-upload">
<h3>上传计划书</h3> <div class="step-head">
<div>
<el-upload <h3>上传计划书</h3>
v-if="!caseRecord" <p>上传客户计划书 PDF系统会解析基础保障信息解析后请人工核对再继续</p>
drag </div>
: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> </div>
<div v-if="parseFailed" class="parsing-status"> <section class="upload-panel">
<el-result icon="error" title="解析失败" sub-title="请重新上传或联系管理员"> <el-upload
<template #extra> v-if="!caseRecord"
<el-button type="primary" @click="resetUpload">重新上传</el-button> 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> </template>
</el-result> </el-upload>
</div>
<div v-if="caseRecord && caseRecord.parseStatus === 'parsed' && !confirmed" class="parsed-form"> <div v-if="parsing" class="parsing-status">
<el-alert title="解析完成,请核对以下数据" type="success" show-icon style="margin-bottom: 16px" /> <el-progress type="circle" :percentage="progress" :width="112" />
<el-form label-width="120px"> <p>{{ parseMessage }}</p>
<el-form-item label="年龄"><el-input-number v-model="formData.age" :min="0" :max="100" /></el-form-item> </div>
<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"> <div v-if="parseFailed" class="parsing-status">
<el-result icon="success" title="数据已确认" sub-title="可进入下一步选择模板" /> <el-result icon="error" title="解析失败" sub-title="请重新上传或联系管理员">
</div> <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"> <div class="step-actions">
<el-button @click="$emit('back')">上一步</el-button> <el-button @click="$emit('back')">上一步</el-button>
@ -174,8 +184,57 @@ onBeforeUnmount(() => {
</script> </script>
<style scoped> <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; } .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; } .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> </style>

View File

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

View File

@ -1,35 +1,88 @@
<template> <template>
<div class="poster-page"> <div class="poster-page generator-page">
<el-steps :active="currentStep" finish-status="success" align-center class="poster-steps"> <section class="generator-header">
<el-step title="选择产品" /> <div>
<el-step title="上传计划书" /> <p class="section-label">海报生成工作台</p>
<el-step title="选模板 + 文案" /> <h1>为客户沟通生成可核对的营销海报</h1>
<el-step title="预览导出" /> <p class="header-copy">先确定产品和销售场景再编辑文案并预览成品减少反复生成和合规风险</p>
</el-steps> </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"> <div class="step-content">
<PosterStepProduct v-if="currentStep === 0" @next="onStep1Next" /> <PosterStepProduct v-if="currentStep === 0" @next="onStep1Next" />
<PosterStepUpload v-else-if="currentStep === 1" :product-id="selectedProductId" @next="onStep2Next" @back="currentStep--" /> <PosterStepUpload
<PosterStepTemplate v-else-if="currentStep === 2" :case-upload-id="caseUploadId" :product-id="selectedProductId" @next="onStep3Next" @back="currentStep--" /> v-else-if="currentStep === 1"
<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--" /> :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>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' 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 PosterStepProduct from '@/components/poster/PosterStepProduct.vue'
import PosterStepUpload from '@/components/poster/PosterStepUpload.vue' import PosterStepUpload from '@/components/poster/PosterStepUpload.vue'
import PosterStepTemplate from '@/components/poster/PosterStepTemplate.vue' import PosterStepTemplate from '@/components/poster/PosterStepTemplate.vue'
import PosterStepPreview from '@/components/poster/PosterStepPreview.vue' import PosterStepPreview from '@/components/poster/PosterStepPreview.vue'
const router = useRouter()
const currentStep = ref(0) const currentStep = ref(0)
const selectedProductId = ref('') const selectedProductId = ref('')
const caseUploadId = ref<number | null>(null) const caseUploadId = ref<number | null>(null)
const selectedTemplateId = 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 copyContent = ref<any>(null)
const aiRawContent = 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) { function onStep1Next(productId: string) {
selectedProductId.value = productId selectedProductId.value = productId
@ -41,25 +94,157 @@ function onStep2Next(id: number) {
currentStep.value++ 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 selectedTemplateId.value = data.templateId
selectedTemplateName.value = data.templateName
selectedScenarioLabel.value = data.scenarioLabel
selectedSize.value = data.exportSize
copyContent.value = data.copyContent copyContent.value = data.copyContent
aiRawContent.value = data.aiRawContent aiRawContent.value = data.aiRawContent
if (data.size) selectedSize.value = data.size
currentStep.value++ 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> </script>
<style scoped> <style scoped>
.poster-page { .poster-page {
padding: 20px; padding: 24px;
max-width: 1180px; 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; 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 { .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 { .step-content {
width: min(1180px, 100%);
margin: 0 auto;
min-height: 400px; 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> </style>

View File

@ -1,15 +1,32 @@
<template> <template>
<div class="ppt-page"> <div class="ppt-page generator-page">
<!-- 步骤条 --> <section class="generator-header">
<el-steps :active="currentStep" finish-status="success" align-center class="ppt-steps"> <div>
<el-step title="上传" description="选择PDF计划书" /> <p class="section-label">PPT 生成工作台</p>
<el-step title="解析" description="AI智能识别" /> <h1>把计划书转成可交付的客户方案</h1>
<el-step title="校验" description="查看并确认数据" /> <p class="header-copy">先确认数据可信再选择版式生成避免把未经核对的利益演示交到客户手里</p>
<el-step title="生成" description="选择风格并生成" /> </div>
<el-step title="结果" description="下载PPT" /> <div class="header-actions">
</el-steps> <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"> <div class="ppt-content">
<PptUpload <PptUpload
v-if="currentStep === 0" v-if="currentStep === 0"
@ -45,14 +62,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' 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 PptUpload from './components/ppt/PptUpload.vue'
import PptParsing from './components/ppt/PptParsing.vue' import PptParsing from './components/ppt/PptParsing.vue'
import PptDataReview from './components/ppt/PptDataReview.vue' import PptDataReview from './components/ppt/PptDataReview.vue'
import PptGenerate from './components/ppt/PptGenerate.vue' import PptGenerate from './components/ppt/PptGenerate.vue'
import PptResult from './components/ppt/PptResult.vue' import PptResult from './components/ppt/PptResult.vue'
const router = useRouter()
const currentStep = ref(0) const currentStep = ref(0)
const sessionId = ref('') const sessionId = ref('')
const steps = [
{ title: '上传', description: '选择 PDF 计划书' },
{ title: '解析', description: 'AI 识别关键信息' },
{ title: '校验', description: '确认风险和数据' },
{ title: '生成', description: '选择版式和公司' },
{ title: '结果', description: '核对并下载' },
]
function onUploaded(id: string) { function onUploaded(id: string) {
sessionId.value = id sessionId.value = id
@ -79,14 +106,83 @@ function resetAll() {
<style scoped> <style scoped>
.ppt-page { .ppt-page {
padding: 20px; padding: 24px;
height: 100%; height: 100%;
overflow: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px;
} }
.ppt-steps { .generator-page {
margin-bottom: 24px; --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 { .ppt-content {
@ -95,4 +191,38 @@ function resetAll() {
align-items: flex-start; align-items: flex-start;
justify-content: center; 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> </style>

View File

@ -60,6 +60,18 @@
<el-input v-model="form.previewImage" /> <el-input v-model="form.previewImage" />
</el-form-item> </el-form-item>
</el-form> </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> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="onSave" :loading="saving">保存</el-button> <el-button type="primary" @click="onSave" :loading="saving">保存</el-button>
@ -69,7 +81,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue' import { Plus } from '@element-plus/icons-vue'
import { pptAdminApi } from '@/utils/ppt-admin-api' import { pptAdminApi } from '@/utils/ppt-admin-api'
@ -83,6 +95,10 @@ const dialogVisible = ref(false)
const editRow = ref<any>(null) const editRow = ref<any>(null)
const form = ref<any>({}) const form = ref<any>({})
const saving = ref(false) const saving = ref(false)
const previewBackground = computed(() => {
const colors = parseColorScheme(form.value.colorSchemeStr)
return `linear-gradient(150deg, ${colors.primary}, ${colors.accent})`
})
onMounted(() => loadData()) onMounted(() => loadData())
@ -112,7 +128,12 @@ async function onSave() {
try { try {
const payload: any = { ...form.value } const payload: any = { ...form.value }
if (payload.colorSchemeStr) { 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 delete payload.colorSchemeStr
if (editRow.value) { if (editRow.value) {
@ -135,4 +156,87 @@ async function onToggleStatus(row: any) {
ElMessage.success('操作成功') ElMessage.success('操作成功')
loadData() 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> </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> <template>
<div class="page-container"> <div class="page-container">
<div class="page-header"> <div class="page-header">
<h2>PPT/海报模型设置</h2> <div>
<h2>PPT/海报模型设置</h2>
<p>按用途配置模型并用同步模型检查供应商和 API Key 是否可用</p>
</div>
</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 解析模型 --> <!-- PPT 解析模型 -->
<el-divider content-position="left">PPT 解析模型</el-divider> <el-divider content-position="left">PPT 解析模型</el-divider>
@ -306,9 +324,56 @@ onMounted(loadSettings)
margin: 0; margin: 0;
font-size: 20px; 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 { .form-tip {
margin-left: 12px; margin-left: 12px;
color: #909399; color: #909399;
font-size: 13px; font-size: 13px;
} }
@media (max-width: 767px) {
.page-container {
padding: 12px;
}
.settings-summary {
grid-template-columns: 1fr;
}
.settings-form {
padding: 16px;
}
}
</style> </style>

View File

@ -12,7 +12,7 @@
<el-table-column prop="name" label="名称" min-width="150" /> <el-table-column prop="name" label="名称" min-width="150" />
<el-table-column prop="planType" label="类型" width="80"> <el-table-column prop="planType" label="类型" width="80">
<template #default="{ row }"> <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> </template>
</el-table-column> </el-table-column>
<el-table-column prop="stylePreset" label="风格" width="100" /> <el-table-column prop="stylePreset" label="风格" width="100" />
@ -85,8 +85,8 @@
</el-select> </el-select>
<el-input v-model="slide.title" size="small" placeholder="页面标题" style="flex:1; margin: 0 8px" /> <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" 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 === 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 === slidesList.length - 1" @click="moveSlide(idx, 1)">下移</el-button>
</div> </div>
<div class="slide-item-detail"> <div class="slide-item-detail">
<el-input v-model="slide.narrativeHint" size="small" placeholder="叙事提示30字内" style="width: 50%" /> <el-input v-model="slide.narrativeHint" size="small" placeholder="叙事提示30字内" style="width: 50%" />
@ -107,6 +107,24 @@
</div> </div>
</div> </div>
</el-form> </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> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="onSave" :loading="saving">保存</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)) 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() { async function onSave() {
saving.value = true saving.value = true
try { try {
@ -280,4 +306,68 @@ async function onToggleStatus(row: any) {
display: flex; display: flex;
align-items: center; 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> </style>

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,76 +1,73 @@
<template> <template>
<div class="ppt-result"> <div class="ppt-result">
<!-- 交付确认区 --> <el-card shadow="never" class="result-card">
<div class="delivery-header"> <template #header>
<el-icon :size="28" color="#67c23a"><SuccessFilled /></el-icon> <div class="card-header">
<div class="delivery-title"> <div>
<div class="delivery-main">PPT 已生成完成</div> <div class="title-row">
<div class="delivery-sub"> {{ slideCount }} 页幻灯片 · {{ createdAt }}</div> <el-icon><SuccessFilled /></el-icon>
</div> <span>PPT 已生成</span>
</div> </div>
<p>下载前请完成最后核对确保客户版材料可以直接交付</p>
<!-- 产品摘要 --> </div>
<div v-if="products.length" class="product-summary"> <el-tag type="success" size="large">已完成</el-tag>
<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> </template>
</div>
<!-- 下载前核对提示 --> <section class="delivery-hero">
<div class="checklist-box"> <div class="deck-preview" aria-hidden="true">
<div class="checklist-title"> <div class="slide-sheet slide-main">
<el-icon><InfoFilled /></el-icon> <span>客户方案</span>
下载前请核对 <strong>{{ slideCount || '-' }} </strong>
</div> </div>
<ul class="checklist-items"> <div class="slide-sheet slide-back"></div>
<li>客户姓名被保人年龄是否正确</li> <div class="slide-sheet slide-tail"></div>
<li>产品名称缴费年期是否匹配</li> </div>
<li>利益演示数据与计划书一致</li> <div class="delivery-summary">
</ul> <h3>这份 PPT 已准备好下载</h3>
</div> <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>
<!-- 下载按钮唯一主按钮 --> <section class="delivery-grid">
<div class="download-area"> <div class="delivery-card">
<el-button type="primary" size="large" @click="downloadPpt" :loading="downloading" class="download-btn"> <h4>交付核对</h4>
<el-icon><Download /></el-icon> <ul class="check-list">
{{ downloading ? `下载中 ${downloadProgress}%` : '下载 PPT 文件' }} <li v-for="item in checklist" :key="item">
</el-button> <span class="check-mark"></span>
<el-progress v-if="downloading" :percentage="downloadProgress" :stroke-width="6" style="max-width: 300px; margin-top: 12px" /> {{ item }}
</div> </li>
</ul>
<!-- 次要操作 --> </div>
<div class="secondary-actions"> <div class="delivery-card">
<el-button plain @click="$emit('regenerate')">重新生成</el-button> <h4>文件信息</h4>
<el-button plain @click="$emit('new-session')">新建会话</el-button> <el-descriptions :column="1" border class="session-info">
</div> <el-descriptions-item label="会话 ID">{{ sessionId }}</el-descriptions-item>
<el-descriptions-item label="幻灯片数">{{ slideCount || '-' }}</el-descriptions-item>
<!-- 会话信息折叠 --> <el-descriptions-item label="状态">
<el-collapse style="margin-top: 16px"> <el-tag type="success">已完成</el-tag>
<el-collapse-item title="会话详情" name="session"> </el-descriptions-item>
<el-descriptions :column="2" border size="small"> <el-descriptions-item label="创建时间">{{ createdAt || '-' }}</el-descriptions-item>
<el-descriptions-item label="会话 ID">{{ sessionId }}</el-descriptions-item> </el-descriptions>
<el-descriptions-item label="幻灯片数">{{ slideCount }}</el-descriptions-item> </div>
<el-descriptions-item label="状态"> </section>
<el-tag type="success" size="small">已完成</el-tag> </el-card>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ createdAt }}</el-descriptions-item>
</el-descriptions>
</el-collapse-item>
</el-collapse>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus' 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' import { pptApi } from '@/utils/ppt-api'
const props = defineProps<{ const props = defineProps<{
@ -86,18 +83,12 @@ const slideCount = ref(0)
const createdAt = ref('') const createdAt = ref('')
const downloading = ref(false) const downloading = ref(false)
const downloadProgress = ref(0) const downloadProgress = ref(0)
const products = ref<Array<{ productName: string; planType: string; yearCount: number }>>([]) const checklist = [
'客户姓名和产品名称已核对',
const typeTagMap: Record<string, string> = { '保费、缴费年期和回本年份已核对',
savings: 'success', '利益演示表与原计划书一致',
ci: 'warning', '生成内容已完成合规人工确认',
iul: 'info', ]
}
const typeNameMap: Record<string, string> = {
savings: '储蓄险',
ci: '重疾险',
iul: 'IUL',
}
onMounted(async () => { onMounted(async () => {
try { try {
@ -105,16 +96,8 @@ onMounted(async () => {
const data = res?.data const data = res?.data
slideCount.value = data?.slide_count || 0 slideCount.value = data?.slide_count || 0
createdAt.value = data?.created_at || '' 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 { } catch {
// ElMessage.warning('文件信息暂时无法加载,仍可尝试下载')
} }
}) })
@ -132,6 +115,7 @@ async function downloadPpt() {
link.click() link.click()
document.body.removeChild(link) document.body.removeChild(link)
window.URL.revokeObjectURL(url) window.URL.revokeObjectURL(url)
ElMessage.success('PPT 下载已开始')
} catch (error) { } catch (error) {
console.error('下载失败:', error) console.error('下载失败:', error)
ElMessage.error('PPT 下载失败,请重试') ElMessage.error('PPT 下载失败,请重试')
@ -144,109 +128,187 @@ async function downloadPpt() {
<style scoped> <style scoped>
.ppt-result { .ppt-result {
width: 100%; width: 100%;
max-width: 700px; max-width: 960px;
} }
.delivery-header { .result-card {
display: flex; width: 100%;
align-items: center;
gap: 12px;
padding: 20px 24px;
background: #f0f9eb;
border: 1px solid #e1f3d8;
border-radius: 8px; border-radius: 8px;
margin-bottom: 16px; border-color: #d9e2ef;
}
.delivery-main {
font-size: 18px;
font-weight: 700;
color: #303133;
}
.delivery-sub {
font-size: 13px;
color: #909399;
margin-top: 4px;
} }
.product-summary { .card-header {
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; display: flex;
flex-wrap: wrap; align-items: flex-start;
gap: 12px; justify-content: space-between;
gap: 16px;
} }
.product-chip {
.title-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} color: #172033;
.chip-name { font-size: 18px;
font-weight: 600; font-weight: 700;
font-size: 14px;
color: #303133;
}
.chip-meta {
font-size: 12px;
color: #909399;
} }
.checklist-box { .card-header p,
margin-bottom: 20px; .delivery-summary p {
padding: 16px; margin: 6px 0 0;
background: #fdf6ec; color: #667085;
border: 1px solid #faecd8; line-height: 1.6;
border-radius: 8px;
} }
.checklist-title {
display: flex; .delivery-hero {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 28px;
align-items: center; align-items: center;
gap: 6px; padding: 22px;
font-size: 14px; border: 1px solid #d9e2ef;
font-weight: 600; border-radius: 8px;
color: #e6a23c; background: #f8fafc;
margin-bottom: 8px;
}
.checklist-items {
margin: 0;
padding-left: 20px;
font-size: 13px;
color: #606266;
line-height: 2;
} }
.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; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; justify-content: space-between;
padding: 20px 0; background:
} linear-gradient(90deg, #2563eb 0 8px, transparent 8px),
.download-btn { linear-gradient(135deg, #ffffff, #eff6ff);
min-width: 200px;
} }
.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; display: flex;
justify-content: center; flex-wrap: wrap;
gap: 12px; gap: 10px;
margin-top: 8px; margin-top: 18px;
} }
@media (max-width: 768px) { .download-progress {
.delivery-header { max-width: 360px;
padding: 16px; 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> </style>

View File

@ -28,7 +28,7 @@
:auto-upload="false" :auto-upload="false"
:limit="1" :limit="1"
accept=".pdf" accept=".pdf"
:on-change="(file) => handleFileChange(file, 'savings')" :on-change="(file: any) => handleFileChange(file, 'savings')"
:on-remove="() => removeFile('savings')" :on-remove="() => removeFile('savings')"
class="port-upload" class="port-upload"
> >
@ -63,7 +63,7 @@
:auto-upload="false" :auto-upload="false"
:limit="1" :limit="1"
accept=".pdf" accept=".pdf"
:on-change="(file) => handleFileChange(file, 'ci')" :on-change="(file: any) => handleFileChange(file, 'ci')"
:on-remove="() => removeFile('ci')" :on-remove="() => removeFile('ci')"
class="port-upload" class="port-upload"
> >
@ -97,7 +97,7 @@
:auto-upload="false" :auto-upload="false"
:limit="1" :limit="1"
accept=".pdf" accept=".pdf"
:on-change="(file) => handleFileChange(file, 'iul')" :on-change="(file: any) => handleFileChange(file, 'iul')"
:on-remove="() => removeFile('iul')" :on-remove="() => removeFile('iul')"
class="port-upload" 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) return api.post('/poster/generate', params)
}, },

View File

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