优化海报程程页面PPT
This commit is contained in:
parent
751302205a
commit
65d17179b3
@ -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:
|
||||
"""像素转 EMU(96 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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)} 页可能为空白页",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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' ? '任务排队中...' : '正在解析计划书...'
|
||||
|
||||
135
frontend/src/components/poster/workspace/PosterActionBar.vue
Normal file
135
frontend/src/components/poster/workspace/PosterActionBar.vue
Normal 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>
|
||||
@ -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>
|
||||
291
frontend/src/components/poster/workspace/PosterCopyPanel.vue
Normal file
291
frontend/src/components/poster/workspace/PosterCopyPanel.vue
Normal 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>
|
||||
335
frontend/src/components/poster/workspace/PosterCreativePanel.vue
Normal file
335
frontend/src/components/poster/workspace/PosterCreativePanel.vue
Normal 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>
|
||||
206
frontend/src/components/poster/workspace/PosterDeliveryPanel.vue
Normal file
206
frontend/src/components/poster/workspace/PosterDeliveryPanel.vue
Normal 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>
|
||||
86
frontend/src/components/poster/workspace/PosterInspector.vue
Normal file
86
frontend/src/components/poster/workspace/PosterInspector.vue
Normal 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>
|
||||
299
frontend/src/components/poster/workspace/PosterProductPanel.vue
Normal file
299
frontend/src/components/poster/workspace/PosterProductPanel.vue
Normal 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>
|
||||
473
frontend/src/components/poster/workspace/PosterSourcePanel.vue
Normal file
473
frontend/src/components/poster/workspace/PosterSourcePanel.vue
Normal 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>
|
||||
244
frontend/src/components/poster/workspace/PosterStage.vue
Normal file
244
frontend/src/components/poster/workspace/PosterStage.vue
Normal 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>
|
||||
155
frontend/src/components/poster/workspace/PosterToolbar.vue
Normal file
155
frontend/src/components/poster/workspace/PosterToolbar.vue
Normal 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>
|
||||
314
frontend/src/composables/usePosterWorkspace.ts
Normal file
314
frontend/src/composables/usePosterWorkspace.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user