feat(generate): 生成管线改为异步,前端接入轮询进度

后端:
- POST /api/v1/generate 改为异步模式,立即返回 taskId
- 管线在后台 goroutine 执行,状态通过 GET /tasks/:taskId 轮询
- 任务状态流转: pending → running → saving → completed/failed

前端:
- generation store: submit 后每 2s 轮询 getTask,完成时自动 getAssets
- GeneratePage: 实时显示轮询状态文本 + 进度条
- ResultPage: 挂载时轮询任务,未完成显示骨架屏+进度,完成自动加载素材
- Types: Task.status 增加 submitted / saving 状态
This commit is contained in:
2026-05-25 12:51:49 +08:00
parent c4dc7394b1
commit 322e6aecbb
6 changed files with 230 additions and 118 deletions
-7
View File
@@ -19,13 +19,6 @@ export async function getTask(taskId: string): Promise<Task> {
export async function getAssets(taskId: string): Promise<Asset[]> {
const resp = await get<AssetsResponse>(`/api/v1/tasks/${taskId}/assets`)
return toAssetList(resp)
}
/** 将响应转为 Asset[] 供前端组件使用 */
function toAssetList(
resp: AssetsResponse | GenerateResponse,
): Asset[] {
return resp.assets.map((a, i) => ({
id: `asset-${i}`,
url: a.url,
+2 -12
View File
@@ -67,7 +67,7 @@ export interface Task {
projectId: string
prompt: string
assetType: string
status: 'pending' | 'running' | 'completed' | 'failed'
status: 'pending' | 'submitted' | 'running' | 'completed' | 'failed'
stage?: PipelineStage
progress?: number
retryCount?: number
@@ -106,19 +106,9 @@ export interface GenerateRequest {
format?: 'spritesheet' | 'individual'
}
// 生成响应 — 对应 POST /api/v1/generate 返回
// 生成响应 — 对应 POST /api/v1/generate 返回(异步,仅含 taskId)
export interface GenerateResponse {
taskId: string
assets: {
url: string
format: string
}[]
metadata: {
frameWidth: number
frameHeight: number
frameCount: number
directions: number
}
}
// 素材列表响应 — 对应 GET /api/v1/tasks/:taskId/assets
+2 -1
View File
@@ -21,6 +21,7 @@ export default function GeneratePage() {
status,
progress,
taskId,
statusText,
submit,
reset: resetGeneration,
} = useGenerationStore()
@@ -91,7 +92,7 @@ export default function GeneratePage() {
{status === 'running' && (
<p style={{ textAlign: 'center', color: 'var(--text-secondary)' }}>
管线执行中,请稍候...
{statusText || '管线执行中,请稍候...'}
</p>
)}
+85 -42
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, useRef } from 'react'
import { Link, useParams } from 'react-router-dom'
import { getTask, getAssets } from '../api/generate'
import type { Asset, Task } from '../api/types'
@@ -10,19 +10,47 @@ export default function ResultPage() {
const [task, setTask] = useState<Task | null>(null)
const [assets, setAssets] = useState<Asset[]>([])
const [loading, setLoading] = useState(true)
const [polling, setPolling] = useState(false)
const pollRef = useRef<ReturnType<typeof setInterval>>()
useEffect(() => {
if (!taskId) return
setLoading(true)
Promise.all([getTask(taskId), getAssets(taskId)])
.then(([t, a]) => {
const fetchTask = async () => {
try {
const t = await getTask(taskId)
setTask(t)
setAssets(a)
})
.finally(() => setLoading(false))
if (t.status === 'completed') {
if (pollRef.current) clearInterval(pollRef.current)
setPolling(false)
const a = await getAssets(taskId)
setAssets(a)
setLoading(false)
} else if (t.status === 'failed') {
if (pollRef.current) clearInterval(pollRef.current)
setPolling(false)
setLoading(false)
} else if (!pollRef.current) {
// 开始轮询
setPolling(true)
pollRef.current = setInterval(fetchTask, 2000)
}
} catch {
// 出错也停止加载态
setLoading(false)
}
}
fetchTask()
return () => {
if (pollRef.current) clearInterval(pollRef.current)
}
}, [taskId])
if (loading) {
if (loading || polling) {
return (
<div className="container page-enter" style={{ paddingTop: 40 }}>
<div className="card" style={{ marginBottom: 24 }}>
@@ -30,6 +58,13 @@ export default function ResultPage() {
<div style={{ marginTop: 16 }}>
<Skeleton variant="text" lines={4} />
</div>
{task && (
<p style={{ textAlign: 'center', color: 'var(--text-secondary)', marginTop: 16 }}>
{task.status === 'pending' && '任务排队中...'}
{task.status === 'running' && `生成中... ${task.progress ?? 0}%`}
{task.status === 'submitted' && '已提交,等待处理...'}
</p>
)}
</div>
<div className="card">
<Skeleton variant="card" />
@@ -53,27 +88,35 @@ export default function ResultPage() {
<div className="container page-enter" style={{ paddingTop: 24, paddingBottom: 40 }}>
<h1 style={{ fontSize: 24, marginBottom: 32 }}>生成结果</h1>
{/* 任务信息 */}
<section className="card" style={{ marginBottom: 24 }}>
<h2 style={{ fontSize: 16, marginBottom: 16 }}>任务信息</h2>
<div
style={{
display: 'grid',
gridTemplateColumns: '120px 1fr',
gap: '8px 16px',
fontSize: 13,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ fontSize: 16, marginBottom: 0 }}>任务信息</h2>
<div style={{ display: 'flex', gap: 12 }}>
{assets.length > 0 && (
<button
className="btn-primary"
onClick={() => downloadAssets(assets)}
style={{ padding: '8px 20px', fontSize: 13 }}
>
下载全部素材
</button>
)}
<Link
to={`/projects/${projectId}/generate`}
className="btn-secondary"
style={{ padding: '8px 20px', fontSize: 13, borderRadius: 'var(--radius)', display: 'inline-block' }}
>
继续生成
</Link>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: '8px 16px', fontSize: 13 }}>
<span style={{ color: 'var(--text-secondary)' }}>提示词</span>
<span>{task.prompt}</span>
<span style={{ color: 'var(--text-secondary)' }}>素材类型</span>
<span>{task.assetType}</span>
<span style={{ color: 'var(--text-secondary)' }}>状态</span>
<span
style={{
color: task.status === 'completed' ? 'var(--success)' : 'var(--error)',
}}
>
<span style={{ color: task.status === 'completed' ? 'var(--success)' : 'var(--error)' }}>
{task.status === 'completed' ? '已完成' : '失败'}
</span>
<span style={{ color: 'var(--text-secondary)' }}>创建时间</span>
@@ -93,29 +136,29 @@ export default function ResultPage() {
</div>
</section>
{/* 素材预览 */}
<section className="card" style={{ marginBottom: 24 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<h2 style={{ fontSize: 16 }}>素材预览</h2>
{assets.length > 0 && (
<button
className="btn-primary"
onClick={() => alert('下载功能即将上线')}
style={{ padding: '8px 20px', fontSize: 13 }}
>
下载素材
</button>
)}
</div>
<h2 style={{ fontSize: 16, marginBottom: 16 }}>素材预览</h2>
<AssetPreview assets={assets} />
</section>
</div>
)
}
async function downloadAssets(assets: Asset[]) {
for (const a of assets) {
try {
const res = await fetch(a.url)
const blob = await res.blob()
const blobUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = blobUrl
link.download = `${a.id}.${a.format}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(blobUrl)
} catch {
window.open(a.url, '_blank')
}
}
}
+64 -31
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand'
import type { Asset, GenerateRequest, GenerateResponse } from '../api/types'
import { submitGenerate } from '../api/generate'
import type { Asset, GenerateRequest } from '../api/types'
import { submitGenerate, getTask, getAssets } from '../api/generate'
type Status = 'idle' | 'submitting' | 'running' | 'completed' | 'failed'
@@ -9,66 +9,99 @@ interface GenerationState {
projectId: string | null
progress: number
status: Status
statusText: string
assets: Asset[]
error: string | null
submit: (req: GenerateRequest) => Promise<void>
reset: () => void
}
export const useGenerationStore = create<GenerationState>((set) => ({
let pollTimer: ReturnType<typeof setInterval> | null = null
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
export const useGenerationStore = create<GenerationState>((set, get) => ({
taskId: null,
projectId: null,
progress: 0,
status: 'idle',
statusText: '',
assets: [],
error: null,
submit: async (req) => {
set({ status: 'submitting', error: null })
stopPolling()
set({ status: 'submitting', error: null, statusText: '提交中...' })
try {
set({ status: 'running', progress: 30 })
const result = await submitGenerate(req)
set({ progress: 80 })
const assets = mapAssets(result)
const { taskId } = await submitGenerate(req)
set({
taskId: result.taskId,
taskId,
projectId: req.projectId,
status: 'completed',
progress: 100,
assets,
status: 'running',
progress: 10,
statusText: '任务已提交,等待生成...',
})
// 开始轮询进度
pollTimer = setInterval(async () => {
try {
const task = await getTask(taskId)
set({
progress: task.progress ?? get().progress,
statusText:
task.status === 'running'
? '生成中...'
: task.status === 'pending'
? '排队中...'
: task.status,
})
if (task.status === 'completed') {
stopPolling()
set({ progress: 90, statusText: '获取结果...' })
const assets = await getAssets(taskId)
set({
status: 'completed',
progress: 100,
statusText: '生成完成',
assets,
})
} else if (task.status === 'failed') {
stopPolling()
set({
status: 'failed',
error: task.error || '生成失败',
statusText: '生成失败',
})
}
} catch {
// 网络错误不中断轮询
}
}, 2000)
} catch (err) {
const errMsg = (err as Error).message
set({ status: 'failed', error: errMsg })
stopPolling()
set({ status: 'failed', error: (err as Error).message, statusText: '提交失败' })
}
},
reset: () => {
stopPolling()
set({
taskId: null,
projectId: null,
progress: 0,
status: 'idle',
statusText: '',
assets: [],
error: null,
})
},
}))
function mapAssets(resp: GenerateResponse): Asset[] {
return resp.assets.map((a, i) => ({
id: `asset-${i}`,
url: a.url,
format: a.format,
width: resp.metadata.frameWidth,
height: resp.metadata.frameHeight,
metadata: {
frameWidth: resp.metadata.frameWidth,
frameHeight: resp.metadata.frameHeight,
frameCount: resp.metadata.frameCount,
directions: resp.metadata.directions,
},
}))
}