优化海报程程页面PPT

This commit is contained in:
wsb1224 2026-07-30 09:15:07 +08:00
parent 751302205a
commit 65d17179b3
18 changed files with 3112 additions and 246 deletions

View File

@ -532,6 +532,112 @@ def _execute_ppt_generate(task_id: str):
# ─── PPT 重新生成任务(版本化)─────────────────────────────
def _apply_edits_to_pptx(pptx_path: str, edit_slides: list):
"""将 slides.json 中的编辑应用到已渲染的 PPTX 文件。
1. 删除被标记为 hidden 的幻灯片从后往前删避免索引偏移
2. 对非隐藏页按位置匹配文本框并更新文字
"""
from pptx import Presentation
from pptx.util import Emu
prs = Presentation(pptx_path)
slides = list(prs.slides)
# 收集需要删除的幻灯片索引(倒序处理)
hidden_indices = sorted(
[i for i, s in enumerate(edit_slides) if s.get("hidden") and i < len(slides)],
reverse=True,
)
for idx in hidden_indices:
slide_id = slides[idx].slide_id
rId = prs.slides._sldIdLst[idx].get("{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id")
if not rId:
# 尝试通过 rels 查找
for rel in prs.part.rels.values():
if rel.target_part is slides[idx].part:
rId = rel.rId
break
if rId:
prs.part.drop_rel(rId)
del prs.slides._sldIdLst[idx]
logger.info("已删除隐藏幻灯片: index=%d", idx)
# 应用文字编辑(匹配位置)
TOLERANCE = 50000 # EMU 容差(约 0.5px
edited_count = 0
for slide_idx, edit_slide in enumerate(edit_slides):
if edit_slide.get("hidden"):
continue
if slide_idx >= len(list(prs.slides)):
continue
slide = list(prs.slides)[slide_idx]
edit_shapes = edit_slide.get("shapes", [])
for edit_shape in edit_shapes:
if edit_shape.get("type") != "textbox":
continue
edit_paras = edit_shape.get("paragraphs", [])
if not edit_paras:
continue
edit_x = edit_shape.get("x", 0)
edit_y = edit_shape.get("y", 0)
# 匹配 PPTX 中的形状(按位置)
for shape in slide.shapes:
if not shape.has_text_frame:
continue
shape_x = int(shape.left) if shape.left else 0
shape_y = int(shape.top) if shape.top else 0
if abs(shape_x - _px_to_emu(edit_x)) > TOLERANCE:
continue
if abs(shape_y - _px_to_emu(edit_y)) > TOLERANCE:
continue
# 位置匹配,更新文字
_update_shape_text(shape, edit_paras)
edited_count += 1
break
if edited_count > 0 or hidden_indices:
prs.save(pptx_path)
logger.info("PPTX 后处理完成: 编辑 %d 个文本框, 删除 %d 个隐藏页",
edited_count, len(hidden_indices))
def _px_to_emu(px: int) -> int:
"""像素转 EMU96 DPI"""
return round(px * 914400 / 96)
def _update_shape_text(shape, edit_paras: list):
"""将编辑后的段落文本写入 python-pptx 形状。"""
from pptx.util import Pt
from pptx.dml.color import RGBColor
tf = shape.text_frame
existing_paras = list(tf.paragraphs)
for i, edit_para in enumerate(edit_paras):
new_text = edit_para.get("text", "")
if i < len(existing_paras):
para = existing_paras[i]
# 保留第一个 run 的格式,替换文本
if para.runs:
para.runs[0].text = new_text
# 删除多余 runs
for run in para.runs[1:]:
run.text = ""
else:
para.text = new_text
else:
# 新增段落
para = tf.add_paragraph()
para.text = new_text
# 删除多余段落(如果编辑后段落变少了)
# python-pptx 不支持直接删除段落,只能清空
for i in range(len(edit_paras), len(existing_paras)):
existing_paras[i].text = ""
@shared_task(bind=True, name="insurance.regenerate_ppt", max_retries=3, default_retry_delay=30)
def regenerate_ppt_task(self, task_id: str):
"""PPT 重新生成任务(基于编辑内容生成新版本)。"""
@ -588,12 +694,6 @@ def _execute_ppt_regenerate(task_id: str):
snapshot = json.loads(task.input_snapshot_json) if task.input_snapshot_json else {}
edits = snapshot.get("edits")
# 应用编辑:移除隐藏的幻灯片
if edits and edits.get("slides"):
hidden_indices = {i for i, s in enumerate(edits["slides"]) if s.get("hidden")}
if hidden_indices:
logger.info("重新生成: 移除 %d 个隐藏幻灯片: %s", len(hidden_indices), hidden_indices)
_update_task_status(task_id, stage="rendering", progress=30, message="重新渲染 PPT")
# 使用 DeckContract 重新渲染
@ -628,6 +728,15 @@ def _execute_ppt_regenerate(task_id: str):
finished_at=datetime.now())
return
_update_task_status(task_id, stage="saving", progress=70, message="应用编辑")
# 后处理:应用文字编辑和删除隐藏页
if edits and edits.get("slides"):
try:
_apply_edits_to_pptx(output_path, edits["slides"])
except Exception as exc:
logger.warning("应用编辑到 PPTX 失败: %s", exc)
_update_task_status(task_id, stage="saving", progress=80, message="保存新版本")
# 解析幻灯片结构
@ -641,24 +750,17 @@ def _execute_ppt_regenerate(task_id: str):
except Exception as exc:
logger.warning("重新生成: 幻灯片解析失败: %s", exc)
# 如果有隐藏页,从 slides.json 中标记
if slides_data and edits and edits.get("slides"):
for i, edit_slide in enumerate(edits["slides"]):
if edit_slide.get("hidden") and i < len(slides_data.get("slides", [])):
slides_data["slides"][i]["hidden"] = True
slides_json_path = os.path.join(slides_dir, "slides.json")
with open(slides_json_path, "w", encoding="utf-8") as f:
json.dump(slides_data, f, ensure_ascii=False)
# 质量检查
# 质量检查(传入 extractions 避免误判)
quality_report = None
try:
from insurance.ppt.quality_checker import QualityChecker
checker = QualityChecker()
# 从 session 读取原始 extractions 用于关键数据检查
session_extractions = json.loads(session.extractions_json) if session.extractions_json else []
quality_report = checker.check(
pptx_path=output_path,
slides_data=slides_data,
extractions=[],
extractions=session_extractions,
expected_slide_count=result.get("slideCount", 0),
)
except Exception as exc:

View File

@ -147,7 +147,22 @@ class PptTemplate(db.Model):
def to_dict(self):
import json
try:
slides = json.loads(self.slides_config_json) if self.slides_config_json else []
except (json.JSONDecodeError, TypeError):
slides = []
try:
required_types = json.loads(self.required_page_types_json) if self.required_page_types_json else []
except (json.JSONDecodeError, TypeError):
required_types = []
try:
company_ids = json.loads(self.applicable_company_ids) if self.applicable_company_ids else []
except (json.JSONDecodeError, TypeError):
company_ids = []
try:
product_ids = json.loads(self.applicable_product_ids) if self.applicable_product_ids else []
except (json.JSONDecodeError, TypeError):
product_ids = []
return {
"id": self.id,
"planType": self.plan_type,
@ -160,12 +175,12 @@ class PptTemplate(db.Model):
"isBuiltIn": str(self.source_template_asset_id or "").startswith("builtin://"),
"cloneReady": self.clone_ready,
"cloneRenderer": self.clone_renderer,
"requiredPageTypes": json.loads(self.required_page_types_json) if self.required_page_types_json else [],
"requiredPageTypes": required_types,
"name": self.name,
"scenarioTag": self.scenario_tag,
"previewImage": self.preview_image,
"applicableCompanyIds": json.loads(self.applicable_company_ids) if self.applicable_company_ids else [],
"applicableProductIds": json.loads(self.applicable_product_ids) if self.applicable_product_ids else [],
"applicableCompanyIds": company_ids,
"applicableProductIds": product_ids,
"slidesConfig": slides,
"slideCount": len(slides),
"status": self.status,

View File

@ -117,21 +117,32 @@ class QualityChecker:
blank_pages = []
for i, slide in enumerate(slides):
shapes = slide.get("shapes", [])
has_text = False
has_content = False
for shape in shapes:
if shape.get("type") == "textbox":
stype = shape.get("type", "")
# 文本框有内容
if stype == "textbox":
for para in shape.get("paragraphs", []):
if para.get("text", "").strip():
has_text = True
has_content = True
break
if has_text:
# 图片、表格也算内容
elif stype in ("image", "table"):
has_content = True
# 有填充色的矩形也算内容(封面背景等)
elif stype == "rect" and shape.get("fill"):
has_content = True
# 分组形状中的子元素
elif stype == "group" and shape.get("children"):
has_content = True
if has_content:
break
if not has_text:
if not has_content:
blank_pages.append(i + 1)
if not blank_pages:
return {"key": "no_blank_pages", "status": "pass",
"message": "所有页面均包含文本内容", "page": None}
"message": "所有页面均包含内容(文本/图片/表格/形状)", "page": None}
if len(blank_pages) <= 2:
return {"key": "no_blank_pages", "status": "warn",
"message": f"{', '.join(str(p) for p in blank_pages)} 页可能为空白页",

View File

@ -15,6 +15,7 @@
<div class="draft-title">{{ copyContent?.headline || '客户专属保障方案' }}</div>
<div class="draft-body">{{ copyContent?.body || '文案将在生成时写入海报。' }}</div>
<div class="draft-cta">{{ copyContent?.call_to_action || '联系顾问了解详情' }}</div>
<div class="draft-hint"> 文案排版示意最终效果以 AI 生成为准</div>
</div>
<div v-else-if="taskStatus === 'queued' || taskStatus === 'generating'" class="generate-state">
@ -55,9 +56,9 @@
</div>
<div class="primary-actions">
<el-button v-if="taskStatus !== 'done'" type="primary" size="large" @click="onGenerate">
<el-icon><Picture /></el-icon>
生成海报
<el-button v-if="taskStatus !== 'done'" type="primary" size="large" :loading="submitting" @click="onGenerate">
<el-icon v-if="!submitting"><Picture /></el-icon>
{{ submitting ? '正在提交...' : '生成海报' }}
</el-button>
<el-button v-else type="primary" size="large" @click="onDownload">
<el-icon><Download /></el-icon>
@ -96,6 +97,7 @@ const taskProgress = ref(0)
const taskError = ref<string | null>(null)
const posterObjectUrl = ref<string | null>(null)
const recordId = ref<number | null>(null)
const submitting = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
let pollRetries = 0
const MAX_POLL_RETRIES = 90 // 90 3
@ -135,6 +137,7 @@ function clearPoster() {
taskProgress.value = 0
taskError.value = null
pollRetries = 0
submitting.value = false
}
function stopPolling() {
@ -145,6 +148,7 @@ function stopPolling() {
}
async function onGenerate() {
submitting.value = true
taskStatus.value = 'queued'
taskProgress.value = 0
taskError.value = null
@ -168,6 +172,8 @@ async function onGenerate() {
} catch (e: any) {
taskStatus.value = 'failed'
taskError.value = e?.message || '提交失败'
} finally {
submitting.value = false
}
}
@ -324,6 +330,13 @@ onBeforeUnmount(() => {
font-weight: 700;
}
.draft-hint {
margin-top: 8px;
font-size: 11px;
opacity: .55;
text-align: center;
}
.generate-state {
width: min(420px, 100%);
text-align: center;

View File

@ -230,8 +230,19 @@ function formatStyle(text?: string) {
}
function previewBackground(template: any) {
const primary = template.colorScheme?.primary || '#1e3a5f'
const accent = template.colorScheme?.accent || '#2563eb'
// 使
const fallbackPairs = [
['#1e3a5f', '#2563eb'],
['#2d1b4e', '#7c3aed'],
['#1a3a2a', '#059669'],
['#3a1a1a', '#dc2626'],
['#1a2a3a', '#0891b2'],
['#3a2a1a', '#d97706'],
]
const idx = (template.id || 0) % fallbackPairs.length
const [defaultPrimary, defaultAccent] = fallbackPairs[idx]
const primary = template.colorScheme?.primary || defaultPrimary
const accent = template.colorScheme?.accent || defaultAccent
return `linear-gradient(135deg, ${primary}, ${accent})`
}
@ -347,8 +358,10 @@ function onNext() {
text-align: left;
}
.template-card:hover {
.template-card:hover,
.template-card:focus-visible {
border-color: #2563eb;
outline: none;
}
.template-card.active {

View File

@ -40,7 +40,7 @@
</div>
<div v-if="parseFailed" class="parsing-status">
<el-result icon="error" title="解析失败" sub-title="请重新上传或联系管理员">
<el-result icon="error" title="解析失败" :sub-title="parseFailMessage">
<template #extra>
<el-button type="primary" @click="resetUpload">重新上传</el-button>
</template>
@ -100,6 +100,7 @@ const parsing = ref(false)
const parseFailed = ref(false)
const progress = ref(0)
const parseMessage = ref('正在上传...')
const parseFailMessage = ref('解析计划书时出错,请重新上传或联系管理员。')
const confirmed = ref(false)
const pdfPassword = ref('')
let pollTimer: ReturnType<typeof setInterval> | null = null
@ -118,6 +119,7 @@ function resetUpload() {
progress.value = 0
confirmed.value = false
pollRetries = 0
parseFailMessage.value = '解析计划书时出错,请重新上传或联系管理员。'
}
function stopPolling() {
@ -182,6 +184,7 @@ function startParsePolling(id: number) {
stopPolling()
parsing.value = false
parseFailed.value = true
parseFailMessage.value = '计划书解析失败,可能是文件损坏、页数过多或格式不受支持。请重新上传。'
} else if (status === 'parsing' || status === 'queued') {
progress.value = Math.min(90, progress.value + 10)
parseMessage.value = status === 'queued' ? '任务排队中...' : '正在解析计划书...'

View File

@ -0,0 +1,135 @@
<template>
<footer class="poster-action-bar">
<div class="action-status">
<span class="status-text">{{ completionText }}</span>
<div class="progress-dots">
<span
v-for="item in completionItems"
:key="item.key"
class="dot"
:class="{ done: item.done }"
:title="item.label"
/>
</div>
</div>
<div class="action-buttons">
<el-button
v-if="draft.taskStatus === 'done' && draft.posterUrl"
@click="$emit('download')"
>
<el-icon><Download /></el-icon>
下载海报
</el-button>
<el-button
v-if="draft.taskStatus === 'done'"
@click="$emit('regenerate')"
>
重新生成
</el-button>
<el-button
type="primary"
:disabled="!canAct"
:loading="isGenerating"
@click="$emit('action')"
>
{{ actionLabel }}
</el-button>
</div>
</footer>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Download } from '@element-plus/icons-vue'
import type { PosterDraft } from '@/composables/usePosterWorkspace'
const props = defineProps<{
draft: PosterDraft
completionText: string
completionItems: Array<{ key: string; label: string; done: boolean }>
actionLabel: string
actionState: string
canGenerate: boolean
}>()
defineEmits<{
action: []
download: []
regenerate: []
}>()
const isGenerating = computed(() =>
props.draft.taskStatus === 'submitting' ||
props.draft.taskStatus === 'queued' ||
props.draft.taskStatus === 'generating'
)
const canAct = computed(() => {
if (isGenerating.value) return false
return props.actionState !== 'generating'
})
</script>
<style scoped>
.poster-action-bar {
height: 56px;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-top: 1px solid var(--poster-border);
background: #fff;
flex-shrink: 0;
}
.action-status {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.status-text {
font-size: 13px;
color: var(--poster-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-dots {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #e5e7eb;
transition: background 0.2s;
}
.dot.done {
background: #16a34a;
}
.action-buttons {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
@media (max-width: 767px) {
.status-text {
display: none;
}
.poster-action-bar {
padding: 0 12px;
}
}
</style>

View File

@ -0,0 +1,85 @@
<template>
<aside class="config-rail">
<div class="rail-scroll">
<PosterProductPanel
:product-id="draft.productId"
:product-name="draft.productName"
:product-company="draft.productCompany"
:use-masked-data="draft.useMaskedData"
@update:product-id="$emit('update:draft', { productId: $event.id, productName: $event.name, productCompany: $event.company })"
@update:use-masked-data="$emit('update:draft', { useMaskedData: $event })"
/>
<el-divider class="panel-divider" />
<PosterSourcePanel
:product-id="draft.productId"
:case-upload-id="draft.caseUploadId"
:case-file-name="draft.caseFileName"
:case-file-size="draft.caseFileSize"
:parse-status="draft.parseStatus"
:parse-progress="draft.parseProgress"
:parse-message="draft.parseMessage"
:parse-fail-message="draft.parseFailMessage"
:parsed-fields="draft.parsedFields"
:data-confirmed="draft.dataConfirmed"
@update:parse="$emit('update:draft', $event)"
@confirm-data="$emit('update:draft', { dataConfirmed: true })"
/>
<el-divider class="panel-divider" />
<PosterCreativePanel
:scenario="draft.scenario"
:export-size="draft.exportSize"
:template-id="draft.templateId"
:template-name="draft.templateName"
@update:scenario="$emit('update:draft', { scenario: $event })"
@update:export-size="$emit('update:draft', { exportSize: $event })"
@update:template="$emit('update:draft', { templateId: $event.id, templateName: $event.name })"
/>
</div>
</aside>
</template>
<script setup lang="ts">
import type { PosterDraft } from '@/composables/usePosterWorkspace'
import PosterProductPanel from './PosterProductPanel.vue'
import PosterSourcePanel from './PosterSourcePanel.vue'
import PosterCreativePanel from './PosterCreativePanel.vue'
defineProps<{ draft: PosterDraft }>()
defineEmits<{ 'update:draft': [patch: Partial<PosterDraft>] }>()
</script>
<style scoped>
.config-rail {
width: 300px;
min-width: 280px;
max-width: 320px;
border-right: 1px solid var(--poster-border);
background: #fafbfc;
display: flex;
flex-direction: column;
overflow: hidden;
}
.rail-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 16px;
}
.panel-divider {
margin: 12px 0;
}
@media (max-width: 1023px) {
.config-rail {
width: 280px;
min-width: 260px;
max-width: 280px;
}
}
</style>

View File

@ -0,0 +1,291 @@
<template>
<div class="copy-panel">
<!-- 文案模式选择 -->
<div class="mode-row">
<el-radio-group :model-value="copyMode" @update:model-value="$emit('update:copy-mode', $event)" size="small">
<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-source">
<el-select
:model-value="copyTemplateId"
@update:model-value="$emit('update:copy-template-id', $event)"
placeholder="选择文案模板"
size="small"
style="width: 100%"
>
<el-option
v-for="ct in orderedCopyTemplates"
:key="ct.id"
:label="ct.scenarioTag ? `${ct.name} · ${ct.scenarioTag}` : ct.name"
:value="ct.id"
/>
</el-select>
<el-button type="primary" size="small" @click="onGenerateCopy" :loading="generatingCopy" :disabled="!copyTemplateId" style="width: 100%">
填充文案
</el-button>
</div>
<!-- AI 文案源 -->
<div v-else class="copy-source">
<div class="ai-styles">
<label class="field-label">AI 风格</label>
<el-segmented :model-value="aiStyle" @update:model-value="$emit('update:ai-style', $event)" :options="styleOptions" size="small" />
</div>
<el-button type="primary" size="small" @click="onGenerateCopy" :loading="generatingCopy" style="width: 100%">
AI 生成文案
</el-button>
</div>
<!-- 候选版本AI 重新生成时不覆盖 -->
<div v-if="candidateContent" class="candidate-card">
<div class="candidate-header">
<span class="candidate-label">候选版本</span>
<div class="candidate-actions">
<el-button text type="primary" size="small" @click="applyCandidate">采用</el-button>
<el-button text size="small" @click="candidateContent = null">丢弃</el-button>
</div>
</div>
<div class="candidate-preview">
<p class="candidate-headline">{{ candidateContent.headline }}</p>
<p class="candidate-body">{{ candidateContent.body }}</p>
</div>
</div>
<!-- 文案编辑区 -->
<div v-if="copyContent" class="copy-editor">
<div class="field-group">
<label class="field-label">标题</label>
<el-input
:model-value="copyContent.headline"
@update:model-value="updateField('headline', $event)"
placeholder="海报标题"
size="small"
/>
<span v-if="headlineLength > 20" class="char-warn">标题较长 ({{ headlineLength }} )</span>
</div>
<div class="field-group">
<label class="field-label">正文</label>
<el-input
:model-value="copyContent.body"
@update:model-value="updateField('body', $event)"
type="textarea"
:rows="3"
placeholder="海报正文"
size="small"
/>
<span v-if="bodyLength > 80" class="char-warn">正文较长 ({{ bodyLength }} )</span>
</div>
<div class="field-group">
<label class="field-label">行动号召</label>
<el-input
:model-value="copyContent.call_to_action"
@update:model-value="updateField('call_to_action', $event)"
placeholder="联系顾问了解详情"
size="small"
/>
</div>
</div>
<!-- 空状态 -->
<div v-else class="empty-copy">
<el-icon :size="24" color="#c0c4cc"><EditPen /></el-icon>
<span>选择模式并生成文案</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { EditPen } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { posterApi } from '@/utils/poster-api'
const props = defineProps<{
copyMode: 'template' | 'ai'
copyTemplateId: number | null
aiStyle: string
copyContent: { headline: string; body: string; call_to_action: string } | null
caseUploadId: number | null
productId: string
scenario: string
useMaskedData: boolean
}>()
const emit = defineEmits<{
'update:copy-mode': [value: 'template' | 'ai']
'update:copy-template-id': [value: number | null]
'update:ai-style': [value: string]
'update:copy-content': [value: { headline: string; body: string; call_to_action: string }]
'update:ai-raw-content': [value: any]
}>()
const generatingCopy = ref(false)
const copyTemplates = ref<any[]>([])
const candidateContent = ref<any>(null)
const styleOptions = ['专业稳健', '温暖顾问', '高端资产配置', '简洁朋友圈']
const orderedCopyTemplates = computed(() =>
[...copyTemplates.value].sort((a, b) => {
const aM = a.scenarioTag === props.scenario ? 1 : 0
const bM = b.scenarioTag === props.scenario ? 1 : 0
return bM - aM
})
)
const headlineLength = computed(() => props.copyContent?.headline?.length || 0)
const bodyLength = computed(() => props.copyContent?.body?.length || 0)
function updateField(field: string, value: string) {
if (!props.copyContent) return
emit('update:copy-content', { ...props.copyContent, [field]: value })
}
function applyCandidate() {
if (candidateContent.value) {
emit('update:copy-content', { ...candidateContent.value })
candidateContent.value = null
}
}
async function onGenerateCopy() {
generatingCopy.value = true
try {
const res: any = await posterApi.generateCopy({
mode: props.copyMode,
caseUploadId: props.caseUploadId ?? undefined,
productId: props.productId,
templateId: props.copyMode === 'template' ? props.copyTemplateId ?? undefined : undefined,
style: `${props.aiStyle},用于${props.scenario}`,
useMaskedData: props.useMaskedData || false,
})
const newCopy = res?.data?.data?.copy ?? res?.data?.copy
if (props.copyMode === 'ai') {
emit('update:ai-raw-content', { ...newCopy })
}
//
if (props.copyContent?.headline) {
candidateContent.value = newCopy
} else {
emit('update:copy-content', newCopy)
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '生成失败')
} finally {
generatingCopy.value = false
}
}
onMounted(async () => {
try {
const res: any = await posterApi.getCopyTemplates()
copyTemplates.value = res?.data?.data ?? res?.data ?? []
} catch {
//
}
})
</script>
<style scoped>
.mode-row {
margin-bottom: 12px;
}
.copy-source {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.ai-styles {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-size: 12px;
color: var(--poster-muted);
font-weight: 500;
}
.field-group {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.char-warn {
font-size: 11px;
color: #e6a23c;
}
.copy-editor {
padding: 12px;
border: 1px solid var(--poster-border);
border-radius: 6px;
background: #fafbfc;
}
/* ── 候选版本 ──────────────────────── */
.candidate-card {
margin-bottom: 12px;
padding: 10px;
border: 1px solid #b3d8ff;
border-radius: 6px;
background: #ecf5ff;
}
.candidate-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.candidate-label {
font-size: 12px;
font-weight: 600;
color: #409eff;
}
.candidate-actions {
display: flex;
gap: 4px;
}
.candidate-preview {
font-size: 12px;
color: var(--poster-text);
}
.candidate-headline {
font-weight: 600;
margin: 0 0 4px;
}
.candidate-body {
margin: 0;
color: var(--poster-muted);
line-height: 1.5;
}
/* ── 空状态 ────────────────────────── */
.empty-copy {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px;
color: #c0c4cc;
font-size: 13px;
}
</style>

View File

@ -0,0 +1,335 @@
<template>
<div class="creative-panel">
<div class="panel-header" @click="expanded = !expanded">
<div class="header-left">
<el-icon :size="16"><Brush /></el-icon>
<strong>场景与模板</strong>
</div>
<div class="header-right">
<span v-if="templateName" class="summary">{{ templateName }}</span>
<el-icon :class="{ 'rotate-180': expanded }"><ArrowDown /></el-icon>
</div>
</div>
<div v-show="expanded" class="panel-body">
<!-- 销售场景 -->
<div class="field-group">
<label class="field-label">销售场景</label>
<el-segmented :model-value="scenario" @update:model-value="$emit('update:scenario', $event)" :options="scenarioOptions" size="small" />
</div>
<!-- 输出比例 -->
<div class="field-group">
<label class="field-label">输出比例</label>
<el-segmented :model-value="exportSize" @update:model-value="$emit('update:export-size', $event)" :options="sizeOptions" size="small" />
</div>
<!-- 模板选择 -->
<div class="field-group">
<div class="field-label-row">
<label class="field-label">海报模板</label>
<el-button v-if="templates.length > 6" text type="primary" size="small" @click="drawerVisible = true">
查看全部 ({{ templates.length }})
</el-button>
</div>
<el-skeleton :loading="loading" animated :rows="2">
<template #template>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px">
<el-skeleton-item v-for="i in 4" :key="i" variant="rect" style="height: 80px" />
</div>
</template>
<template #default>
<el-empty v-if="templates.length === 0" description="暂无可用模板" :image-size="48" />
<div v-else class="template-grid">
<button
v-for="t in visibleTemplates"
:key="t.id"
type="button"
class="template-thumb"
:class="{ active: templateId === t.id }"
@click="onSelectTemplate(t)"
>
<el-image v-if="t.previewImage" :src="t.previewImage" class="thumb-image" fit="cover" />
<div v-else class="thumb-placeholder" :style="{ background: previewBackground(t) }">
<span class="thumb-lines"></span>
<span class="thumb-lines short"></span>
</div>
<div class="thumb-name">{{ t.name }}</div>
</button>
</div>
</template>
</el-skeleton>
</div>
</div>
<!-- 全部模板抽屉 -->
<el-drawer v-model="drawerVisible" title="全部模板" size="520px" :z-index="2000">
<div class="drawer-scenario">
<el-segmented v-model="drawerScenario" :options="scenarioOptions" size="small" />
</div>
<div class="drawer-grid">
<button
v-for="t in drawerTemplates"
:key="t.id"
type="button"
class="template-thumb drawer-thumb"
:class="{ active: templateId === t.id }"
@click="onSelectTemplate(t); drawerVisible = false"
>
<el-image v-if="t.previewImage" :src="t.previewImage" class="thumb-image" fit="cover" />
<div v-else class="thumb-placeholder" :style="{ background: previewBackground(t) }">
<span class="thumb-lines"></span>
<span class="thumb-lines short"></span>
</div>
<div class="thumb-info">
<div class="thumb-name">{{ t.name }}</div>
<el-tag v-if="t.scenarioTag" size="small" type="info">{{ t.scenarioTag }}</el-tag>
</div>
</button>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Brush, ArrowDown } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { posterApi } from '@/utils/poster-api'
const props = defineProps<{
scenario: string
exportSize: string
templateId: number | null
templateName: string
}>()
const emit = defineEmits<{
'update:scenario': [value: string]
'update:export-size': [value: string]
'update:template': [template: { id: number; name: string }]
}>()
const expanded = ref(true)
const loading = ref(false)
const drawerVisible = ref(false)
const drawerScenario = ref(props.scenario)
const templates = ref<any[]>([])
const sizeOptions = [
{ label: '竖版', value: '1024x1792' },
{ label: '横版', value: '1792x1024' },
{ label: '方图', value: '1024x1024' },
]
const defaultScenarios = ['朋友圈沟通', '客户私聊', '讲座邀约', '产品卖点']
const scenarioOptions = computed(() => {
const configured = templates.value.map(t => t.scenarioTag).filter(Boolean)
return [...new Set([...defaultScenarios, ...configured])]
})
const orderedTemplates = computed(() =>
[...templates.value].sort((a, b) => {
const aM = a.scenarioTag === props.scenario ? 1 : 0
const bM = b.scenarioTag === props.scenario ? 1 : 0
return bM - aM
})
)
const visibleTemplates = computed(() => orderedTemplates.value.slice(0, 6))
const drawerTemplates = computed(() =>
[...templates.value].sort((a, b) => {
const aM = a.scenarioTag === drawerScenario.value ? 1 : 0
const bM = b.scenarioTag === drawerScenario.value ? 1 : 0
return bM - aM
})
)
function onSelectTemplate(t: any) {
emit('update:template', { id: t.id, name: t.name })
}
function previewBackground(template: any) {
const pairs = [
['#1e3a5f', '#2563eb'],
['#2d1b4e', '#7c3aed'],
['#1a3a2a', '#059669'],
['#3a1a1a', '#dc2626'],
['#1a2a3a', '#0891b2'],
['#3a2a1a', '#d97706'],
]
const idx = (template.id || 0) % pairs.length
const primary = template.colorScheme?.primary || pairs[idx][0]
const accent = template.colorScheme?.accent || pairs[idx][1]
return `linear-gradient(135deg, ${primary}, ${accent})`
}
onMounted(async () => {
loading.value = true
try {
const res: any = await posterApi.getTemplates()
templates.value = res?.data?.data ?? res?.data ?? []
//
if (templates.value.length && !props.templateId) {
onSelectTemplate(templates.value[0])
}
} catch (e: any) {
ElMessage.error(e?.message || '模板加载失败')
} finally {
loading.value = false
}
})
</script>
<style scoped>
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 8px 0;
user-select: none;
}
.header-left {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: var(--poster-text);
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.summary {
font-size: 12px;
color: var(--poster-muted);
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rotate-180 {
transform: rotate(180deg);
}
.panel-body {
padding-top: 8px;
display: flex;
flex-direction: column;
gap: 12px;
}
.field-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-size: 12px;
color: var(--poster-muted);
font-weight: 500;
}
.field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
}
/* ── 模板缩略图网格 ────────────────── */
.template-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.template-thumb {
padding: 0;
border: 2px solid #e4e7ed;
border-radius: 6px;
cursor: pointer;
overflow: hidden;
transition: all 0.15s;
background: #fff;
text-align: left;
}
.template-thumb:hover {
border-color: #3b7a57;
}
.template-thumb.active {
border-color: #3b7a57;
box-shadow: 0 2px 8px rgb(59 122 87 / 20%);
}
.thumb-image {
width: 100%;
height: 72px;
}
.thumb-placeholder {
width: 100%;
height: 72px;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 6px;
padding: 10px;
}
.thumb-lines {
display: block;
width: 60%;
height: 5px;
border-radius: 5px;
background: rgb(255 255 255 / 70%);
}
.thumb-lines.short {
width: 40%;
opacity: .6;
}
.thumb-name {
padding: 6px 8px;
font-size: 11px;
font-weight: 600;
color: var(--poster-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── 抽屉内部 ──────────────────────── */
.drawer-scenario {
margin-bottom: 12px;
}
.drawer-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.drawer-thumb .thumb-image,
.drawer-thumb .thumb-placeholder {
height: 100px;
}
.thumb-info {
padding: 8px;
display: flex;
align-items: center;
justify-content: space-between;
}
</style>

View File

@ -0,0 +1,206 @@
<template>
<div class="delivery-panel">
<!-- 生成设置摘要 -->
<div class="settings-card">
<h4 class="card-title">生成设置</h4>
<el-descriptions :column="1" size="small" border>
<el-descriptions-item label="模板">{{ draft.templateName || '-' }}</el-descriptions-item>
<el-descriptions-item label="场景">{{ draft.scenario || '-' }}</el-descriptions-item>
<el-descriptions-item label="尺寸">{{ sizeText }}</el-descriptions-item>
<el-descriptions-item label="文案模式">{{ draft.aiRawContent ? 'AI 生成' : '模板文案' }}</el-descriptions-item>
</el-descriptions>
</div>
<!-- 合规检查 -->
<div class="compliance-card">
<h4 class="card-title">合规检查</h4>
<ul class="check-list">
<li v-for="check in checks" :key="check.key" class="check-item" :class="check.status">
<span class="check-icon">
<el-icon v-if="check.status === 'pass'" color="#16a34a"><CircleCheckFilled /></el-icon>
<el-icon v-else-if="check.status === 'warn'" color="#e6a23c"><WarningFilled /></el-icon>
<el-icon v-else-if="check.status === 'block'" color="#f56c6c"><CircleCloseFilled /></el-icon>
</span>
<div class="check-content">
<span class="check-label">{{ check.label }}</span>
<span v-if="check.hint" class="check-hint">{{ check.hint }}</span>
</div>
</li>
</ul>
<!-- 人工确认 -->
<div class="confirm-row">
<el-checkbox
:model-value="draft.complianceConfirmed"
@update:model-value="$emit('update:compliance-confirmed', $event)"
>
我已人工核对文案确认合规
</el-checkbox>
</div>
</div>
<!-- 阻断提示 -->
<div v-if="blockingCount > 0" class="block-notice">
<el-icon><WarningFilled /></el-icon>
<span>{{ blockingCount }} 项阻断需处理后才能生成</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { CircleCheckFilled, WarningFilled, CircleCloseFilled } from '@element-plus/icons-vue'
import type { PosterDraft } from '@/composables/usePosterWorkspace'
const props = defineProps<{ draft: PosterDraft }>()
defineEmits<{ 'update:compliance-confirmed': [value: boolean] }>()
const sizeText = computed(() => {
const s = props.draft.exportSize
if (s === '1792x1024') return '横版 1792×1024'
if (s === '1024x1024') return '方图 1024×1024'
return '竖版 1024×1792'
})
interface CheckItem {
key: string
label: string
status: 'pass' | 'warn' | 'block'
hint?: string
}
const checks = computed<CheckItem[]>(() => {
const d = props.draft
const list: CheckItem[] = []
// 1.
if (d.productId && d.caseUploadId && d.dataConfirmed) {
list.push({ key: 'match', label: '产品与计划书匹配', status: 'pass' })
} else if (d.productId && !d.caseUploadId) {
list.push({ key: 'match', label: '尚未上传计划书', status: 'block', hint: '请上传客户计划书' })
} else if (d.caseUploadId && !d.dataConfirmed) {
list.push({ key: 'match', label: '计划书数据待确认', status: 'warn', hint: '请确认解析数据' })
} else {
list.push({ key: 'match', label: '产品和计划书未就绪', status: 'block' })
}
// 2.
if (d.copyContent?.headline && d.copyContent?.body) {
list.push({ key: 'content', label: '标题和正文已填写', status: 'pass' })
} else {
list.push({ key: 'content', label: '标题或正文为空', status: 'block', hint: '请编辑文案' })
}
// 3.
if (d.dataConfirmed) {
list.push({ key: 'numbers', label: '数据来自已确认字段', status: 'pass' })
} else if (d.parseStatus === 'parsed') {
list.push({ key: 'numbers', label: '数据尚未确认', status: 'warn', hint: '请核对解析字段' })
} else {
list.push({ key: 'numbers', label: '无计划书数据', status: 'block' })
}
// 4.
const headline = d.copyContent?.headline || ''
const body = d.copyContent?.body || ''
const allText = headline + body
const riskyWords = ['保证', '稳赚', '无风险', '零风险', '最高收益', '保底', '回报率']
const hasRisky = riskyWords.some(w => allText.includes(w))
if (hasRisky) {
list.push({ key: 'compliance', label: '文案包含敏感表达', status: 'warn', hint: '请检查收益相关表述' })
} else if (allText.length > 0) {
list.push({ key: 'compliance', label: '未检测到夸大表达', status: 'pass' })
}
return list
})
const blockingCount = computed(() =>
checks.value.filter(c => c.status === 'block').length
)
</script>
<style scoped>
.card-title {
margin: 0 0 10px;
font-size: 14px;
font-weight: 600;
color: var(--poster-text);
}
.settings-card {
margin-bottom: 14px;
}
.compliance-card {
margin-bottom: 14px;
}
.check-list {
list-style: none;
padding: 0;
margin: 0 0 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.check-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
font-size: 13px;
}
.check-item.pass {
background: #f0f9eb;
}
.check-item.warn {
background: #fdf6ec;
}
.check-item.block {
background: #fef0f0;
}
.check-icon {
flex-shrink: 0;
margin-top: 1px;
}
.check-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.check-label {
color: var(--poster-text);
font-weight: 500;
}
.check-hint {
font-size: 11px;
color: var(--poster-muted);
}
.confirm-row {
padding-top: 8px;
border-top: 1px solid #f0f0f0;
}
/* ── 阻断提示 ──────────────────────── */
.block-notice {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-radius: 6px;
background: #fef0f0;
color: #f56c6c;
font-size: 13px;
}
</style>

View File

@ -0,0 +1,86 @@
<template>
<aside class="poster-inspector">
<el-tabs v-model="activeTab" class="inspector-tabs">
<el-tab-pane label="文案" name="copy">
<PosterCopyPanel
:copy-mode="draft.copyMode"
:copy-template-id="draft.copyTemplateId"
:ai-style="draft.aiStyle"
:copy-content="draft.copyContent"
:case-upload-id="draft.caseUploadId"
:product-id="draft.productId"
:scenario="draft.scenario"
:use-masked-data="draft.useMaskedData"
@update:copy-mode="$emit('update:draft', { copyMode: $event })"
@update:copy-template-id="$emit('update:draft', { copyTemplateId: $event })"
@update:ai-style="$emit('update:draft', { aiStyle: $event })"
@update:copy-content="$emit('update:draft', { copyContent: $event })"
@update:ai-raw-content="$emit('update:draft', { aiRawContent: $event })"
/>
</el-tab-pane>
<el-tab-pane label="发布" name="delivery">
<PosterDeliveryPanel
:draft="draft"
@update:compliance-confirmed="$emit('update:draft', { complianceConfirmed: $event })"
/>
</el-tab-pane>
</el-tabs>
</aside>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { PosterDraft } from '@/composables/usePosterWorkspace'
import PosterCopyPanel from './PosterCopyPanel.vue'
import PosterDeliveryPanel from './PosterDeliveryPanel.vue'
defineProps<{ draft: PosterDraft }>()
defineEmits<{ 'update:draft': [patch: Partial<PosterDraft>] }>()
const activeTab = ref('copy')
</script>
<style scoped>
.poster-inspector {
width: 320px;
min-width: 300px;
max-width: 360px;
border-left: 1px solid var(--poster-border);
background: #fff;
display: flex;
flex-direction: column;
overflow: hidden;
}
.inspector-tabs {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.inspector-tabs :deep(.el-tabs__header) {
margin: 0;
padding: 0 16px;
background: #fafbfc;
border-bottom: 1px solid var(--poster-border);
}
.inspector-tabs :deep(.el-tabs__content) {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.inspector-tabs :deep(.el-tab-pane) {
min-height: 100%;
}
@media (max-width: 1023px) {
.poster-inspector {
width: 300px;
min-width: 280px;
max-width: 300px;
}
}
</style>

View File

@ -0,0 +1,299 @@
<template>
<div class="product-panel">
<div class="panel-header" @click="expanded = !expanded">
<div class="header-left">
<el-icon :size="16"><Goods /></el-icon>
<strong>产品资料</strong>
</div>
<div class="header-right">
<span v-if="productName" class="summary">{{ productName }}</span>
<el-icon :class="{ 'rotate-180': expanded }"><ArrowDown /></el-icon>
</div>
</div>
<div v-show="expanded" class="panel-body">
<!-- 已选产品摘要 -->
<div v-if="productName" class="product-summary">
<div class="summary-main">
<span class="product-company">{{ productCompany }}</span>
<span class="product-name-text">{{ productName }}</span>
</div>
<el-button text type="primary" size="small" @click="drawerVisible = true">更换</el-button>
</div>
<!-- 未选产品 -->
<el-button v-else type="primary" plain class="select-btn" @click="drawerVisible = true">
<el-icon><Plus /></el-icon>
</el-button>
<!-- 脱敏开关 -->
<div class="mask-option">
<el-switch
:model-value="useMaskedData"
@update:model-value="$emit('update:use-masked-data', $event)"
size="small"
/>
<span class="mask-label">使用脱敏数据</span>
</div>
</div>
<!-- 产品选择抽屉 -->
<el-drawer v-model="drawerVisible" title="选择产品" size="420px" :z-index="2000">
<div class="drawer-search">
<el-input v-model="searchText" placeholder="搜索产品名称" clearable :prefix-icon="Search" />
</div>
<div class="drawer-filters">
<el-select v-model="filterCompany" placeholder="保司筛选" clearable size="small">
<el-option v-for="c in companies" :key="c" :label="c" :value="c" />
</el-select>
<el-select v-model="filterType" placeholder="险种筛选" clearable size="small">
<el-option v-for="t in types" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
</div>
<el-skeleton :loading="loading" animated :rows="5">
<template #template>
<div style="display: grid; gap: 8px">
<el-skeleton-item v-for="i in 4" :key="i" variant="rect" style="height: 56px" />
</div>
</template>
<template #default>
<el-empty v-if="filteredProducts.length === 0" description="暂无匹配产品" />
<div v-else class="product-list">
<button
v-for="p in filteredProducts"
:key="p.id"
type="button"
class="product-item"
:class="{ active: productId === p.id }"
@click="onSelect(p)"
>
<div class="item-main">
<span class="item-name">{{ p.displayName }}</span>
<span class="item-company">{{ p.companyName }}</span>
</div>
<el-tag size="small" type="info">{{ typeMap[p.planType] || p.planType }}</el-tag>
</button>
</div>
</template>
</el-skeleton>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Goods, ArrowDown, Plus, Search } from '@element-plus/icons-vue'
import { posterApi } from '@/utils/poster-api'
const props = defineProps<{
productId: string
productName: string
productCompany: string
useMaskedData: boolean
}>()
const emit = defineEmits<{
'update:product-id': [product: { id: string; name: string; company: string }]
'update:use-masked-data': [value: boolean]
}>()
const expanded = ref(true)
const drawerVisible = ref(false)
const loading = ref(false)
const products = ref<any[]>([])
const searchText = ref('')
const filterCompany = ref('')
const filterType = ref('')
const typeMap: Record<string, string> = { savings: '储蓄', ci: '重疾', iul: 'IUL' }
const companies = computed(() => {
const set = new Set(products.value.map(p => p.companyName).filter(Boolean))
return Array.from(set)
})
const types = computed(() => {
const set = new Set(products.value.map(p => p.planType).filter(Boolean))
return Array.from(set).map(v => ({ value: v, label: typeMap[v] || v }))
})
const filteredProducts = computed(() => {
return products.value.filter(p => {
if (searchText.value && !p.displayName?.toLowerCase().includes(searchText.value.toLowerCase())) return false
if (filterCompany.value && p.companyName !== filterCompany.value) return false
if (filterType.value && p.planType !== filterType.value) return false
return true
})
})
function onSelect(p: any) {
emit('update:product-id', {
id: p.id,
name: p.displayName,
company: p.companyName || '',
})
drawerVisible.value = false
}
onMounted(async () => {
loading.value = true
try {
const res: any = await posterApi.getProducts()
products.value = res?.data?.data ?? res?.data ?? []
} finally {
loading.value = false
}
})
</script>
<style scoped>
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 8px 0;
user-select: none;
}
.header-left {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: var(--poster-text);
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.summary {
font-size: 12px;
color: var(--poster-muted);
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rotate-180 {
transform: rotate(180deg);
}
.panel-body {
padding-top: 8px;
}
.product-summary {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border: 1px solid var(--poster-border);
border-radius: 6px;
background: #f0f7f3;
}
.summary-main {
display: flex;
flex-direction: column;
min-width: 0;
}
.product-company {
font-size: 11px;
color: var(--poster-muted);
}
.product-name-text {
font-size: 13px;
font-weight: 600;
color: var(--poster-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.select-btn {
width: 100%;
}
.mask-option {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #f0f0f0;
}
.mask-label {
font-size: 12px;
color: var(--poster-muted);
}
/* ── 抽屉内部 ──────────────────────── */
.drawer-search {
margin-bottom: 12px;
}
.drawer-filters {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.drawer-filters .el-select {
flex: 1;
}
.product-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.product-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border: 2px solid #e4e7ed;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
background: #fff;
text-align: left;
}
.product-item:hover {
border-color: #3b7a57;
}
.product-item.active {
border-color: #3b7a57;
background: #f0f7f3;
}
.item-main {
display: flex;
flex-direction: column;
min-width: 0;
}
.item-name {
font-weight: 600;
font-size: 14px;
color: var(--poster-text);
}
.item-company {
font-size: 12px;
color: var(--poster-muted);
margin-top: 2px;
}
</style>

View File

@ -0,0 +1,473 @@
<template>
<div class="source-panel">
<div class="panel-header" @click="expanded = !expanded">
<div class="header-left">
<el-icon :size="16"><Document /></el-icon>
<strong>计划书与数据</strong>
</div>
<div class="header-right">
<span v-if="dataConfirmed" class="summary">已确认</span>
<span v-else-if="parseStatus === 'parsed'" class="summary warn">待确认</span>
<el-icon :class="{ 'rotate-180': expanded }"><ArrowDown /></el-icon>
</div>
</div>
<div v-show="expanded" class="panel-body">
<!-- 未上传上传区域 -->
<div v-if="parseStatus === 'none'" class="upload-area">
<div v-if="!productId" class="upload-disabled">
<el-icon :size="24" color="#c0c4cc"><Upload /></el-icon>
<span>请先选择产品</span>
</div>
<template v-else>
<el-input
v-model="localPassword"
type="password"
show-password
placeholder="PDF 密码(未加密留空)"
size="small"
class="password-input"
/>
<el-upload
drag
:auto-upload="false"
:limit="1"
accept=".pdf"
:on-change="handleFileChange"
class="compact-upload"
>
<el-icon class="el-icon--upload"><Upload /></el-icon>
<div class="upload-text">拖拽或<em>点击上传</em>计划书</div>
</el-upload>
</template>
</div>
<!-- 上传中/解析中紧凑状态行 -->
<div v-else-if="parseStatus === 'uploading' || parseStatus === 'queued' || parseStatus === 'parsing'" class="parsing-row">
<el-icon class="spin"><Loading /></el-icon>
<span class="parse-msg">{{ parseMessage || '解析中...' }}</span>
<el-progress :percentage="parseProgress" :stroke-width="4" :show-text="false" style="flex: 1" />
</div>
<!-- 解析失败 -->
<div v-else-if="parseStatus === 'failed'" class="failed-row">
<span class="fail-text">{{ parseFailMessage || '解析失败' }}</span>
<el-button text type="primary" size="small" @click="onReset">重新上传</el-button>
</div>
<!-- 解析成功文件状态行 + 折叠字段 -->
<template v-else-if="parseStatus === 'parsed'">
<div class="file-row">
<el-icon :size="14"><Document /></el-icon>
<span class="file-name">{{ caseFileName || '计划书' }}</span>
<span class="file-size">{{ formatSize(caseFileSize) }}</span>
<el-tag size="small" type="success">已解析</el-tag>
<el-button text type="primary" size="small" @click="onReset">替换</el-button>
</div>
<!-- 已确认收起为摘要 -->
<div v-if="dataConfirmed && !editingFields" class="confirmed-summary">
<el-icon color="#16a34a"><CircleCheckFilled /></el-icon>
<span>{{ confirmedFieldCount }} 个字段已确认</span>
<el-button text type="primary" size="small" @click="editingFields = true">修改</el-button>
</div>
<!-- 待确认/编辑中字段表单 -->
<div v-else class="field-form">
<el-alert v-if="editingFields" title="修改数据后需要重新确认" type="warning" :closable="false" show-icon style="margin-bottom: 10px" />
<el-form label-position="top" size="small">
<el-form-item label="年龄">
<el-input-number v-model="localFields.age" :min="0" :max="100" style="width: 100%" />
</el-form-item>
<el-form-item label="性别">
<el-select v-model="localFields.gender" style="width: 100%">
<el-option label="男" value="男" />
<el-option label="女" value="女" />
</el-select>
</el-form-item>
<el-form-item label="货币">
<el-select v-model="localFields.currency" style="width: 100%">
<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="localFields.sum_assured" :min="0" :step="10000" style="width: 100%" />
</el-form-item>
<el-form-item label="缴费年期">
<el-input-number v-model="localFields.premium_term" :min="1" :max="30" style="width: 100%" />
</el-form-item>
<el-form-item label="年缴保费">
<el-input-number v-model="localFields.annual_premium" :min="0" :step="1000" style="width: 100%" />
</el-form-item>
<el-form-item label="保障期限">
<el-input v-model="localFields.coverage_period" />
</el-form-item>
</el-form>
<el-button type="primary" size="small" @click="onConfirm" :loading="confirming" style="width: 100%">
确认数据
</el-button>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Document, ArrowDown, Upload, Loading, CircleCheckFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { posterApi } from '@/utils/poster-api'
const props = defineProps<{
productId: string
caseUploadId: number | null
caseFileName: string
caseFileSize: number
parseStatus: string
parseProgress: number
parseMessage: string
parseFailMessage: string
parsedFields: Record<string, any>
dataConfirmed: boolean
}>()
const emit = defineEmits<{
'update:parse': [patch: Record<string, any>]
'confirm-data': []
}>()
const expanded = ref(true)
const editingFields = ref(false)
const confirming = ref(false)
const localPassword = ref('')
const localFields = ref({
age: 35, gender: '男', currency: 'USD', sum_assured: 500000,
premium_term: 5, annual_premium: 100000, coverage_period: '终身',
})
// parsedFields
watch(() => props.parsedFields, (v) => {
if (v && Object.keys(v).length > 0) {
Object.assign(localFields.value, v)
}
}, { immediate: true, deep: true })
//
watch(() => props.dataConfirmed, (v) => {
if (v) editingFields.value = false
})
const confirmedFieldCount = computed(() => {
const f = props.parsedFields
if (!f || Object.keys(f).length === 0) return 7
return Object.keys(f).length
})
function formatSize(bytes: number) {
if (!bytes) return ''
if (bytes < 1024) return `${bytes}B`
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)}KB`
return `${(bytes / 1048576).toFixed(1)}MB`
}
let pollTimer: ReturnType<typeof setInterval> | null = null
let pollRetries = 0
const MAX_POLL_RETRIES = 90
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
async function handleFileChange(file: any) {
const raw = file.raw || file
emit('update:parse', {
parseStatus: 'uploading',
parseProgress: 10,
parseMessage: '正在上传...',
dataConfirmed: false,
})
try {
const res: any = await posterApi.uploadCase(props.productId, raw, localPassword.value)
const data = res?.data
emit('update:parse', {
caseUploadId: data?.id,
caseFileName: raw.name || '计划书.pdf',
caseFileSize: raw.size || 0,
parseStatus: data?.parseStatus || 'parsing',
parseProgress: 20,
parseMessage: '计划书已上传,正在解析...',
})
localPassword.value = ''
if (data?.id) {
startParsePolling(data.id)
}
} catch (e: any) {
emit('update:parse', {
parseStatus: 'none',
parseProgress: 0,
parseMessage: '',
})
ElMessage.error(e?.message || '上传失败')
}
}
function startParsePolling(id: number) {
stopPolling()
pollRetries = 0
pollTimer = setInterval(async () => {
pollRetries++
if (pollRetries > MAX_POLL_RETRIES) {
stopPolling()
emit('update:parse', {
parseStatus: 'failed',
parseMessage: '',
parseFailMessage: '解析超时,请重试',
})
return
}
try {
const res: any = await posterApi.getCaseUpload(id)
const data = res?.data
if (!data) return
const status = data.parseStatus
if (status === 'parsed') {
stopPolling()
emit('update:parse', {
parseStatus: 'parsed',
parseProgress: 100,
parseMessage: '',
parsedFields: data.parsedData || {},
})
if (data.parsedData) {
Object.assign(localFields.value, data.parsedData)
}
} else if (status === 'failed') {
stopPolling()
emit('update:parse', {
parseStatus: 'failed',
parseMessage: '',
parseFailMessage: '计划书解析失败,请重新上传。',
})
} else {
emit('update:parse', {
parseProgress: Math.min(90, props.parseProgress + 8),
parseMessage: status === 'queued' ? '任务排队中...' : '正在解析计划书...',
})
}
} catch {
//
}
}, 2000)
}
async function onConfirm() {
if (!props.caseUploadId) return
confirming.value = true
try {
await posterApi.confirmCaseUpload(props.caseUploadId, localFields.value)
emit('update:parse', { parsedFields: { ...localFields.value }, dataConfirmed: true })
emit('confirm-data')
editingFields.value = false
ElMessage.success('数据已确认')
} catch {
ElMessage.error('确认失败')
} finally {
confirming.value = false
}
}
function onReset() {
stopPolling()
emit('update:parse', {
caseUploadId: null,
caseFileName: '',
caseFileSize: 0,
parseStatus: 'none',
parseProgress: 0,
parseMessage: '',
parseFailMessage: '',
parsedFields: {},
dataConfirmed: false,
})
editingFields.value = false
}
</script>
<style scoped>
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 8px 0;
user-select: none;
}
.header-left {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: var(--poster-text);
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.summary {
font-size: 12px;
color: var(--poster-muted);
}
.summary.warn {
color: #e6a23c;
}
.rotate-180 {
transform: rotate(180deg);
}
.panel-body {
padding-top: 8px;
}
/* ── 上传区域 ──────────────────────── */
.upload-disabled {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 16px;
border: 1px dashed #d9d9d9;
border-radius: 6px;
color: #c0c4cc;
font-size: 13px;
}
.compact-upload :deep(.el-upload-dragger) {
padding: 16px;
}
.upload-text {
font-size: 13px;
color: #606266;
}
.upload-text em {
color: #3b7a57;
font-style: normal;
}
.password-input {
margin-bottom: 8px;
}
/* ── 解析中 ────────────────────────── */
.parsing-row {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border: 1px solid var(--poster-border);
border-radius: 6px;
background: #f8fafc;
}
.parse-msg {
font-size: 13px;
color: var(--poster-muted);
white-space: nowrap;
}
.spin {
animation: spin 1s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ── 失败 ──────────────────────────── */
.failed-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border: 1px solid #fde2e2;
border-radius: 6px;
background: #fef0f0;
}
.fail-text {
font-size: 12px;
color: #f56c6c;
}
/* ── 文件状态行 ────────────────────── */
.file-row {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border: 1px solid var(--poster-border);
border-radius: 6px;
background: #fff;
font-size: 13px;
}
.file-name {
font-weight: 500;
color: var(--poster-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.file-size {
color: var(--poster-muted);
font-size: 12px;
flex-shrink: 0;
}
/* ── 确认摘要 ──────────────────────── */
.confirmed-summary {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 8px 10px;
border: 1px solid #e1f3d8;
border-radius: 6px;
background: #f0f9eb;
font-size: 13px;
color: #16a34a;
}
/* ── 字段表单 ──────────────────────── */
.field-form {
margin-top: 8px;
}
.field-form :deep(.el-form-item) {
margin-bottom: 10px;
}
.field-form :deep(.el-form-item__label) {
font-size: 12px;
color: var(--poster-muted);
}
</style>

View File

@ -0,0 +1,244 @@
<template>
<div class="poster-stage" ref="stageRef">
<!-- 缩放控制 -->
<div class="stage-toolbar">
<el-segmented v-model="zoom" :options="zoomOptions" size="small" />
</div>
<!-- 画布主体 -->
<div class="stage-canvas" :style="canvasStyle">
<!-- 生成前布局预览 -->
<div v-if="draft.taskStatus === 'idle' || draft.taskStatus === 'submitting'"
class="canvas-preview" :class="sizeClass" :style="previewBgStyle">
<div class="preview-brand">{{ draft.templateName || '请选择模板' }}</div>
<div class="preview-headline">{{ draft.copyContent?.headline || '客户专属保障方案' }}</div>
<div class="preview-body">{{ draft.copyContent?.body || '编辑文案后实时预览海报效果。' }}</div>
<div class="preview-cta">{{ draft.copyContent?.call_to_action || '联系顾问了解详情' }}</div>
<div v-if="!draft.templateId" class="preview-hint">选择模板后画布将更新配色和构图</div>
</div>
<!-- 生成中 -->
<div v-else-if="draft.taskStatus === 'queued' || draft.taskStatus === 'generating'" class="canvas-loading">
<el-progress type="circle" :percentage="draft.taskProgress" :width="120" />
<p class="loading-text">
{{ draft.taskStatus === 'queued' ? '任务排队中...' : '海报生成中,预计 10 到 30 秒' }}
</p>
</div>
<!-- 生成失败 -->
<div v-else-if="draft.taskStatus === 'failed'" class="canvas-error">
<el-icon :size="48" color="#ef4444"><CircleCloseFilled /></el-icon>
<p class="error-title">生成失败</p>
<p class="error-message">{{ draft.taskError || '请重试' }}</p>
<el-button type="primary" @click="$emit('retry')">重新生成</el-button>
</div>
<!-- 生成完成真实海报 -->
<div v-else-if="draft.taskStatus === 'done' && draft.posterUrl" class="canvas-result">
<img :src="draft.posterUrl" alt="生成的海报" class="poster-image" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { CircleCloseFilled } from '@element-plus/icons-vue'
import type { PosterDraft } from '@/composables/usePosterWorkspace'
const props = defineProps<{ draft: PosterDraft }>()
defineEmits<{ retry: [] }>()
const stageRef = ref<HTMLElement | null>(null)
const zoom = ref('fit')
const zoomOptions = [
{ label: '适应', value: 'fit' },
{ label: '50%', value: '50' },
{ label: '100%', value: '100' },
]
const sizeClass = computed(() => {
const size = props.draft.exportSize
if (size === '1792x1024') return 'landscape'
if (size === '1024x1024') return 'square'
return 'portrait'
})
const aspectRatio = computed(() => {
const size = props.draft.exportSize
if (size === '1792x1024') return '1792 / 1024'
if (size === '1024x1024') return '1 / 1'
return '1024 / 1792'
})
const canvasStyle = computed(() => {
const base: Record<string, string> = {}
if (zoom.value === 'fit') {
base.maxHeight = '100%'
base.maxWidth = '100%'
} else if (zoom.value === '50') {
base.maxHeight = '50%'
base.maxWidth = '50%'
}
return base
})
const previewBgStyle = computed(() => {
return {
aspectRatio: aspectRatio.value,
background: 'linear-gradient(160deg, #1a3a2a 0%, #2d6a4f 50%, #40916c 100%)',
}
})
</script>
<style scoped>
.poster-stage {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
background: #f5f6f8;
}
.stage-toolbar {
position: absolute;
top: 12px;
right: 12px;
z-index: 10;
}
.stage-canvas {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
width: 100%;
height: 100%;
}
/* ── 预览画布 ──────────────────────── */
.canvas-preview {
position: relative;
width: min(340px, 80%);
border-radius: 8px;
padding: 28px;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 14px;
color: #fff;
box-shadow: 0 22px 58px rgb(15 23 42 / 18%);
overflow: hidden;
}
.canvas-preview.portrait {
min-height: 480px;
}
.canvas-preview.landscape {
width: min(560px, 90%);
min-height: 280px;
}
.canvas-preview.square {
width: min(380px, 80%);
min-height: 380px;
}
.preview-brand {
font-size: 12px;
opacity: .76;
}
.preview-headline {
font-size: 24px;
font-weight: 800;
line-height: 1.25;
}
.preview-body {
font-size: 14px;
line-height: 1.6;
opacity: .9;
max-width: 30ch;
}
.preview-cta {
align-self: flex-start;
padding: 8px 12px;
border-radius: 8px;
background: rgb(255 255 255 / 92%);
color: #172033;
font-size: 13px;
font-weight: 700;
}
.preview-hint {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
padding: 4px 10px;
border-radius: 4px;
background: rgb(0 0 0 / 50%);
color: #fff;
font-size: 11px;
white-space: nowrap;
}
/* ── 加载状态 ──────────────────────── */
.canvas-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.loading-text {
color: var(--poster-muted);
font-size: 14px;
}
/* ── 错误状态 ──────────────────────── */
.canvas-error {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
.error-title {
color: #ef4444;
font-size: 16px;
font-weight: 600;
margin: 0;
}
.error-message {
color: var(--poster-muted);
font-size: 13px;
margin: 0;
}
/* ── 生成结果 ──────────────────────── */
.canvas-result {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.poster-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 8px 32px rgb(15 23 42 / 12%);
}
</style>

View File

@ -0,0 +1,155 @@
<template>
<header class="poster-toolbar">
<div class="toolbar-left">
<el-input
v-model="localTitle"
class="title-input"
placeholder="海报项目名称"
@blur="onTitleBlur"
@keydown.enter="($event.target as HTMLInputElement)?.blur()"
/>
<span v-if="saving" class="save-indicator saving">
<el-icon class="spin"><Loading /></el-icon>
</span>
<span v-else-if="lastSavedText" class="save-indicator saved">
{{ lastSavedText }}
</span>
</div>
<div class="toolbar-right">
<el-badge :value="pendingTaskCount" :hidden="pendingTaskCount === 0" :max="9">
<el-button :icon="Bell" text @click="$emit('open-tasks')">任务</el-button>
</el-badge>
<el-button :icon="Clock" text @click="$emit('open-history')">历史</el-button>
<el-dropdown trigger="click" @command="onCommand">
<el-button :icon="MoreFilled" text />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="reset">
<el-icon><RefreshLeft /></el-icon>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { Bell, Clock, MoreFilled, RefreshLeft, Loading } from '@element-plus/icons-vue'
const props = defineProps<{
title: string
saving?: boolean
lastSaved?: Date | null
pendingTaskCount?: number
}>()
const emit = defineEmits<{
'update:title': [value: string]
'open-tasks': []
'open-history': []
reset: []
}>()
const localTitle = ref(props.title || '未命名海报')
watch(() => props.title, (v) => {
if (v) localTitle.value = v
})
function onTitleBlur() {
emit('update:title', localTitle.value)
}
const lastSavedText = computed(() => {
if (!props.lastSaved) return ''
const now = Date.now()
const diff = now - props.lastSaved.getTime()
if (diff < 60000) return '刚刚保存'
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前保存`
return ''
})
function onCommand(cmd: string) {
if (cmd === 'reset') emit('reset')
}
</script>
<style scoped>
.poster-toolbar {
height: 56px;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--poster-border);
background: #fff;
flex-shrink: 0;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.title-input {
max-width: 280px;
}
.title-input :deep(.el-input__inner) {
font-size: 15px;
font-weight: 600;
color: var(--poster-text);
border: none;
padding: 0;
background: transparent;
}
.title-input :deep(.el-input__wrapper) {
box-shadow: none !important;
padding: 4px 8px;
border-radius: 4px;
}
.title-input :deep(.el-input__wrapper:hover),
.title-input :deep(.el-input__wrapper:focus-within) {
background: #f5f7fa;
}
.save-indicator {
font-size: 12px;
white-space: nowrap;
}
.save-indicator.saving {
color: var(--poster-muted);
display: flex;
align-items: center;
gap: 4px;
}
.save-indicator.saved {
color: #16a34a;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.toolbar-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,314 @@
/**
*
*
*
* 1. PosterDraft 稿
* 2. localStorage
* 3. API
* 4.
*/
import { ref, computed, watch, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import api from '@/utils/api'
/** 海报草稿统一数据结构 */
export interface PosterDraft {
// 产品
productId: string
productName: string
productCompany: string
// 脱敏
useMaskedData: boolean
// 上传
caseUploadId: number | null
caseFileName: string
caseFileSize: number
parseStatus: 'none' | 'uploading' | 'queued' | 'parsing' | 'parsed' | 'failed'
parseProgress: number
parseMessage: string
parseFailMessage: string
// 解析字段
parsedFields: Record<string, any>
// 确认状态
dataConfirmed: boolean
// 创意
scenario: string
exportSize: string
templateId: number | null
templateName: string
// 文案
copyMode: 'template' | 'ai'
copyTemplateId: number | null
aiStyle: string
copyContent: {
headline: string
body: string
call_to_action: string
} | null
aiRawContent: any
// 生成任务
taskStatus: 'idle' | 'submitting' | 'queued' | 'generating' | 'done' | 'failed'
taskProgress: number
taskError: string | null
taskRecordId: number | null
// 生成结果
posterUrl: string | null
// 合规
complianceConfirmed: boolean
}
function createEmptyDraft(): PosterDraft {
return {
productId: '',
productName: '',
productCompany: '',
useMaskedData: false,
caseUploadId: null,
caseFileName: '',
caseFileSize: 0,
parseStatus: 'none',
parseProgress: 0,
parseMessage: '',
parseFailMessage: '',
parsedFields: {},
dataConfirmed: false,
scenario: '朋友圈沟通',
exportSize: '1024x1792',
templateId: null,
templateName: '',
copyMode: 'template',
copyTemplateId: null,
aiStyle: '专业稳健',
copyContent: null,
aiRawContent: null,
taskStatus: 'idle',
taskProgress: 0,
taskError: null,
taskRecordId: null,
posterUrl: null,
complianceConfirmed: false,
}
}
export function usePosterWorkspace() {
const router = useRouter()
const route = useRoute()
// ── 核心状态 ──────────────────────────────
const recordId = ref<number | null>(null)
const draft = ref<PosterDraft>(createEmptyDraft())
const loading = ref(false)
const restored = ref(false)
// ── 持久化 Key ────────────────────────────
const STORAGE_KEY = computed(() =>
recordId.value ? `poster_ws_draft_${recordId.value}` : 'poster_ws_draft_new'
)
// ── 持久化到 localStorage ─────────────────
function persistDraft() {
try {
localStorage.setItem(STORAGE_KEY.value, JSON.stringify(draft.value))
} catch { /* quota exceeded, ignore */ }
}
// 深度监听草稿变更 → 自动持久化
watch(draft, persistDraft, { deep: true })
// ── 从 localStorage 恢复 ─────────────────
function tryRestoreLocal(): boolean {
try {
const saved = localStorage.getItem(STORAGE_KEY.value)
if (saved) {
const parsed = JSON.parse(saved)
Object.assign(draft.value, parsed)
return true
}
} catch { /* corrupt data, ignore */ }
return false
}
// ── 从后端 API 恢复 ──────────────────────
async function restore(): Promise<boolean> {
const urlRecordId = route.params.recordId as string
if (urlRecordId) {
recordId.value = Number(urlRecordId)
}
// 先尝试 localStorage 快恢复
if (tryRestoreLocal()) {
restored.value = true
}
if (!recordId.value) return false
loading.value = true
try {
const res: any = await api.get(`/poster/records/${recordId.value}`)
const data = res?.data ?? res
if (data?.code === 0 && data?.data) {
const record = data.data
// 用后端数据填充缺失字段
if (!draft.value.productId && record.productId) {
draft.value.productId = record.productId
}
if (!draft.value.caseUploadId && record.caseUploadId) {
draft.value.caseUploadId = record.caseUploadId
}
if (!draft.value.templateId && record.templateId) {
draft.value.templateId = record.templateId
}
if (record.exportSize) {
draft.value.exportSize = record.exportSize
}
if (record.copyContent) {
draft.value.copyContent = record.copyContent
}
if (record.extraData?.useMaskedData !== undefined) {
draft.value.useMaskedData = record.extraData.useMaskedData
}
restored.value = true
return true
}
} catch (e) {
console.warn('恢复海报工作区失败:', e)
} finally {
loading.value = false
}
return false
}
// ── URL 同步 ─────────────────────────────
function syncUrl() {
if (recordId.value) {
const target = `/poster/${recordId.value}`
if (route.path !== target) {
router.replace(target)
}
}
}
function setRecordId(id: number) {
recordId.value = id
persistDraft()
syncUrl()
}
// ── 重置 ─────────────────────────────────
function reset() {
// 清理 localStorage
try {
localStorage.removeItem(STORAGE_KEY.value)
} catch { /* ignore */ }
recordId.value = null
draft.value = createEmptyDraft()
restored.value = false
router.replace('/poster')
}
// ── 完成进度计算 ─────────────────────────
const completionItems = computed(() => {
const d = draft.value
return [
{
key: 'product',
label: '选择产品',
done: !!d.productId,
blocking: true,
},
{
key: 'upload',
label: '上传计划书',
done: d.parseStatus === 'parsed' && d.dataConfirmed,
blocking: true,
},
{
key: 'copy',
label: '编辑文案',
done: !!d.copyContent?.headline,
blocking: true,
},
{
key: 'compliance',
label: '合规确认',
done: d.complianceConfirmed,
blocking: true,
},
]
})
const completedCount = computed(() =>
completionItems.value.filter(i => i.done).length
)
const totalCount = computed(() => completionItems.value.length)
const completionText = computed(() => {
const remaining = completionItems.value.filter(i => !i.done)
if (remaining.length === 0) return '所有项目已完成,可以生成海报'
return `已完成 ${completedCount.value}/${totalCount.value} 项,还需${remaining.map(i => i.label).join('、')}`
})
// ── 主操作按钮状态 ──────────────────────
type ActionState =
| 'select-product'
| 'upload-data'
| 'confirm-data'
| 'generate-copy'
| 'confirm-compliance'
| 'generate-poster'
| 'download-poster'
| 'generating'
const actionState = computed<ActionState>(() => {
const d = draft.value
if (d.taskStatus === 'submitting' || d.taskStatus === 'queued' || d.taskStatus === 'generating') {
return 'generating'
}
if (d.taskStatus === 'done' && d.posterUrl) {
return 'download-poster'
}
if (!d.productId) return 'select-product'
if (d.parseStatus !== 'parsed' || !d.dataConfirmed) return 'upload-data'
if (!d.copyContent?.headline) return 'generate-copy'
if (!d.complianceConfirmed) return 'confirm-compliance'
return 'generate-poster'
})
const actionLabel = computed(() => {
const labels: Record<ActionState, string> = {
'select-product': '选择产品',
'upload-data': '上传计划书',
'confirm-data': '确认数据',
'generate-copy': '生成文案',
'confirm-compliance': '确认合规',
'generate-poster': '生成海报',
'download-poster': '下载海报',
'generating': '生成中...',
}
return labels[actionState.value]
})
const canGenerate = computed(() =>
actionState.value === 'generate-poster' || actionState.value === 'download-poster'
)
return {
recordId,
draft,
loading,
restored,
restore,
reset,
setRecordId,
persistDraft,
completionItems,
completedCount,
totalCount,
completionText,
actionState,
actionLabel,
canGenerate,
}
}

View File

@ -1,267 +1,353 @@
<template>
<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>
<div class="poster-workspace" :class="{ 'mobile-mode': isMobile }">
<!-- 顶部工具栏 -->
<PosterToolbar
:title="ws.draft.value.productName || '未命名海报'"
:saving="false"
:last-saved="null"
:pending-task-count="0"
@update:title="ws.draft.value.productName = $event"
@open-tasks="showTasksDrawer = true"
@open-history="router.push('/poster/history')"
@reset="onReset"
/>
<nav class="page-sections" aria-label="海报页面内容">
<el-button :type="activeSection === 'workspace' ? 'primary' : 'default'" @click="activeSection = 'workspace'">
生成工作台
</el-button>
<el-button :type="activeSection === 'tasks' ? 'primary' : 'default'" :icon="Bell" @click="activeSection = 'tasks'">
任务中心
</el-button>
</nav>
<section v-if="activeSection === 'workspace'" class="generator-progress">
<div class="mobile-step-summary">
{{ currentStep + 1 }}/{{ steps.length }} {{ steps[currentStep].title }}
<span>{{ steps[currentStep].description }}</span>
<!-- 主工作区三栏布局 -->
<div class="workspace-body" :class="{ 'hide-inspector': hideInspector }">
<!-- 移动端标签页切换 -->
<div v-if="isMobile" class="mobile-tabs">
<button :class="{ active: mobileTab === 'config' }" @click="mobileTab = 'config'">配置</button>
<button :class="{ active: mobileTab === 'stage' }" @click="mobileTab = 'stage'">预览</button>
<button :class="{ active: mobileTab === 'inspector' }" @click="mobileTab = 'inspector'">发布</button>
</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 v-if="activeSection === 'workspace'" class="step-content">
<PosterStepProduct v-if="currentStep === 0" v-model:useMaskedData="useMaskedData" @next="onStep1Next" />
<PosterStepUpload
v-else-if="currentStep === 1"
:product-id="selectedProductId"
@next="onStep2Next"
@back="currentStep--"
<!-- 左栏配置区 -->
<PosterConfigRail
v-show="!isMobile || mobileTab === 'config'"
:draft="ws.draft.value"
@update:draft="onDraftUpdate"
/>
<PosterStepTemplate
v-else-if="currentStep === 2"
:case-upload-id="caseUploadId"
:product-id="selectedProductId"
:use-masked-data="useMaskedData"
@next="onStep3Next"
@back="currentStep--"
<!-- 中央海报画布 -->
<PosterStage
v-show="!isMobile || mobileTab === 'stage'"
:draft="ws.draft.value"
@retry="onGenerate"
/>
<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"
:use-masked-data="useMaskedData"
@back="currentStep--"
<!-- 右栏文案与发布 -->
<PosterInspector
v-show="!isMobile || mobileTab === 'inspector'"
:draft="ws.draft.value"
@update:draft="onDraftUpdate"
/>
</div>
<TasksPage v-else artifact-type="poster" embedded />
<!-- 底部操作栏 -->
<PosterActionBar
:draft="ws.draft.value"
:completion-text="ws.completionText.value"
:completion-items="ws.completionItems.value"
:action-label="ws.actionLabel.value"
:action-state="ws.actionState.value"
:can-generate="ws.canGenerate.value"
@action="onAction"
@download="onDownload"
@regenerate="onRegenerate"
/>
<!-- 任务抽屉 -->
<el-drawer v-model="showTasksDrawer" title="任务中心" size="400px">
<TasksPage artifact-type="poster" embedded />
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { Bell, Clock, RefreshLeft } from '@element-plus/icons-vue'
import { usePosterWorkspace } from '@/composables/useWorkspace'
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'
import { ElMessage, ElMessageBox } from 'element-plus'
import { usePosterWorkspace } from '@/composables/usePosterWorkspace'
import { useMobile } from '@/composables/useMobile'
import { posterApi } from '@/utils/poster-api'
import PosterToolbar from '@/components/poster/workspace/PosterToolbar.vue'
import PosterConfigRail from '@/components/poster/workspace/PosterConfigRail.vue'
import PosterStage from '@/components/poster/workspace/PosterStage.vue'
import PosterInspector from '@/components/poster/workspace/PosterInspector.vue'
import PosterActionBar from '@/components/poster/workspace/PosterActionBar.vue'
import TasksPage from './TasksPage.vue'
const router = useRouter()
const ws = usePosterWorkspace()
const {
currentStep, selectedProductId, caseUploadId, selectedTemplateId,
selectedTemplateName, selectedScenarioLabel, selectedSize,
useMaskedData, copyContent, aiRawContent,
} = ws
const activeSection = ref<'workspace' | 'tasks'>('workspace')
const { isMobile } = useMobile()
const steps = [
{ title: '选择产品', description: '确定客户沟通对象' },
{ title: '上传', description: '解析计划书数据' },
{ title: '模板文案', description: '选择场景并编辑' },
{ title: '预览导出', description: '生成并下载海报' },
]
const showTasksDrawer = ref(false)
const mobileTab = ref<'config' | 'stage' | 'inspector'>('stage')
// URL recordId
//
const workspaceWidth = ref(window.innerWidth - 220) //
function updateWorkspaceWidth() {
workspaceWidth.value = window.innerWidth - 220
}
onMounted(() => window.addEventListener('resize', updateWorkspaceWidth))
onBeforeUnmount(() => window.removeEventListener('resize', updateWorkspaceWidth))
// < 900px
const hideInspector = computed(() => workspaceWidth.value < 900)
// 稿
function onDraftUpdate(patch: Record<string, any>) {
Object.assign(ws.draft.value, patch)
}
//
async function onAction() {
const state = ws.actionState.value
switch (state) {
case 'select-product':
//
break
case 'upload-data':
//
break
case 'confirm-data':
//
break
case 'generate-copy':
//
break
case 'confirm-compliance':
//
break
case 'generate-poster':
await onGenerate()
break
}
}
//
let pollTimer: ReturnType<typeof setInterval> | null = null
let pollRetries = 0
const MAX_POLL_RETRIES = 90
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
async function onGenerate() {
const d = ws.draft.value
d.taskStatus = 'submitting'
d.taskProgress = 0
d.taskError = null
d.posterUrl = null
pollRetries = 0
try {
const res: any = await posterApi.generatePoster({
caseUploadId: d.caseUploadId ?? undefined,
productId: d.productId,
templateId: d.templateId ?? undefined,
copyContent: d.copyContent,
aiRawContent: d.aiRawContent,
size: d.exportSize,
copyMode: d.aiRawContent ? 'ai' : 'template',
useMaskedData: d.useMaskedData || false,
})
const record = res?.data
d.taskRecordId = record?.id
if (record?.id) {
ws.setRecordId(record.id)
d.taskStatus = 'queued'
startPolling(record.id)
}
} catch (e: any) {
d.taskStatus = 'failed'
d.taskError = e?.message || '提交失败'
}
}
function startPolling(id: number) {
stopPolling()
pollRetries = 0
pollTimer = setInterval(async () => {
pollRetries++
if (pollRetries > MAX_POLL_RETRIES) {
stopPolling()
ws.draft.value.taskStatus = 'failed'
ws.draft.value.taskError = '生成超时,请重试'
return
}
try {
const res: any = await posterApi.getRecord(id)
const data = res?.data
ws.draft.value.taskStatus = data?.taskStatus || 'generating'
ws.draft.value.taskProgress = data?.taskProgress || 0
ws.draft.value.taskError = data?.taskError || null
if (ws.draft.value.taskStatus === 'done') {
stopPolling()
try {
const blob = await posterApi.downloadPoster(id)
ws.draft.value.posterUrl = URL.createObjectURL(blob)
} catch {
ws.draft.value.taskStatus = 'failed'
ws.draft.value.taskError = '图片下载失败'
}
} else if (ws.draft.value.taskStatus === 'failed') {
stopPolling()
}
} catch {
//
}
}, 2000)
}
//
async function onDownload() {
const recordId = ws.draft.value.taskRecordId || ws.recordId.value
if (!recordId) return
try {
const blob = await posterApi.downloadPoster(recordId)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `poster_${recordId}.png`
a.click()
URL.revokeObjectURL(url)
} catch {
ElMessage.error('下载失败')
}
}
//
function onRegenerate() {
stopPolling()
if (ws.draft.value.posterUrl) {
URL.revokeObjectURL(ws.draft.value.posterUrl)
}
ws.draft.value.taskStatus = 'idle'
ws.draft.value.taskProgress = 0
ws.draft.value.taskError = null
ws.draft.value.posterUrl = null
ws.draft.value.taskRecordId = null
pollRetries = 0
}
//
async function onReset() {
try {
await ElMessageBox.confirm('确定要重新开始吗?当前草稿将丢失。', '重新开始', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
stopPolling()
ws.reset()
} catch {
//
}
}
//
onMounted(async () => {
await ws.restore()
})
function onStep1Next(productId: string) {
selectedProductId.value = productId
currentStep.value++
}
function onStep2Next(id: number) {
caseUploadId.value = id
currentStep.value++
}
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
currentStep.value++
}
function resetAll() {
ws.reset()
}
onBeforeUnmount(() => {
stopPolling()
if (ws.draft.value.posterUrl) {
URL.revokeObjectURL(ws.draft.value.posterUrl)
}
})
</script>
<style scoped>
.poster-page {
padding: 24px;
/* ── 主题变量 ───────────────────────── */
.poster-workspace {
--poster-accent: #3b7a57;
--poster-accent-light: #f0f7f3;
--poster-border: #e5eaf3;
--poster-text: #172033;
--poster-muted: #667085;
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;
overflow: hidden;
background: #fff;
}
.section-label {
margin: 0 0 6px;
color: var(--generator-accent);
font-size: 13px;
font-weight: 600;
/* ── 工作区主体:三栏 grid ────────────── */
.workspace-body {
flex: 1;
display: grid;
grid-template-columns: minmax(240px, 280px) 1fr minmax(260px, 320px);
overflow: hidden;
min-height: 0;
}
.generator-header h1 {
margin: 0;
color: var(--generator-text);
font-size: 22px;
line-height: 1.3;
/* ── 隐藏第三栏inspector在双栏模式 ── */
.workspace-body.hide-inspector {
grid-template-columns: minmax(240px, 280px) 1fr;
}
.header-copy {
margin: 8px 0 0;
color: var(--generator-muted);
font-size: 14px;
line-height: 1.6;
.workspace-body.hide-inspector > :nth-child(3) {
display: none !important;
}
.header-actions {
/* ── 移动端标签页 ────────────────────── */
.mobile-tabs {
display: flex;
gap: 10px;
border-bottom: 1px solid var(--poster-border);
background: #fafbfc;
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-tabs button {
flex: 1;
padding: 10px;
border: none;
background: none;
font-size: 14px;
color: var(--poster-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
}
.page-sections {
width: min(1180px, 100%);
margin: 0 auto;
display: flex;
gap: 8px;
}
.poster-steps {
margin-bottom: 0;
}
.mobile-step-summary {
display: none;
color: var(--generator-text);
.mobile-tabs button.active {
color: var(--poster-accent);
border-bottom-color: var(--poster-accent);
font-weight: 600;
}
.mobile-step-summary span {
display: block;
margin-top: 4px;
color: var(--generator-muted);
font-size: 13px;
font-weight: 400;
/* ── 移动端布局 ─────────────────────── */
.mobile-mode .workspace-body {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.step-content {
width: min(1180px, 100%);
margin: 0 auto;
min-height: 400px;
.mobile-mode .workspace-body :deep(.config-rail),
.mobile-mode .workspace-body :deep(.poster-inspector) {
width: 100%;
min-width: 0;
max-width: none;
border: none;
}
@media (max-width: 767px) {
.poster-page {
padding: 12px;
padding-bottom: 88px;
}
.mobile-mode .workspace-body :deep(.config-rail) {
border-bottom: 1px solid var(--poster-border);
}
.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;
/* ── 双栏响应式 ──────────────────────── */
@media (max-width: 768px) {
.workspace-body:not(.mobile-tabs-visible) {
grid-template-columns: 1fr;
}
}
</style>