生成帧完成后自动刷新项目封面

- 项目库生成帧任务入队后继续轮询任务进度,解析成功后重新拉取项目列表和当前项目对象。

- 成功后显示视频帧生成完成提示,并让后端写入的 thumbnail_url 立即刷新到项目卡片封面。

- 补充 ProjectLibrary 回归测试,覆盖生成帧完成后自动显示新封面。

- 更新前端审计、设计冻结、测试计划、状态机和项目指南文档。
This commit is contained in:
2026-05-04 05:07:22 +08:00
parent 85de1ffbb2
commit 02635abab1
7 changed files with 92 additions and 12 deletions

View File

@@ -134,8 +134,10 @@ describe('ProjectLibrary', () => {
it('generates frames from an imported video with the selected FPS', async () => {
apiMock.getProjects
.mockResolvedValueOnce([{ id: 'p4', name: 'clip.mp4', status: 'pending', frames: 0, video_path: 'uploads/clip.mp4', parse_fps: 30 }])
.mockResolvedValueOnce([{ id: 'p4', name: 'clip.mp4', status: 'parsing', frames: 0, video_path: 'uploads/clip.mp4', parse_fps: 12 }]);
.mockResolvedValueOnce([{ id: 'p4', name: 'clip.mp4', status: 'parsing', frames: 0, video_path: 'uploads/clip.mp4', parse_fps: 12 }])
.mockResolvedValueOnce([{ id: 'p4', name: 'clip.mp4', status: 'ready', frames: 24, video_path: 'uploads/clip.mp4', parse_fps: 12, thumbnail_url: 'http://thumb/frame.jpg' }]);
apiMock.parseMedia.mockResolvedValueOnce({ id: 22, status: 'queued', progress: 0 });
apiMock.getTask.mockResolvedValueOnce({ id: 22, status: 'success', progress: 100, message: '解析完成' });
const { container } = render(<ProjectLibrary onProjectSelect={vi.fn()} />);
@@ -146,6 +148,11 @@ describe('ProjectLibrary', () => {
await waitFor(() => expect(apiMock.parseMedia).toHaveBeenCalledWith('p4', { parseFps: 12 }));
expect(await screen.findByRole('status')).toHaveTextContent('生成帧任务已入队 #22');
expect(await screen.findByText('12FPS')).toBeInTheDocument();
expect(screen.getByText('正在生成视频帧')).toBeInTheDocument();
await waitFor(() => expect(apiMock.getTask).toHaveBeenCalledWith(22), { timeout: 2500 });
expect(await screen.findByAltText('clip.mp4')).toHaveAttribute('src', 'http://thumb/frame.jpg');
expect(await screen.findByRole('status')).toHaveTextContent('视频帧生成完成,项目封面已自动更新');
});
it('hides frame generation while editing a project name', async () => {

View File

@@ -96,6 +96,18 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
}
};
const refreshProjects = async () => {
const data = await getProjects();
setProjects(data);
if (currentProject?.id) {
const refreshedCurrentProject = data.find((project) => project.id === currentProject.id);
if (refreshedCurrentProject) {
setCurrentProject(refreshedCurrentProject);
}
}
return data;
};
const ImportProgressPanel = () => {
if (!importProgress) return null;
const percent = typeof importProgress.percent === 'number'
@@ -378,14 +390,75 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
const handleGenerateFrames = async () => {
if (!frameProject?.id) return;
const targetProject = frameProject;
setIsGeneratingFrames(true);
try {
const task = await parseMedia(frameProject.id, { parseFps: frameParseFps });
const task = await parseMedia(targetProject.id, { parseFps: frameParseFps });
showNotice(`生成帧任务已入队 #${task.id}\n帧率: ${frameParseFps} FPS\n可在 Dashboard 查看进度。`, 'success');
const data = await getProjects();
setProjects(data);
await refreshProjects();
setShowFrameConfig(false);
setFrameProject(null);
if (task.id) {
setImportProgress({
kind: 'video',
phase: 'parsing',
title: '正在生成视频帧',
detail: task.message || `项目:${targetProject.name}`,
percent: task.progress ?? 0,
});
void waitForTaskDone(task.id, (progress) => {
setImportProgress({
kind: 'video',
phase: 'parsing',
title: '正在生成视频帧',
detail: progress.message || `任务状态: ${progress.status || 'running'}`,
percent: progress.progress,
});
}).then(async (completed) => {
if (completed.status === 'failed') {
setImportProgress({
kind: 'video',
phase: 'error',
title: '视频帧生成失败',
detail: completed.error || completed.message || targetProject.name,
percent: 100,
});
showNotice('视频帧生成失败,请检查后端服务或任务日志', 'error');
return;
}
if (completed.status === 'cancelled') {
setImportProgress({
kind: 'video',
phase: 'error',
title: '视频帧生成已取消',
detail: targetProject.name,
percent: 100,
});
showNotice('视频帧生成任务已取消', 'error');
return;
}
await refreshProjects();
setImportProgress({
kind: 'video',
phase: 'done',
title: '视频帧生成完成',
detail: '项目封面已自动更新',
percent: 100,
});
showNotice('视频帧生成完成,项目封面已自动更新', 'success');
scheduleProgressDismiss();
}).catch((err) => {
console.error('Frame generation polling failed:', err);
setImportProgress({
kind: 'video',
phase: 'error',
title: '视频帧生成进度同步失败',
detail: targetProject.name,
percent: 100,
});
showNotice('视频帧生成进度同步失败,请刷新项目库重试', 'error');
});
}
} catch (err) {
console.error('Frame generation failed:', err);
showNotice('生成帧失败,请检查后端服务或项目源文件', 'error');