552 lines
18 KiB
TypeScript
552 lines
18 KiB
TypeScript
/**
|
||
* 海报工作区统一状态管理。
|
||
*
|
||
* 核心职责:
|
||
* 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'
|
||
import { useAuth } from '@/composables/useAuth'
|
||
|
||
export interface PosterTheme {
|
||
primary: string
|
||
accent: string
|
||
surface?: string
|
||
text?: string
|
||
muted?: string
|
||
}
|
||
|
||
export interface PosterRenderDocument extends Record<string, any> {
|
||
schemaVersion: 2
|
||
revision: number
|
||
formatId: string
|
||
outputMode: 'single' | 'long'
|
||
templateId: number | null
|
||
exportSize: string
|
||
copy: PosterDraft['copyContent']
|
||
facts: Record<string, any>
|
||
features: Array<{ title: string; summary?: string; icon?: string; source_page?: number }>
|
||
theme: PosterTheme
|
||
sections: Array<{ id: string; visible: boolean }>
|
||
}
|
||
|
||
/** 海报草稿统一数据结构 */
|
||
export interface PosterDraft {
|
||
// 产品
|
||
productId: string
|
||
productSourceType: 'library_product' | 'user_material' | ''
|
||
productSourceId: string
|
||
productSourceConfirmedAt: string
|
||
productName: string
|
||
productCompany: string
|
||
// 上传
|
||
caseUploadId: number | null
|
||
caseFileName: string
|
||
caseFileSize: number
|
||
parseStatus: 'none' | 'uploading' | 'queued' | 'parsing' | 'parsed' | 'partial' | 'failed'
|
||
parseProgress: number
|
||
parseMessage: string
|
||
parseFailMessage: string
|
||
// 解析字段
|
||
parsedFields: Record<string, any>
|
||
// 确认状态
|
||
dataConfirmed: boolean
|
||
// 创意
|
||
scenario: string
|
||
outputMode: 'single' | 'long'
|
||
formatId: string
|
||
exportSize: string
|
||
longHeightMode: 'auto' | 'custom'
|
||
customLongHeight: number
|
||
templateId: number | null
|
||
templateName: string
|
||
templateColorScheme: PosterTheme | null
|
||
renderDocument: PosterRenderDocument | null
|
||
// 文案
|
||
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' | 'background_ready' | 'asset_loading' | 'done' | 'failed'
|
||
taskProgress: number
|
||
taskError: string | null
|
||
taskRecordId: number | null
|
||
// 参考图
|
||
referenceImages: Array<{ id: string; url: string; file?: File; name: string }>
|
||
// 生成结果
|
||
posterUrl: string | null
|
||
backgroundCandidateUrl: string | null
|
||
sections: Array<{ id: string; visible: boolean }>
|
||
// 合规
|
||
complianceConfirmed: boolean
|
||
complianceStatus: 'unchecked' | 'pass' | 'warn' | 'block'
|
||
complianceIssues: Array<Record<string, any>>
|
||
complianceRevision: string
|
||
}
|
||
|
||
function createEmptyDraft(): PosterDraft {
|
||
return {
|
||
productId: '',
|
||
productSourceType: '',
|
||
productSourceId: '',
|
||
productSourceConfirmedAt: '',
|
||
productName: '',
|
||
productCompany: '',
|
||
caseUploadId: null,
|
||
caseFileName: '',
|
||
caseFileSize: 0,
|
||
parseStatus: 'none',
|
||
parseProgress: 0,
|
||
parseMessage: '',
|
||
parseFailMessage: '',
|
||
parsedFields: {},
|
||
dataConfirmed: false,
|
||
scenario: '朋友圈沟通',
|
||
outputMode: 'single',
|
||
formatId: 'single_2_3',
|
||
exportSize: '1024x1536',
|
||
longHeightMode: 'auto',
|
||
customLongHeight: 4500,
|
||
templateId: null,
|
||
templateName: '',
|
||
templateColorScheme: null,
|
||
renderDocument: null,
|
||
copyMode: 'template',
|
||
copyTemplateId: null,
|
||
aiStyle: '专业稳健',
|
||
copyContent: null,
|
||
aiRawContent: null,
|
||
taskStatus: 'idle',
|
||
taskProgress: 0,
|
||
taskError: null,
|
||
taskRecordId: null,
|
||
referenceImages: [],
|
||
posterUrl: null,
|
||
backgroundCandidateUrl: null,
|
||
sections: [
|
||
{ id: 'hero', visible: true },
|
||
{ id: 'summary', visible: true },
|
||
{ id: 'benefits', visible: true },
|
||
{ id: 'features', visible: true },
|
||
{ id: 'cta', visible: true },
|
||
{ id: 'disclaimer', visible: true },
|
||
],
|
||
complianceConfirmed: false,
|
||
complianceStatus: 'unchecked',
|
||
complianceIssues: [],
|
||
complianceRevision: '',
|
||
}
|
||
}
|
||
|
||
function normalizeFormat(draft: PosterDraft) {
|
||
if (draft.outputMode === 'long') {
|
||
draft.formatId = 'long_1242_auto'
|
||
const height = Number(draft.customLongHeight)
|
||
if (draft.longHeightMode === 'custom' && Number.isInteger(height) && height >= 1920 && height <= 30000) {
|
||
draft.customLongHeight = height
|
||
draft.exportSize = `1242x${height}`
|
||
} else {
|
||
draft.longHeightMode = 'auto'
|
||
draft.exportSize = '1242xauto'
|
||
}
|
||
return
|
||
}
|
||
if (draft.formatId === 'single_9_16' || draft.exportSize === '1080x1920') {
|
||
draft.formatId = 'single_9_16'
|
||
draft.exportSize = '1080x1920'
|
||
return
|
||
}
|
||
draft.formatId = 'single_2_3'
|
||
draft.exportSize = '1024x1536'
|
||
}
|
||
|
||
export function usePosterWorkspace() {
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const { user } = useAuth()
|
||
|
||
// ── 核心状态 ──────────────────────────────
|
||
const recordId = ref<number | null>(null)
|
||
const draft = ref<PosterDraft>(createEmptyDraft())
|
||
const loading = ref(false)
|
||
const restored = ref(false)
|
||
|
||
// ── 持久化 Key ────────────────────────────
|
||
function getStorageKey(targetRecordId: number | null = recordId.value): string {
|
||
if (!user.value?.id) return ''
|
||
const userId = encodeURIComponent(user.value.id)
|
||
return `poster_ws_draft_${userId}_${targetRecordId ?? 'new'}`
|
||
}
|
||
|
||
const STORAGE_KEY = computed(() => getStorageKey())
|
||
|
||
// 旧版草稿键没有用户维度,无法安全判断归属,直接清理以避免跨账号恢复。
|
||
try {
|
||
for (let index = localStorage.length - 1; index >= 0; index--) {
|
||
const key = localStorage.key(index)
|
||
if (key && /^poster_ws_draft_(?:new|\d+)$/.test(key)) {
|
||
localStorage.removeItem(key)
|
||
}
|
||
}
|
||
} catch { /* ignore */ }
|
||
|
||
/** 需要持久化的字段(排除瞬态数据) */
|
||
function getPersistableDraft(): Partial<PosterDraft> {
|
||
const d = draft.value
|
||
return {
|
||
productId: d.productId,
|
||
productSourceType: d.productSourceType,
|
||
productSourceId: d.productSourceId,
|
||
productSourceConfirmedAt: d.productSourceConfirmedAt,
|
||
productName: d.productName,
|
||
productCompany: d.productCompany,
|
||
caseUploadId: d.caseUploadId,
|
||
caseFileName: d.caseFileName,
|
||
caseFileSize: d.caseFileSize,
|
||
parseStatus: d.parseStatus,
|
||
parseProgress: d.parseProgress,
|
||
parseMessage: d.parseMessage,
|
||
parseFailMessage: d.parseFailMessage,
|
||
parsedFields: d.parsedFields,
|
||
dataConfirmed: d.dataConfirmed,
|
||
scenario: d.scenario,
|
||
outputMode: d.outputMode,
|
||
formatId: d.formatId,
|
||
exportSize: d.exportSize,
|
||
longHeightMode: d.longHeightMode,
|
||
customLongHeight: d.customLongHeight,
|
||
templateId: d.templateId,
|
||
templateName: d.templateName,
|
||
templateColorScheme: d.templateColorScheme,
|
||
renderDocument: d.renderDocument,
|
||
copyMode: d.copyMode,
|
||
copyTemplateId: d.copyTemplateId,
|
||
aiStyle: d.aiStyle,
|
||
copyContent: d.copyContent,
|
||
aiRawContent: d.aiRawContent,
|
||
// 参考图不持久化到 localStorage(blob URL 刷新后失效,需重新上传)
|
||
complianceConfirmed: d.complianceConfirmed,
|
||
complianceStatus: d.complianceStatus,
|
||
complianceIssues: d.complianceIssues,
|
||
complianceRevision: d.complianceRevision,
|
||
sections: d.sections,
|
||
// 以下字段不持久化:taskStatus, taskProgress, taskError, taskRecordId, posterUrl, referenceImages
|
||
}
|
||
}
|
||
|
||
// ── 持久化到 localStorage ─────────────────
|
||
function persistDraft() {
|
||
try {
|
||
const key = STORAGE_KEY.value
|
||
if (!key) return
|
||
localStorage.setItem(key, JSON.stringify(getPersistableDraft()))
|
||
} catch { /* quota exceeded, ignore */ }
|
||
}
|
||
|
||
// 深度监听草稿变更 → 自动持久化
|
||
watch(draft, persistDraft, { deep: true })
|
||
|
||
// ── 从 localStorage 恢复 ─────────────────
|
||
function tryRestoreLocal(): boolean {
|
||
try {
|
||
const key = STORAGE_KEY.value
|
||
if (!key) return false
|
||
const saved = localStorage.getItem(key)
|
||
if (saved) {
|
||
const parsed = JSON.parse(saved)
|
||
Object.assign(draft.value, parsed)
|
||
if (draft.value.productId && !draft.value.productSourceId) {
|
||
draft.value.productSourceType = 'library_product'
|
||
draft.value.productSourceId = draft.value.productId
|
||
}
|
||
normalizeFormat(draft.value)
|
||
return true
|
||
}
|
||
} catch { /* corrupt data, ignore */ }
|
||
return false
|
||
}
|
||
|
||
// ── 从后端 API 恢复 ──────────────────────
|
||
async function restore(): Promise<boolean> {
|
||
const urlRecordId = route.params.recordId as string
|
||
if (urlRecordId) {
|
||
const nextRecordId = Number(urlRecordId)
|
||
if (!Number.isFinite(nextRecordId)) return false
|
||
if (recordId.value !== nextRecordId) {
|
||
recordId.value = nextRecordId
|
||
draft.value = createEmptyDraft()
|
||
}
|
||
}
|
||
|
||
// 先尝试 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 record = res?.data ?? res
|
||
if (record?.id) {
|
||
// 服务端数据覆盖执行状态
|
||
if (record.productId) {
|
||
draft.value.productId = record.productId
|
||
}
|
||
if (record.productSource?.type && record.productSource?.id) {
|
||
draft.value.productSourceType = record.productSource.type
|
||
draft.value.productSourceId = String(record.productSource.id)
|
||
} else if (record.productId) {
|
||
draft.value.productSourceType = 'library_product'
|
||
draft.value.productSourceId = record.productId
|
||
}
|
||
if (record.productSnapshot) {
|
||
draft.value.productName = record.productSnapshot.productName || draft.value.productName
|
||
draft.value.productCompany = record.productSnapshot.companyName || draft.value.productCompany
|
||
draft.value.productSourceConfirmedAt = record.productSnapshot.confirmedAt || ''
|
||
}
|
||
if (record.caseUploadId) {
|
||
draft.value.caseUploadId = record.caseUploadId
|
||
}
|
||
if (record.templateId) {
|
||
draft.value.templateId = record.templateId
|
||
}
|
||
if (record.exportSize) {
|
||
draft.value.exportSize = record.exportSize
|
||
}
|
||
if (record.outputMode) {
|
||
draft.value.outputMode = record.outputMode
|
||
}
|
||
if (record.copyContent) {
|
||
draft.value.copyContent = record.copyContent
|
||
}
|
||
if (record.document) {
|
||
if (record.document.schemaVersion === 2) {
|
||
draft.value.renderDocument = record.document as PosterRenderDocument
|
||
}
|
||
draft.value.copyContent = record.document.copy || draft.value.copyContent
|
||
draft.value.parsedFields = record.document.facts || draft.value.parsedFields
|
||
draft.value.templateId = record.document.templateId || draft.value.templateId
|
||
draft.value.outputMode = record.document.outputMode || draft.value.outputMode
|
||
draft.value.formatId = record.document.formatId || draft.value.formatId
|
||
draft.value.templateColorScheme = record.document.theme || record.document.style || draft.value.templateColorScheme
|
||
draft.value.sections = record.document.sections || draft.value.sections
|
||
const rawRequestedHeight = record.document.requestedOutput?.height
|
||
const requestedHeight = Number(rawRequestedHeight)
|
||
if (
|
||
record.document.outputMode === 'long'
|
||
&& rawRequestedHeight != null
|
||
&& Number.isInteger(requestedHeight)
|
||
&& requestedHeight >= 1920
|
||
&& requestedHeight <= 30000
|
||
) {
|
||
draft.value.longHeightMode = 'custom'
|
||
draft.value.customLongHeight = requestedHeight
|
||
}
|
||
}
|
||
if (record.aiRawContent) {
|
||
draft.value.aiRawContent = record.aiRawContent
|
||
}
|
||
if (record.extraData?.outputMode) {
|
||
draft.value.outputMode = record.extraData.outputMode
|
||
}
|
||
if (record.extraData?.formatId) {
|
||
draft.value.formatId = record.extraData.formatId
|
||
}
|
||
if (record.extraData?.customHeight) {
|
||
draft.value.longHeightMode = 'custom'
|
||
draft.value.customLongHeight = Number(record.extraData.customHeight)
|
||
}
|
||
normalizeFormat(draft.value)
|
||
if (record.compliance) {
|
||
draft.value.complianceStatus = record.compliance.status || 'unchecked'
|
||
draft.value.complianceIssues = record.compliance.issues || []
|
||
draft.value.complianceRevision = record.complianceRevision || record.compliance.revision || ''
|
||
}
|
||
draft.value.taskRecordId = record.id
|
||
// 执行状态始终从服务端读取
|
||
draft.value.taskStatus = record.taskStatus === 'pending'
|
||
? 'queued'
|
||
: (record.taskStatus || 'idle')
|
||
draft.value.taskProgress = record.taskProgress ?? 0
|
||
draft.value.taskError = record.taskError || null
|
||
// posterUrl 不从 localStorage 恢复(blob: 地址已失效)
|
||
draft.value.posterUrl = null
|
||
// 恢复参考图列表(从 extraData)
|
||
if (record.extraData?.referenceImages) {
|
||
draft.value.referenceImages = record.extraData.referenceImages
|
||
}
|
||
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) {
|
||
const previousKey = STORAGE_KEY.value
|
||
if (!recordId.value && previousKey) {
|
||
try {
|
||
localStorage.removeItem(previousKey)
|
||
} catch { /* ignore */ }
|
||
}
|
||
recordId.value = id
|
||
persistDraft()
|
||
syncUrl()
|
||
}
|
||
|
||
// ── 重置 ─────────────────────────────────
|
||
function reset() {
|
||
// 清理 localStorage
|
||
try {
|
||
const key = STORAGE_KEY.value
|
||
if (key) localStorage.removeItem(key)
|
||
} 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.productSourceId,
|
||
blocking: true,
|
||
},
|
||
{
|
||
key: 'upload',
|
||
label: '上传计划书',
|
||
done: ['parsed', 'partial'].includes(d.parseStatus) && d.dataConfirmed,
|
||
blocking: true,
|
||
},
|
||
{
|
||
key: 'template',
|
||
label: '选择模板',
|
||
done: !!d.templateId,
|
||
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'
|
||
| 'select-template'
|
||
| 'generate-copy'
|
||
| 'confirm-compliance'
|
||
| 'generate-poster'
|
||
| 'download-poster'
|
||
| 'generating'
|
||
|
||
const actionState = computed<ActionState>(() => {
|
||
const d = draft.value
|
||
if (['submitting', 'queued', 'generating', 'background_ready', 'asset_loading'].includes(d.taskStatus)) {
|
||
return 'generating'
|
||
}
|
||
if (d.taskStatus === 'done' && d.posterUrl) {
|
||
return 'download-poster'
|
||
}
|
||
if (!d.productSourceId) return 'select-product'
|
||
if (!['parsed', 'partial'].includes(d.parseStatus)) return 'upload-data'
|
||
if (!d.dataConfirmed) return 'confirm-data'
|
||
if (!d.templateId) return 'select-template'
|
||
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': '确认数据',
|
||
'select-template': '选择模板',
|
||
'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,
|
||
}
|
||
}
|