fix: 修复前端队列控制按钮功能

问题修复:
- 修复暂停/恢复/停止按钮无法正常工作的问题
- 添加详细的调试日志来诊断按钮操作
- 优化按钮的启用/禁用逻辑和视觉反馈

 技术改进:
- 在队列操作后添加延迟确保后端状态更新
- 修复refreshQueueStatus方法的返回值类型
- 改进按钮状态判断逻辑,支持更多队列状态

 用户体验提升:
- 添加状态指示器显示当前队列状态
- 改进按钮的禁用状态样式
- 提供更清晰的按钮提示文本

 状态管理优化:
- 确保队列控制操作后正确刷新状态
- 添加操作中的加载状态指示
- 改进错误处理和日志输出

 任务恢复机制:
- 添加recover_stuck_tasks功能恢复卡住的任务
- 在队列启动时自动恢复处理中状态的任务
- 解决应用意外关闭导致任务永久卡住的问题
This commit is contained in:
imeepos 2025-07-14 15:52:28 +08:00
parent 0c7eeb9905
commit 7a9ac750ae
8 changed files with 184 additions and 24 deletions

View File

@ -85,6 +85,18 @@ impl VideoClassificationQueue {
*status = QueueStatus::Running;
drop(status);
// 恢复卡住的任务状态
match self.service.recover_stuck_tasks().await {
Ok(recovered_count) => {
if recovered_count > 0 {
println!("🔄 队列启动时恢复了 {} 个卡住的任务", recovered_count);
}
}
Err(e) => {
println!("⚠️ 恢复卡住任务时出错: {}", e);
}
}
// 更新统计信息
self.update_stats().await?;

View File

@ -305,6 +305,11 @@ impl VideoClassificationService {
self.video_repo.delete_task(task_id).await
}
/// 恢复卡住的任务状态
pub async fn recover_stuck_tasks(&self) -> Result<usize> {
self.video_repo.recover_stuck_tasks().await
}
/// 重试失败的任务
pub async fn retry_failed_task(&self, _task_id: &str) -> Result<()> {
// 这里需要实现重试逻辑

View File

@ -472,6 +472,25 @@ impl VideoClassificationRepository {
let completed_json = serde_json::to_string(&TaskStatus::Completed)?;
let failed_json = serde_json::to_string(&TaskStatus::Failed)?;
// 调试:查看数据库中实际的任务状态分布
let debug_query = if let Some(project_id) = project_id {
format!("SELECT status, COUNT(*) FROM video_classification_tasks WHERE project_id = '{}' GROUP BY status", project_id)
} else {
"SELECT status, COUNT(*) FROM video_classification_tasks GROUP BY status".to_string()
};
let mut debug_stmt = conn.prepare(&debug_query)?;
let debug_rows = debug_stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)?))
})?;
println!("🔍 数据库中实际的任务状态分布:");
for row in debug_rows {
if let Ok((status, count)) = row {
println!(" 状态: {} -> 数量: {}", status, count);
}
}
// 获取任务统计
let mut stmt = conn.prepare(&format!(
"SELECT
@ -484,13 +503,12 @@ impl VideoClassificationRepository {
))?;
let task_stats = stmt.query_row([&pending_json, &uploading_json, &analyzing_json, &completed_json, &failed_json], |row| {
Ok((
row.get::<_, i32>(0)?,
row.get::<_, i32>(1)?,
row.get::<_, i32>(2)?,
row.get::<_, i32>(3)?,
row.get::<_, i32>(4)?,
))
let total: i32 = row.get(0)?;
let pending: i32 = row.get(1)?;
let processing: i32 = row.get(2)?;
let completed: i32 = row.get(3)?;
let failed: i32 = row.get(4)?;
Ok((total, pending, processing, completed, failed))
})?;
// 获取分类记录统计
@ -544,6 +562,49 @@ impl VideoClassificationRepository {
Ok(())
}
/// 恢复卡住的任务状态
/// 将所有处理中的任务Uploading, Analyzing重置为Pending状态
pub async fn recover_stuck_tasks(&self) -> Result<usize> {
let conn = self.database.get_connection();
let conn = conn.lock().unwrap();
let uploading_json = serde_json::to_string(&TaskStatus::Uploading)?;
let analyzing_json = serde_json::to_string(&TaskStatus::Analyzing)?;
let pending_json = serde_json::to_string(&TaskStatus::Pending)?;
println!("🔄 开始恢复卡住的任务状态...");
// 查询卡住的任务
let mut stmt = conn.prepare(
"SELECT id, status FROM video_classification_tasks WHERE status = ? OR status = ?"
)?;
let stuck_tasks: Vec<(String, String)> = stmt.query_map([&uploading_json, &analyzing_json], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?.collect::<Result<Vec<_>, _>>()?;
if stuck_tasks.is_empty() {
println!("✅ 没有发现卡住的任务");
return Ok(0);
}
println!("🔍 发现 {} 个卡住的任务:", stuck_tasks.len());
for (id, status) in &stuck_tasks {
println!(" 任务ID: {}, 状态: {}", &id[..8], status);
}
// 重置任务状态
let updated = conn.execute(
"UPDATE video_classification_tasks
SET status = ?, started_at = NULL, updated_at = datetime('now')
WHERE status = ? OR status = ?",
[&pending_json, &uploading_json, &analyzing_json]
)?;
println!("✅ 已恢复 {} 个任务状态为Pending", updated);
Ok(updated)
}
/// 根据ID获取分类任务
pub async fn get_task_by_id(&self, task_id: &str) -> Result<Option<VideoClassificationTask>> {
let conn = self.database.get_connection();

View File

@ -112,6 +112,7 @@ pub fn run() {
commands::video_classification_commands::get_all_classification_task_progress,
commands::video_classification_commands::get_project_classification_task_progress,
commands::video_classification_commands::stop_classification_queue,
commands::video_classification_commands::recover_stuck_classification_tasks,
commands::video_classification_commands::pause_classification_queue,
commands::video_classification_commands::resume_classification_queue,
commands::video_classification_commands::get_material_classification_records,

View File

@ -118,6 +118,28 @@ pub async fn stop_classification_queue(
queue.stop().await.map_err(|e| e.to_string())
}
/// 恢复卡住的任务状态
#[command]
pub async fn recover_stuck_classification_tasks(
state: State<'_, AppState>,
) -> Result<usize, String> {
let database = state.get_database();
let video_repo = Arc::new(VideoClassificationRepository::new(database.clone()));
let ai_classification_repo = Arc::new(AiClassificationRepository::new(database.clone()));
let material_repo = Arc::new(MaterialRepository::new(database.get_connection()).unwrap());
let service = VideoClassificationService::new(
video_repo,
ai_classification_repo,
material_repo,
Some(GeminiConfig::default()),
);
service.recover_stuck_tasks()
.await
.map_err(|e| e.to_string())
}
/// 暂停分类队列
#[command]
pub async fn pause_classification_queue(

View File

@ -60,7 +60,7 @@ const Navigation: React.FC = () => {
return (
<nav className="bg-white shadow-sm border-b border-gray-200">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<div className="flex items-center">

View File

@ -49,9 +49,9 @@ export const VideoClassificationProgress: React.FC<VideoClassificationProgressPr
// 刷新队列状态的方法
const refreshQueueStats = useCallback(async () => {
if (projectId) {
await getProjectQueueStatus(projectId);
return await getProjectQueueStatus(projectId);
} else {
await refreshQueueStatus();
return await refreshQueueStatus();
}
}, [projectId, getProjectQueueStatus, refreshQueueStatus]);
@ -132,21 +132,28 @@ export const VideoClassificationProgress: React.FC<VideoClassificationProgressPr
// 队列控制
const handlePauseResume = async () => {
try {
console.log('🎮 队列控制按钮点击,当前状态:', typedQueueStats?.status);
if (typedQueueStats?.status === 'Running') {
console.log('⏸️ 暂停队列...');
await pauseQueue();
} else if (typedQueueStats?.status === 'Paused') {
console.log('▶️ 恢复队列...');
await resumeQueue();
} else {
console.log('❓ 未知状态,无法操作:', typedQueueStats?.status);
}
} catch (error) {
console.error('队列控制失败:', error);
console.error('队列控制失败:', error);
}
};
const handleStop = async () => {
try {
console.log('🛑 停止队列...');
await stopQueue();
} catch (error) {
console.error('停止队列失败:', error);
console.error('停止队列失败:', error);
}
};
@ -208,11 +215,20 @@ export const VideoClassificationProgress: React.FC<VideoClassificationProgressPr
{/* 队列控制按钮 */}
{typedQueueStats && (
<div className="flex items-center space-x-2">
{/* 暂停/恢复按钮 */}
<button
onClick={handlePauseResume}
disabled={isLoading}
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
title={typedQueueStats.status === 'Running' ? '暂停' : '恢复'}
disabled={isLoading || (typedQueueStats.status !== 'Running' && typedQueueStats.status !== 'Paused')}
className={`p-2 rounded-md transition-colors ${
isLoading || (typedQueueStats.status !== 'Running' && typedQueueStats.status !== 'Paused')
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}`}
title={
typedQueueStats.status === 'Running' ? '暂停队列' :
typedQueueStats.status === 'Paused' ? '恢复队列' :
'队列未运行'
}
>
{typedQueueStats.status === 'Running' ? (
<Pause className="w-4 h-4" />
@ -220,15 +236,26 @@ export const VideoClassificationProgress: React.FC<VideoClassificationProgressPr
<Play className="w-4 h-4" />
)}
</button>
{/* 停止按钮 */}
<button
onClick={handleStop}
disabled={isLoading}
className="p-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors"
title="停止"
disabled={isLoading || typedQueueStats.status === 'Stopped'}
className={`p-2 rounded-md transition-colors ${
isLoading || typedQueueStats.status === 'Stopped'
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-600 hover:text-red-600 hover:bg-red-50'
}`}
title={typedQueueStats.status === 'Stopped' ? '队列已停止' : '停止队列'}
>
<Square className="w-4 h-4" />
</button>
{/* 状态指示器 */}
<div className="text-xs text-gray-500 ml-2">
: {typedQueueStats.status}
{isLoading && ' (操作中...)'}
</div>
</div>
)}
</div>

View File

@ -87,7 +87,7 @@ interface VideoClassificationState {
// UI helpers
clearError: () => void;
refreshQueueStatus: () => Promise<void>;
refreshQueueStatus: () => Promise<QueueStats | null>;
refreshTaskProgress: () => Promise<void>;
}
@ -185,10 +185,20 @@ export const useVideoClassificationStore = create<VideoClassificationState>((set
stopQueue: async () => {
set({ isLoading: true, error: null });
try {
console.log('🛑 调用停止队列命令...');
await invoke('stop_classification_queue');
await get().refreshQueueStatus();
console.log('✅ 停止队列命令执行成功,刷新状态...');
// 等待一小段时间确保后端状态已更新
await new Promise(resolve => setTimeout(resolve, 500));
// 刷新队列状态
const newStats = await get().getQueueStatus();
console.log('📊 刷新后的队列状态:', newStats);
set({ isLoading: false });
} catch (error) {
console.error('❌ 停止队列失败:', error);
const errorMessage = typeof error === 'string' ? error : '停止队列失败';
set({ error: errorMessage, isLoading: false });
throw new Error(errorMessage);
@ -198,10 +208,20 @@ export const useVideoClassificationStore = create<VideoClassificationState>((set
pauseQueue: async () => {
set({ isLoading: true, error: null });
try {
console.log('⏸️ 调用暂停队列命令...');
await invoke('pause_classification_queue');
await get().refreshQueueStatus();
console.log('✅ 暂停队列命令执行成功,刷新状态...');
// 等待一小段时间确保后端状态已更新
await new Promise(resolve => setTimeout(resolve, 500));
// 刷新队列状态
const newStats = await get().getQueueStatus();
console.log('📊 刷新后的队列状态:', newStats);
set({ isLoading: false });
} catch (error) {
console.error('❌ 暂停队列失败:', error);
const errorMessage = typeof error === 'string' ? error : '暂停队列失败';
set({ error: errorMessage, isLoading: false });
throw new Error(errorMessage);
@ -211,10 +231,20 @@ export const useVideoClassificationStore = create<VideoClassificationState>((set
resumeQueue: async () => {
set({ isLoading: true, error: null });
try {
console.log('▶️ 调用恢复队列命令...');
await invoke('resume_classification_queue');
await get().refreshQueueStatus();
console.log('✅ 恢复队列命令执行成功,刷新状态...');
// 等待一小段时间确保后端状态已更新
await new Promise(resolve => setTimeout(resolve, 500));
// 刷新队列状态
const newStats = await get().getQueueStatus();
console.log('📊 刷新后的队列状态:', newStats);
set({ isLoading: false });
} catch (error) {
console.error('❌ 恢复队列失败:', error);
const errorMessage = typeof error === 'string' ? error : '恢复队列失败';
set({ error: errorMessage, isLoading: false });
throw new Error(errorMessage);
@ -301,10 +331,12 @@ export const useVideoClassificationStore = create<VideoClassificationState>((set
refreshQueueStatus: async () => {
try {
await get().getQueueStatus();
const stats = await get().getQueueStatus();
return stats;
} catch (error) {
// 静默处理错误,避免重复设置错误状态
console.error('刷新队列状态失败:', error);
return null;
}
},