Compare commits
2 Commits
0b69978812
...
5462433036
| Author | SHA1 | Date | |
|---|---|---|---|
| 5462433036 | |||
| 0ffd9dffdf |
@ -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):
|
||||
"""尝试从数据库加载模型配置(带缓存,不阻塞)。"""
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 })
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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)
|
||||
},
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user