feat: 优化项目详情页布局为上下布局

布局优化:
- 从左右布局改为上下布局,提升用户体验
- 统计信息移至顶部,采用卡片式设计
- 选项卡导航采用现代化设计风格

 响应式设计:
- 统计卡片支持2-4列自适应布局
- 选项卡在小屏幕上显示简化文本
- 操作按钮在移动端仅显示图标
- 素材网格支持1-5列响应式布局

 用户体验提升:
- 更好的信息层次结构
- 更高效的空间利用
- 移动端友好的交互设计
- 符合现代UI设计规范

遵循promptx/frontend-developer开发规范,确保:
- 移动优先的响应式设计
- 良好的可访问性支持
- 一致的设计语言
- 优秀的性能表现
This commit is contained in:
imeepos
2025-07-14 14:26:16 +08:00
parent 4b3b00bcfc
commit 4b26c0406c
8 changed files with 1620 additions and 163 deletions

View File

@@ -0,0 +1,469 @@
import React, { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import {
Search,
Filter,
Download,
RefreshCw,
AlertCircle,
CheckCircle,
Clock,
XCircle,
Eye,
Trash2,
RotateCcw
} from 'lucide-react';
import { LoadingSpinner } from './LoadingSpinner';
import { ErrorMessage } from './ErrorMessage';
// 类型定义
interface AiAnalysisLogItem {
id: string;
log_type: string;
title: string;
status: string;
status_display: string;
details: string;
error_message?: string;
confidence?: number;
quality_score?: number;
category?: string;
video_file_path?: string;
retry_count?: number;
created_at: string;
updated_at: string;
}
interface AiAnalysisLogResponse {
logs: AiAnalysisLogItem[];
total_count: number;
current_page: number;
page_size: number;
total_pages: number;
}
interface AiAnalysisLogStats {
total_records: number;
successful_classifications: number;
failed_classifications: number;
needs_review: number;
total_tasks: number;
completed_tasks: number;
failed_tasks: number;
processing_tasks: number;
average_confidence: number;
average_quality_score: number;
recent_24h_activity: number;
}
interface AiAnalysisLogViewerProps {
projectId: string;
}
/**
* AI分析日志查看器组件
* 遵循 Tauri 开发规范的前端组件设计模式
*/
export const AiAnalysisLogViewer: React.FC<AiAnalysisLogViewerProps> = ({ projectId }) => {
const [logs, setLogs] = useState<AiAnalysisLogItem[]>([]);
const [stats, setStats] = useState<AiAnalysisLogStats | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 查询参数
const [logType, setLogType] = useState<'records' | 'tasks'>('records');
const [statusFilter, setStatusFilter] = useState<string>('');
const [searchKeyword, setSearchKeyword] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(20);
const [totalPages, setTotalPages] = useState(0);
const [totalCount, setTotalCount] = useState(0);
// 过滤选项
const [filterOptions, setFilterOptions] = useState<any>(null);
// 加载过滤选项
useEffect(() => {
const loadFilterOptions = async () => {
try {
const options = await invoke('get_ai_analysis_log_filters');
setFilterOptions(options);
} catch (err) {
console.error('加载过滤选项失败:', err);
}
};
loadFilterOptions();
}, []);
// 加载日志数据
const loadLogs = async () => {
if (!projectId) return;
setLoading(true);
setError(null);
try {
const query = {
project_id: projectId,
log_type: logType,
status_filter: statusFilter || null,
search_keyword: searchKeyword || null,
page: currentPage,
page_size: pageSize,
};
const response: AiAnalysisLogResponse = await invoke('get_ai_analysis_logs', { query });
setLogs(response.logs);
setTotalCount(response.total_count);
setTotalPages(response.total_pages);
} catch (err) {
setError(err as string);
} finally {
setLoading(false);
}
};
// 加载统计数据
const loadStats = async () => {
if (!projectId) return;
try {
const statsData: AiAnalysisLogStats = await invoke('get_ai_analysis_stats', {
projectId
});
setStats(statsData);
} catch (err) {
console.error('加载统计数据失败:', err);
}
};
// 初始加载
useEffect(() => {
loadLogs();
loadStats();
}, [projectId, logType, statusFilter, currentPage]);
// 搜索处理
const handleSearch = () => {
setCurrentPage(1);
loadLogs();
};
// 重置搜索
const handleResetSearch = () => {
setSearchKeyword('');
setStatusFilter('');
setCurrentPage(1);
loadLogs();
};
// 导出日志
const handleExport = async (format: 'csv' | 'json') => {
try {
const result = await invoke('export_ai_analysis_logs', {
projectId,
format,
});
console.log('导出结果:', result);
// TODO: 处理导出结果,可能需要保存文件
} catch (err) {
console.error('导出失败:', err);
}
};
// 创建测试数据
const handleCreateTestData = async () => {
try {
const result = await invoke('create_test_ai_analysis_logs', {
projectId,
});
console.log('创建测试数据结果:', result);
// 重新加载数据
loadLogs();
loadStats();
} catch (err) {
console.error('创建测试数据失败:', err);
}
};
// 重试失败任务
const handleRetryTask = async (taskId: string) => {
try {
await invoke('retry_failed_classification_task', { taskId });
loadLogs(); // 重新加载数据
} catch (err) {
console.error('重试任务失败:', err);
}
};
// 获取状态图标
const getStatusIcon = (status: string, logType: string) => {
if (logType === 'record') {
switch (status) {
case '"Classified"':
return <CheckCircle className="w-4 h-4 text-green-500" />;
case '"Failed"':
return <XCircle className="w-4 h-4 text-red-500" />;
case '"NeedsReview"':
return <AlertCircle className="w-4 h-4 text-yellow-500" />;
default:
return <Clock className="w-4 h-4 text-gray-500" />;
}
} else {
switch (status) {
case '"Completed"':
return <CheckCircle className="w-4 h-4 text-green-500" />;
case '"Failed"':
return <XCircle className="w-4 h-4 text-red-500" />;
case '"Pending"':
case '"Uploading"':
case '"Analyzing"':
return <Clock className="w-4 h-4 text-blue-500" />;
default:
return <Clock className="w-4 h-4 text-gray-500" />;
}
}
};
// 获取状态颜色类
const getStatusColorClass = (status: string) => {
if (status.includes('Classified') || status.includes('Completed')) {
return 'bg-green-100 text-green-800';
} else if (status.includes('Failed')) {
return 'bg-red-100 text-red-800';
} else if (status.includes('NeedsReview')) {
return 'bg-yellow-100 text-yellow-800';
} else {
return 'bg-blue-100 text-blue-800';
}
};
if (error) {
return <ErrorMessage message={error} />;
}
return (
<div className="space-y-6">
{/* 统计卡片 */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white p-4 rounded-lg border border-gray-200">
<div className="text-sm text-gray-600"></div>
<div className="text-2xl font-bold text-gray-900">{stats.total_records}</div>
</div>
<div className="bg-white p-4 rounded-lg border border-gray-200">
<div className="text-sm text-gray-600"></div>
<div className="text-2xl font-bold text-green-600">{stats.successful_classifications}</div>
</div>
<div className="bg-white p-4 rounded-lg border border-gray-200">
<div className="text-sm text-gray-600"></div>
<div className="text-2xl font-bold text-red-600">{stats.failed_tasks}</div>
</div>
<div className="bg-white p-4 rounded-lg border border-gray-200">
<div className="text-sm text-gray-600"></div>
<div className="text-2xl font-bold text-blue-600">
{(stats.average_confidence * 100).toFixed(1)}%
</div>
</div>
</div>
)}
{/* 控制栏 */}
<div className="bg-white p-4 rounded-lg border border-gray-200">
<div className="flex flex-col md:flex-row gap-4">
{/* 日志类型切换 */}
<div className="flex space-x-2">
<button
onClick={() => setLogType('records')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
logType === 'records'
? 'bg-blue-100 text-blue-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}`}
>
</button>
<button
onClick={() => setLogType('tasks')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
logType === 'tasks'
? 'bg-blue-100 text-blue-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}`}
>
</button>
</div>
{/* 搜索和过滤 */}
<div className="flex flex-1 gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="text"
placeholder="搜索日志..."
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
{/* 状态过滤 */}
{filterOptions && (
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value=""></option>
{(logType === 'records'
? filterOptions.record_statuses
: filterOptions.task_statuses
).map((status: any) => (
<option key={status.value} value={status.value}>
{status.label}
</option>
))}
</select>
)}
<button
onClick={handleSearch}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Search className="w-4 h-4" />
</button>
<button
onClick={handleResetSearch}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
{/* 操作按钮 */}
<div className="flex gap-2">
<button
onClick={() => handleExport('csv')}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
title="导出CSV"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={handleCreateTestData}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm"
title="创建测试数据"
>
</button>
</div>
</div>
</div>
{/* 日志列表 */}
<div className="bg-white rounded-lg border border-gray-200">
{loading ? (
<div className="p-8 text-center">
<LoadingSpinner />
<p className="mt-2 text-gray-600">...</p>
</div>
) : logs.length === 0 ? (
<div className="p-8 text-center text-gray-500">
</div>
) : (
<div className="divide-y divide-gray-200">
{logs.map((log) => (
<div key={log.id} className="p-4 hover:bg-gray-50">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3 flex-1">
{getStatusIcon(log.status, log.log_type)}
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<h4 className="text-sm font-medium text-gray-900 truncate">
{log.title}
</h4>
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColorClass(log.status)}`}>
{log.status_display}
</span>
</div>
<p className="mt-1 text-sm text-gray-600">{log.details}</p>
{log.error_message && (
<p className="mt-1 text-sm text-red-600">{log.error_message}</p>
)}
<div className="mt-2 flex items-center space-x-4 text-xs text-gray-500">
<span>{new Date(log.created_at).toLocaleString('zh-CN')}</span>
{log.confidence && (
<span>: {(log.confidence * 100).toFixed(1)}%</span>
)}
{log.quality_score && (
<span>: {(log.quality_score * 10).toFixed(1)}/10</span>
)}
{log.retry_count !== undefined && log.retry_count > 0 && (
<span>: {log.retry_count}</span>
)}
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center space-x-2 ml-4">
{log.log_type === 'task' && log.status.includes('Failed') && (
<button
onClick={() => handleRetryTask(log.id)}
className="p-1 text-blue-600 hover:text-blue-800"
title="重试任务"
>
<RotateCcw className="w-4 h-4" />
</button>
)}
<button
className="p-1 text-gray-600 hover:text-gray-800"
title="查看详情"
>
<Eye className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* 分页 */}
{totalPages > 1 && (
<div className="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
<div className="text-sm text-gray-700">
{((currentPage - 1) * pageSize) + 1} {Math.min(currentPage * pageSize, totalCount)}
{totalCount}
</div>
<div className="flex space-x-2">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="px-3 py-1 text-sm border border-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
</button>
<span className="px-3 py-1 text-sm">
{currentPage} / {totalPages}
</span>
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="px-3 py-1 text-sm border border-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
</button>
</div>
</div>
)}
</div>
</div>
);
};