library-picturebook-activity/frontend/src/views/public/create/views/CreatingView.vue

669 lines
18 KiB
Vue
Raw Normal View History

<script lang="ts">
export default { name: 'CreatingView' }
</script>
<template>
<div class="creating-page">
<!-- 开发模式状态切换 -->
<div v-if="isDev" class="dev-bar">
<experiment-outlined />
<span class="dev-label">Mock</span>
<button class="dev-btn" @click="enterMockProgress">进度</button>
<button class="dev-btn" @click="enterMockError">错误</button>
<button class="dev-btn" @click="goMockPreview">跳到预览</button>
</div>
<!-- 进度环 -->
<div class="ring-wrap">
<svg width="180" height="180" class="ring-svg">
<defs>
<linearGradient id="ringGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#6366f1" />
<stop offset="50%" stop-color="#8b5cf6" />
<stop offset="100%" stop-color="#ec4899" />
</linearGradient>
</defs>
<circle cx="90" cy="90" r="80" fill="none" stroke="rgba(99, 102, 241, 0.12)" stroke-width="8" />
<circle
cx="90"
cy="90"
r="80"
fill="none"
stroke="url(#ringGrad)"
stroke-width="8"
:stroke-dasharray="502"
:stroke-dashoffset="502 - (502 * progress / 100)"
stroke-linecap="round"
class="ring-fill"
/>
</svg>
<div class="ring-center">
<div class="ring-pct">{{ progress }}%</div>
<div class="ring-label">创作进度</div>
</div>
</div>
<!-- 状态文字 -->
<div class="stage-text">{{ stage }}</div>
<!-- 轮转创作 tips -->
<div v-if="!error" class="rotating-tips">
<Transition name="tip-fade" mode="out-in">
<div class="rotating-tip" :key="currentTipIdx">{{ creatingTips[currentTipIdx] }}</div>
</Transition>
</div>
<!-- 网络波动提示 -->
<div v-if="networkWarn && !error" class="network-warn">
<wifi-outlined />
<span>网络不太稳定正在尝试重新连接{{ dots }}</span>
</div>
<!-- 错误重试 -->
<div v-if="error" class="error-box">
<frown-outlined class="error-icon" />
<div class="error-text">{{ error }}</div>
<div class="error-actions">
<button v-if="store.workId" class="btn-primary error-btn" @click="resumePolling">
恢复查询进度
</button>
<button v-if="!isQuotaError" class="btn-primary error-btn" :class="{ 'btn-outline': !!store.workId }" @click="retry">
重新创作
</button>
</div>
</div>
<!-- 任务式说明 + 离开入口 -->
<div v-if="!error" class="task-hint">
<div class="task-hint-row">
<cloud-server-outlined class="task-icon" />
<span>AI 正在后台为你创作绘本可以随时离开</span>
</div>
<button class="leave-btn" @click="leaveToWorks">
<inbox-outlined />
<span>去逛逛看看其他作品</span>
</button>
<div class="task-hint-sub">完成后会自动出现在作品库 · 草稿</div>
</div>
<div v-else class="task-hint">
<button class="leave-btn" @click="leaveToWorks">
<inbox-outlined />
<span>去逛逛看看其他作品</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { Client } from '@stomp/stompjs'
import {
ExperimentOutlined,
FrownOutlined,
WifiOutlined,
CloudServerOutlined,
InboxOutlined,
} from '@ant-design/icons-vue'
import { useAicreateStore } from '@/stores/aicreate'
import { createStory, getWorkDetail } from '@/api/aicreate'
import { STATUS, getRouteByStatus } from '@/utils/aicreate/status'
import config from '@/utils/aicreate/config'
const router = useRouter()
const store = useAicreateStore()
const progress = ref(0)
const stage = ref('准备中…')
const dots = ref('')
const error = ref('')
/** 额度/次数限制类错误,不应显示"重新创作"按钮 */
const isQuotaError = computed(() => {
const msg = error.value
return msg.includes('次数已达上限') || msg.includes('额度不足')
})
const networkWarn = ref(false)
const currentTipIdx = ref(0)
const creatingTips = [
'AI 正在为你构思故事',
'画笔正在绘制插画',
'故事世界正在成形',
'角色们正在准备登场',
'色彩正在调和',
]
const isDev = import.meta.env.DEV
let pollTimer: ReturnType<typeof setInterval> | null = null
let dotTimer: ReturnType<typeof setInterval> | null = null
let tipTimer: ReturnType<typeof setInterval> | null = null
let stompClient: any = null
let wsDegraded = false // WebSocket 已降级到轮询,防止重复降级
let submitted = false
let consecutiveErrors = 0
const MAX_SILENT_ERRORS = 3
const MAX_POLL_ERRORS = 15
// 错误消息脱敏
function sanitizeError(msg: string | undefined): string {
if (!msg) return '创作遇到问题,请重新尝试'
if (msg.includes('调用次数已达上限') || msg.includes('30004')) return '今日创作次数已达上限,请明天再试'
if (msg.includes('额度') || msg.includes('30003')) return '创作额度不足,请联系管理员'
if (msg.includes('400') || msg.includes('status code')) return '创作请求异常,请返回重新操作'
if (msg.includes('火山引擎') || msg.includes('VolcEngine')) return 'AI 服务暂时繁忙,请稍后重试'
if (msg.includes('额度')) return msg
if (msg.includes('重复') || msg.includes('DUPLICATE')) return '你有正在创作的作品,请等待完成'
if (msg.includes('timeout') || msg.includes('超时')) return '创作超时,请重新尝试'
if (msg.includes('OSS') || msg.includes('MiniMax')) return '服务暂时不可用,请稍后重试'
if (msg.length > 50) return '创作遇到问题,请重新尝试'
return msg
}
// 后端进度消息 → 用户友好阶段文案(不带任何 emoji 前缀)
function friendlyStage(pct: number, msg: string): string {
if (!msg) return '创作中…'
if (msg.includes('创作完成')) return '绘本创作完成'
if (msg.includes('绘图完成') || msg.includes('绘制完成')) return '插画绘制完成'
if (msg.includes('第') && msg.includes('组')) return '正在绘制插画…'
if (msg.includes('绘图') || msg.includes('绘制') || msg.includes('插画')) return '正在绘制插画…'
if (msg.includes('补生成')) return '正在绘制插画…'
if (msg.includes('语音合成') || msg.includes('配音')) return '正在合成语音…'
if (msg.includes('故事') && msg.includes('完成')) return '故事编写完成,开始绘图…'
if (msg.includes('故事') || msg.includes('创作故事')) return '正在编写故事…'
if (msg.includes('适配') || msg.includes('角色')) return '正在准备绘图…'
if (msg.includes('重试')) return '遇到小问题,正在重新创作…'
if (msg.includes('失败')) return '处理中,请稍候…'
if (pct < 20) return '正在提交创作…'
if (pct < 50) return '正在编写故事…'
if (pct < 80) return '正在绘制插画…'
if (pct < 100) return '即将完成…'
return '绘本创作完成'
}
// 持久化 workId 到 localStorage页面刷新后可恢复轮询
function saveWorkId(id: string) {
store.workId = id
if (id) {
localStorage.setItem('le_workId', id)
} else {
localStorage.removeItem('le_workId')
}
}
function restoreWorkId() {
if (!store.workId) {
store.workId = localStorage.getItem('le_workId') || ''
}
}
// ─── WebSocket 实时推送 (首次进入使用) ───
const startWebSocket = (workId: string) => {
wsDegraded = false
const wsBase = config.wsBaseUrl
? config.wsBaseUrl
: `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`
const wsUrl = `${wsBase}/ws/websocket?orgId=${encodeURIComponent(store.orgId)}`
stompClient = new Client({
brokerURL: wsUrl,
reconnectDelay: 0,
onConnect: () => {
stompClient.subscribe(`/topic/progress/${workId}`, (msg: any) => {
try {
const data = JSON.parse(msg.body)
if (data.progress != null && data.progress > progress.value) progress.value = data.progress
if (data.message) stage.value = friendlyStage(data.progress, data.message)
if (data.progress >= 100) {
progress.value = 100
stage.value = '绘本创作完成'
closeWebSocket()
saveWorkId('')
const route = getRouteByStatus(STATUS.COMPLETED, workId)
if (route) setTimeout(() => router.replace(route), 800)
} else if (data.progress < 0) {
closeWebSocket()
saveWorkId('')
error.value = sanitizeError(data.message)
}
} catch { /* ignore parse error */ }
})
},
onStompError: () => {
if (wsDegraded) return
wsDegraded = true
closeWebSocket()
startPolling(workId)
},
onWebSocketError: () => {
if (wsDegraded) return
wsDegraded = true
closeWebSocket()
startPolling(workId)
},
onWebSocketClose: () => {
if (wsDegraded) return
if (store.workId) {
wsDegraded = true
closeWebSocket()
startPolling(workId)
}
}
})
stompClient.activate()
}
const closeWebSocket = () => {
if (stompClient) {
try { stompClient.deactivate() } catch { /* ignore */ }
stompClient = null
}
}
// ─── B2 轮询 ───
const startPolling = (workId: string) => {
if (pollTimer) clearInterval(pollTimer)
consecutiveErrors = 0
networkWarn.value = false
pollTimer = setInterval(async () => {
try {
const detail = await getWorkDetail(workId)
const work = detail.data
if (!work) return
if (consecutiveErrors > 0 || networkWarn.value) {
consecutiveErrors = 0
networkWarn.value = false
}
if (work.progress != null && work.progress > progress.value) progress.value = work.progress
if (work.progressMessage) stage.value = friendlyStage(progress.value, work.progressMessage)
if (work.status >= STATUS.COMPLETED) {
progress.value = 100
stage.value = '绘本创作完成'
clearInterval(pollTimer!)
pollTimer = null
saveWorkId('')
const route = getRouteByStatus(work.status, workId)
if (route) setTimeout(() => router.replace(route), 800)
} else if (work.status === STATUS.FAILED) {
clearInterval(pollTimer!)
pollTimer = null
saveWorkId('')
error.value = sanitizeError(work.failReason)
}
} catch {
consecutiveErrors++
if (consecutiveErrors > MAX_POLL_ERRORS) {
clearInterval(pollTimer!)
pollTimer = null
networkWarn.value = false
error.value = '网络连接异常,创作仍在后台进行中'
} else if (consecutiveErrors > MAX_SILENT_ERRORS) {
networkWarn.value = true
}
}
}, 8000)
}
const startCreation = async () => {
if (submitted) return
submitted = true
error.value = ''
progress.value = 5
stage.value = '正在提交创作请求…'
try {
const res = await createStory({
imageUrl: store.imageUrl,
storyHint: store.storyData?.storyHint || '',
style: store.selectedStyle,
title: store.storyData?.title || '',
author: store.storyData?.author,
heroCharId: store.selectedCharacter?.charId,
extractId: store.extractId,
})
const workId = res?.workId
if (!workId) {
error.value = res.msg || '创作提交失败'
submitted = false
return
}
saveWorkId(workId)
progress.value = 10
stage.value = '故事构思中…'
startWebSocket(workId)
} catch (e: any) {
if (store.workId) {
progress.value = 10
stage.value = '创作已提交到后台…'
startPolling(store.workId)
} else {
error.value = sanitizeError(e.message)
submitted = false
}
}
}
const resumePolling = () => {
error.value = ''
networkWarn.value = false
progress.value = 10
stage.value = '正在查询创作进度…'
startPolling(store.workId)
}
const retry = () => {
if (isDev && !store.imageUrl) {
enterMockProgress()
return
}
saveWorkId('')
submitted = false
startCreation()
}
const leaveToWorks = () => {
// 关闭前端监听但后端任务继续store.workId 仍在 localStorage下次进入 CreatingView 会恢复
closeWebSocket()
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
router.push('/p/works?tab=draft')
}
// ─── 开发模式:模拟状态 ───
const enterMockProgress = () => {
closeWebSocket()
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
submitted = true
error.value = ''
networkWarn.value = false
progress.value = 35
stage.value = '正在编写故事…'
}
const enterMockError = () => {
closeWebSocket()
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
submitted = false
error.value = '创作请求异常,请返回重新操作'
}
const goMockPreview = () => {
closeWebSocket()
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
store.fillMockWorkDetail()
router.push(`/p/create/preview/${store.workId}`)
}
onMounted(() => {
dotTimer = setInterval(() => {
dots.value = dots.value.length >= 3 ? '' : dots.value + '.'
}, 500)
tipTimer = setInterval(() => {
currentTipIdx.value = (currentTipIdx.value + 1) % creatingTips.length
}, 3500)
// 恢复 workId
const urlWorkId = new URLSearchParams(window.location.search).get('workId')
if (urlWorkId) {
saveWorkId(urlWorkId)
} else {
restoreWorkId()
}
// 开发模式兜底:缺关键数据时直接进入模拟态,避免真实接口失败
if (isDev && !store.workId && (!store.imageUrl || !store.storyData)) {
enterMockProgress()
return
}
if (store.workId) {
submitted = true
progress.value = 10
stage.value = '正在查询创作进度…'
startPolling(store.workId)
} else {
startCreation()
}
})
onUnmounted(() => {
closeWebSocket()
if (pollTimer) clearInterval(pollTimer)
if (dotTimer) clearInterval(dotTimer)
if (tipTimer) clearInterval(tipTimer)
})
</script>
<style lang="scss" scoped>
.creating-page {
min-height: 100vh;
background: var(--ai-bg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 20px 32px;
position: relative;
}
/* ---------- 开发模式切换器 ---------- */
.dev-bar {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(99, 102, 241, 0.04);
border: 1px dashed rgba(99, 102, 241, 0.3);
border-radius: 12px;
font-size: 11px;
color: var(--ai-text-sub);
z-index: 5;
:deep(.anticon) {
font-size: 12px;
color: var(--ai-primary);
}
}
.dev-label { font-weight: 600; }
.dev-btn {
padding: 4px 10px;
border-radius: 10px;
background: #fff;
color: var(--ai-text-sub);
font-size: 11px;
font-weight: 600;
border: 1px solid rgba(99, 102, 241, 0.2);
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--ai-primary);
color: var(--ai-primary);
}
}
/* ---------- 进度环 ---------- */
.ring-wrap {
position: relative;
width: 180px;
height: 180px;
margin-bottom: 28px;
}
.ring-svg { transform: rotate(-90deg); }
.ring-fill { transition: stroke-dashoffset 0.8s ease; }
.ring-center {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.ring-pct {
font-size: 38px;
font-weight: 900;
background: var(--ai-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -1px;
}
.ring-label {
font-size: 12px;
color: var(--ai-text-sub);
margin-top: 2px;
letter-spacing: 1px;
}
/* ---------- 阶段文字 ---------- */
.stage-text {
font-size: 17px;
font-weight: 700;
text-align: center;
color: var(--ai-text);
}
/* ---------- 轮转 tips ---------- */
.rotating-tips {
margin-top: 12px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.rotating-tip {
font-size: 13px;
color: var(--ai-text-sub);
font-weight: 500;
text-align: center;
letter-spacing: 0.3px;
}
.tip-fade-enter-active,
.tip-fade-leave-active {
transition: opacity 0.5s ease, transform 0.5s ease;
}
.tip-fade-enter-from { opacity: 0; transform: translateY(8px); }
.tip-fade-leave-to { opacity: 0; transform: translateY(-8px); }
/* ---------- 网络警告 ---------- */
.network-warn {
margin-top: 14px;
padding: 6px 14px;
border-radius: 12px;
background: rgba(245, 158, 11, 0.08);
color: #d97706;
font-size: 12px;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
:deep(.anticon) { font-size: 13px; }
}
/* ---------- 错误状态 ---------- */
.error-box {
margin-top: 24px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.error-icon {
font-size: 44px;
color: var(--ai-text-sub);
margin-bottom: 12px;
}
.error-text {
color: #ef4444;
font-size: 14px;
font-weight: 600;
line-height: 1.6;
max-width: 280px;
}
.error-actions {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 18px;
width: 100%;
max-width: 240px;
}
.error-btn {
font-size: 14px !important;
padding: 12px 0 !important;
border-radius: 24px !important;
}
.error-btn.btn-outline {
background: transparent !important;
color: var(--ai-primary) !important;
border: 1.5px solid rgba(99, 102, 241, 0.3) !important;
box-shadow: none !important;
}
/* ---------- 任务式说明 + 离开按钮 ---------- */
.task-hint {
margin-top: 36px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
max-width: 320px;
}
.task-hint-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--ai-text);
font-weight: 500;
text-align: center;
}
.task-icon {
font-size: 15px;
color: var(--ai-primary);
flex-shrink: 0;
}
.task-hint-sub {
font-size: 11px;
color: var(--ai-text-sub);
text-align: center;
}
.leave-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 11px 22px;
border-radius: 22px;
background: #fff;
color: var(--ai-primary);
font-size: 14px;
font-weight: 600;
border: 1.5px solid rgba(99, 102, 241, 0.3);
box-shadow: 0 2px 10px rgba(99, 102, 241, 0.06);
cursor: pointer;
transition: all 0.2s;
:deep(.anticon) { font-size: 15px; }
&:hover {
border-color: var(--ai-primary);
background: rgba(99, 102, 241, 0.04);
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.12);
}
&:active { transform: scale(0.98); }
}
</style>