baodan/frontend/src/composables/usePosterWorkspace.ts
2026-08-01 21:18:34 +08:00

552 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 海报工作区统一状态管理。
*
* 核心职责:
* 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,
// 参考图不持久化到 localStorageblob 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,
}
}