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

617 lines
17 KiB
Vue
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.

<script lang="ts">
export default { name: 'CreatingView' }
</script>
<template>
<div class="creating-page">
<!-- 进度环 -->
<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 {
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 { clearExtractDraft } from '@/utils/aicreate/extractDraft'
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 正在为你构思故事',
'画笔正在绘制插画',
'故事世界正在成形',
'角色们正在准备登场',
'色彩正在调和',
]
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') || ''
}
}
/** 创作已推进到预览/配音等后续步骤时清除 extract 本地草稿 */
function replaceWhenCreationAdvances(route: ReturnType<typeof getRouteByStatus>) {
if (!route) return
if (route.name !== 'PublicCreateCreating') {
clearExtractDraft()
}
setTimeout(() => router.replace(route), 800)
}
// ─── 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 work = await getWorkDetail(workId)
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) replaceWhenCreationAdvances(route)
} 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 = () => {
saveWorkId('')
submitted = false
startCreation()
}
const leaveToWorks = () => {
// 关闭前端监听但后端任务继续store.workId 仍在 localStorage下次进入 CreatingView 会恢复
closeWebSocket()
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
router.push('/p/works?tab=draft')
}
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 (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;
}
/* ---------- 进度环 ---------- */
.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>