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