
阅读大约需 12 分钟
loading 变量让小禾发了一场高烧
小禾以为自己已经掌握了状态管理的精髓。
毕竟,嵌套数据、持久化存储这些难题都被他一一拿下了。
直到产品经理说:“加个生成图片的按钮吧。”
小禾想:这还不简单?三分钟搞定:
// 看起来很简单对吧?
const loading = ref(false)
async function generateImage() {
loading.value = true
await api.generate()
loading.value = false
}
<template>
<button @click="generateImage" :disabled="loading">
{{ loading ? '生成中...' : '生成图片' }}
</button>
</template>
小禾心满意足地提交了代码。
然后,bug 们排起了长队。
Bug 1:用户手速太快
测试妹子报了第一个 bug:“我点了两下,出来了两张图。”
小禾一看日志:
[14:23:01] 开始生成图片
[14:23:01] 开始生成图片 ← 用户又点了一下
[14:23:05] 图片生成完成
[14:23:06] 图片生成完成 ← 这是第二张
问题:按钮虽然在 loading 时 disabled 了,但有时候用户的点击比状态更新更快。
小禾加了个防抖:
const loading = ref(false)
async function generateImage() {
// 防止重复点击
if (loading.value) {
console.warn('已有任务在进行中')
return
}
loading.value = true
try {
await api.generate()
} finally {
loading.value = false
}
}
好了,第一个 bug 修完。
Bug 2:生成失败了,但按钮还是灰的
测试妹子又来了:“我断网试了下,按钮一直是灰的,点不了。”
小禾一看代码:
async function generateImage() {
loading.value = true
await api.generate() // 报错了
loading.value = false // 这行没执行到!
}
经典的遗漏 try-finally。
async function generateImage() {
loading.value = true
try {
await api.generate()
} catch (error) {
console.error('生成失败:', error)
// 这里是不是要提示用户?
} finally {
loading.value = false // 无论成功失败都执行
}
}
等等,失败了应该告诉用户吧?
一个 loading 变量不够用了。
Bug 3:成功了还是失败了?用户分不清
产品经理说:“生成失败后,用户不知道发生了什么,以为在加载。”
小禾意识到,true/false 不足以描述所有状态:
用户看到的:
- 按钮可点击 → 可以开始生成
- 按钮灰色 → 正在生成
- 按钮可点击 → 生成完了(成功?失败?)
他需要更多的状态:
type GenerationStatus =
| 'idle' // 空闲,可以开始
| 'generating' // 生成中
| 'generated' // 已生成(成功)
| 'error' // 出错了
const status = ref<GenerationStatus>('idle')
const errorMessage = ref<string>('')
现在 UI 可以根据状态显示不同内容:
<template>
<div>
<button @click="generate" :disabled="status === 'generating'">
<template v-if="status === 'generating'">
<LoadingSpinner /> 生成中...
</template>
<template v-else-if="status === 'error'">
重新生成
</template>
<template v-else-if="status === 'generated'">
重新生成
</template>
<template v-else>
生成图片
</template>
</button>
<div v-if="status === 'error'" class="error-tip">
{{ errorMessage }}
</div>
</div>
</template>
终于,用户能分清发生了什么。
Bug 4:同时生成图片和配音,状态乱了
功能越来越多,现在不只是生成图片了:
- 生成图片
- 生成配音
- 生成动画
- 合成视频
产品经理说:“用户可以同时生成图片和配音,不用等。”
小禾的一个 loading 变量彻底崩溃了:
// ❌ 这样写,两个操作会互相干扰
const loading = ref(false)
async function generateImage() {
loading.value = true
await api.generateImage()
loading.value = false // 配音还在生成呢!
}
async function generateAudio() {
loading.value = true
await api.generateAudio()
loading.value = false // 图片还在生成呢!
}
解决方案:每种操作有自己的状态。
// stores/editor.ts
export const useEditorStore = defineStore('editor', () => {
// 不同类型的生成操作,各自独立
const generatingImage = ref(false)
const generatingAnimation = ref(false)
const generatingAudio = ref(false)
const generatingVideo = ref(false)
// 是否有任何生成在进行(用于全局 loading 遮罩)
const isGenerating = computed(() =>
generatingImage.value ||
generatingAnimation.value ||
generatingAudio.value ||
generatingVideo.value
)
return {
generatingImage,
generatingAnimation,
generatingAudio,
generatingVideo,
isGenerating
}
})
现在可以愉快地并行生成了:
async function generateImageForShot() {
generatingImage.value = true
try {
await api.generateImage()
} finally {
generatingImage.value = false
}
}
async function generateAudioForShot() {
generatingAudio.value = true
try {
await api.generateAudio()
} finally {
generatingAudio.value = false
}
}
// 同时开始,互不干扰
await Promise.all([
generateImageForShot(),
generateAudioForShot()
])
Bug 5:每个分镜都有自己的状态
问题又升级了。
产品经理说:“每个分镜都能单独生成图片,而且要显示各自的进度。”
小禾画了张图:
场景 1
├── 分镜 1:已生成
├── 分镜 2:生成中... ← 显示 loading
├── 分镜 3:生成失败 ← 显示错误
└── 分镜 4:未生成
场景 2
├── 分镜 1:生成中... ← 也在生成
└── 分镜 2:未生成
一个全局的 generatingImage 不够用了。
每个分镜都需要自己的状态。
interface Shot {
id: string
prompt: string
imageUrl?: string
status: GenerationStatus // 每个分镜有自己的状态
errorMessage?: string
}
现在修改分镜时同时更新状态:
// stores/editor.ts
async function generateShotImage() {
const shot = storyStore.currentShot
if (!shot) return
// 检查:这个分镜已经在生成中?
if (shot.status === 'generating') {
console.warn('该分镜已在生成中')
return
}
try {
// 1. 设置为生成中
storyStore.updateShot(shot.sceneId, shot.id, {
status: 'generating',
errorMessage: undefined
})
// 2. 调用 API
const result = await generateApi.generateShotImage({
prompt: shot.prompt
})
// 3. 更新成功结果
storyStore.updateShot(shot.sceneId, shot.id, {
status: 'generated',
imageUrl: result.data.imageUrl
})
return result.data
} catch (error: any) {
// 4. 处理错误
console.error('生成失败:', error)
storyStore.updateShot(shot.sceneId, shot.id, {
status: 'error',
errorMessage: error.message || '生成失败,请重试'
})
throw error
}
}
UI 层根据每个分镜的状态渲染:
<template>
<div class="shot-card" :class="statusClass">
<!-- 图片区域 -->
<div class="shot-image">
<img v-if="shot.imageUrl" :src="shot.imageUrl" />
<!-- 生成中遮罩 -->
<div v-if="shot.status === 'generating'" class="overlay">
<LoadingSpinner />
<span>AI 正在创作中...</span>
</div>
<!-- 错误提示 -->
<div v-if="shot.status === 'error'" class="overlay error">
<span>{{ shot.errorMessage }}</span>
<button @click="retry">重试</button>
</div>
</div>
<!-- 底部操作栏 -->
<div class="shot-actions">
<button
@click="generate"
:disabled="shot.status === 'generating'"
>
{{ buttonText }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
const buttonText = computed(() => {
switch (shot.status) {
case 'generating': return '生成中...'
case 'error': return '重新生成'
case 'generated': return '重新生成'
default: return '生成图片'
}
})
</script>
进阶:批量生成与并发控制
产品经理的需求没有尽头:“一键生成整个场景的所有分镜。”
小禾想:简单,Promise.all 搞定:
// ❌ 危险!10 个分镜同时请求,服务器可能扛不住
async function generateAllShots(sceneId: string) {
const scene = storyStore.getSceneById(sceneId)
await Promise.all(
scene.shots.map(shot => generateShotImage(shot))
)
}
后端同事发来消息:“你是不是在搞 DDoS 攻击?”
啊!原来是并发太高,GPU 被打满了。
解决方案:并发控制。
async function generateAllShots(sceneId: string) {
const scene = storyStore.getSceneById(sceneId)
if (!scene) return
// 只处理需要生成的分镜
const pendingShots = scene.shots.filter(
s => s.status === 'idle' || s.status === 'error'
)
// 并发控制:最多同时 3 个
const concurrency = 3
const results = []
for (let i = 0; i < pendingShots.length; i += concurrency) {
const batch = pendingShots.slice(i, i + concurrency)
// 用 allSettled 而不是 all,这样一个失败不会影响其他
const batchResults = await Promise.allSettled(
batch.map(shot => generateSingleShot(sceneId, shot.id))
)
results.push(...batchResults)
}
// 统计结果
const succeeded = results.filter(r => r.status === 'fulfilled').length
const failed = results.filter(r => r.status === 'rejected').length
if (failed > 0) {
ElMessage.warning(`生成完成: ${succeeded} 成功, ${failed} 失败`)
} else {
ElMessage.success(`全部 ${succeeded} 个分镜生成完成`)
}
}
这样既保证了效率,又不会把服务器干崩。
进阶:取消生成
测试妹子又来了:“用户点了生成,但是 prompt 写错了,想取消怎么办?”
小禾意识到,长时间运行的操作需要取消机制。
// 保存 AbortController 的引用
const abortController = ref<AbortController | null>(null)
async function generateShotImage() {
const shot = storyStore.currentShot
if (!shot) return
// 创建取消控制器
abortController.value = new AbortController()
try {
storyStore.updateShot(shot.sceneId, shot.id, { status: 'generating' })
const result = await generateApi.generateShotImage({
prompt: shot.prompt,
signal: abortController.value.signal // 传给 API
})
storyStore.updateShot(shot.sceneId, shot.id, {
status: 'generated',
imageUrl: result.data.imageUrl
})
} catch (error: any) {
// 检查是否是主动取消
if (error.name === 'AbortError') {
console.log('生成已取消')
storyStore.updateShot(shot.sceneId, shot.id, {
status: 'idle' // 回到空闲状态
})
return
}
// 其他错误正常处理
storyStore.updateShot(shot.sceneId, shot.id, {
status: 'error',
errorMessage: error.message
})
} finally {
abortController.value = null
}
}
// 取消函数
function cancelGeneration() {
abortController.value?.abort()
}
UI 上加个取消按钮:
<template>
<div v-if="shot.status === 'generating'" class="generating-overlay">
<LoadingSpinner />
<span>生成中...</span>
<button @click="cancel" class="cancel-btn">取消</button>
</div>
</template>
终极挑战:多阶段生成流水线
产品经理放了个大招:“一键生成完整视频。”
小禾看了看流程:
生成完整视频:
1. 生成所有分镜图片(可能 10+ 个)
2. 为每个分镜生成动画(每个要 1-2 分钟)
3. 生成配音
4. 合成最终视频
每个阶段都可能失败,都需要显示进度。
这不再是一个简单的 loading 状态,而是一个状态机。
interface VideoGenerationState {
stage: 'idle' | 'images' | 'animations' | 'audio' | 'compositing' | 'done' | 'error'
progress: {
images: { total: number; completed: number }
animations: { total: number; completed: number }
audio: { status: GenerationStatus }
video: { status: GenerationStatus }
}
error?: string
}
export const useVideoGenerationStore = defineStore('videoGeneration', () => {
const state = ref<VideoGenerationState>({
stage: 'idle',
progress: {
images: { total: 0, completed: 0 },
animations: { total: 0, completed: 0 },
audio: { status: 'idle' },
video: { status: 'idle' }
}
})
// 当前阶段的进度百分比
const stageProgress = computed(() => {
const { stage, progress } = state.value
switch (stage) {
case 'images':
return progress.images.total > 0
? (progress.images.completed / progress.images.total) * 100
: 0
case 'animations':
return progress.animations.total > 0
? (progress.animations.completed / progress.animations.total) * 100
: 0
case 'audio':
case 'compositing':
return 50 // 这两个阶段没有细分进度
case 'done':
return 100
default:
return 0
}
})
// 总体进度(4 个阶段)
const overallProgress = computed(() => {
const stageWeights = { images: 30, animations: 40, audio: 15, compositing: 15 }
const { stage, progress } = state.value
let completed = 0
// 已完成的阶段
if (['animations', 'audio', 'compositing', 'done'].includes(stage)) {
completed += stageWeights.images
}
if (['audio', 'compositing', 'done'].includes(stage)) {
completed += stageWeights.animations
}
if (['compositing', 'done'].includes(stage)) {
completed += stageWeights.audio
}
if (stage === 'done') {
completed += stageWeights.compositing
}
// 当前阶段的进度
if (stage === 'images') {
completed += (progress.images.completed / progress.images.total) * stageWeights.images
} else if (stage === 'animations') {
completed += (progress.animations.completed / progress.animations.total) * stageWeights.animations
}
return Math.round(completed)
})
async function generateFullVideo(sceneId: string) {
const scene = storyStore.getSceneById(sceneId)
if (!scene) return
try {
// 阶段 1:生成所有图片
state.value.stage = 'images'
state.value.progress.images = { total: scene.shots.length, completed: 0 }
for (const shot of scene.shots) {
if (shot.status !== 'generated') {
await generateShotImage(shot)
}
state.value.progress.images.completed++
}
// 阶段 2:生成所有动画
state.value.stage = 'animations'
state.value.progress.animations = { total: scene.shots.length, completed: 0 }
for (const shot of scene.shots) {
await generateShotAnimation(shot)
state.value.progress.animations.completed++
}
// 阶段 3:生成配音
state.value.stage = 'audio'
state.value.progress.audio.status = 'generating'
await generateAudio(sceneId)
state.value.progress.audio.status = 'generated'
// 阶段 4:合成视频
state.value.stage = 'compositing'
state.value.progress.video.status = 'generating'
await compositeVideo(sceneId)
state.value.progress.video.status = 'generated'
// 完成
state.value.stage = 'done'
ElMessage.success('视频生成完成!')
} catch (error: any) {
state.value.stage = 'error'
state.value.error = error.message
ElMessage.error(`生成失败: ${error.message}`)
}
}
function reset() {
state.value = {
stage: 'idle',
progress: {
images: { total: 0, completed: 0 },
animations: { total: 0, completed: 0 },
audio: { status: 'idle' },
video: { status: 'idle' }
}
}
}
return {
state,
stageProgress,
overallProgress,
generateFullVideo,
reset
}
})
UI 展示多阶段进度:
<template>
<div class="video-generation-modal">
<h3>生成完整视频</h3>
<!-- 阶段指示器 -->
<div class="stage-indicator">
<div
v-for="s in stages"
:key="s.key"
:class="['stage', { active: state.stage === s.key, done: isStageCompleted(s.key) }]"
>
<span class="stage-icon">{{ getStageIcon(s.key) }}</span>
<span class="stage-name">{{ s.label }}</span>
</div>
</div>
<!-- 当前阶段详情 -->
<div class="current-stage">
<template v-if="state.stage === 'images'">
正在生成图片 ({{ state.progress.images.completed }}/{{ state.progress.images.total }})
</template>
<template v-else-if="state.stage === 'animations'">
正在生成动画 ({{ state.progress.animations.completed }}/{{ state.progress.animations.total }})
</template>
<template v-else-if="state.stage === 'audio'">
正在生成配音...
</template>
<template v-else-if="state.stage === 'compositing'">
正在合成视频...
</template>
<template v-else-if="state.stage === 'done'">
生成完成!
</template>
<template v-else-if="state.stage === 'error'">
生成失败: {{ state.error }}
</template>
</div>
<!-- 进度条 -->
<el-progress :percentage="overallProgress" :status="progressStatus" />
<!-- 操作按钮 -->
<div class="actions">
<el-button
v-if="state.stage === 'idle'"
type="primary"
@click="startGeneration"
>
开始生成
</el-button>
<el-button
v-if="state.stage === 'error'"
@click="retry"
>
重试
</el-button>
</div>
</div>
</template>
状态管理清单
经过这一番折腾,小禾总结了一份清单:
| 场景 | 解决方案 |
|---|---|
| 单个操作 | idle/generating/generated/error 四状态 |
| 多类型操作 | 每种操作独立的状态变量 |
| 每个资源的状态 | 状态存在资源对象上(如 shot.status) |
| 防止重复触发 | 操作前检查状态 |
| 支持取消 | AbortController |
| 批量操作 | 并发控制 + Promise.allSettled |
| 多阶段流程 | 状态机 + 进度追踪 |
| 用户反馈 | 不同状态不同 UI |
小禾的感悟
一个 loading,
看似简单,
实则暗藏玄机。
true 和 false,
不足以描述世界,
idle、generating、generated、error,
才是完整的故事。
一个变量管不了多个操作,
每个操作要有自己的状态。
一个分镜有自己的 loading,
一个场景有自己的进度。
别忘了失败的情况,
别忘了取消的需求,
别忘了并发的控制,
别忘了用户的感受。
状态越复杂,
设计越要清晰。
状态机不是炫技,
而是驯服混乱的武器。
小禾保存代码,带着高烧长舒了一口气。
原来一个简单的"生成中…",背后有这么多学问。
下一篇预告:版本升级后老用户数据全挂了?一个迁移函数救命
数据结构变了,老用户的 localStorage 怎么办?
敬请期待。

798

被折叠的 条评论
为什么被折叠?



