feat: optimize ExportRecordManager UI to match project design system

- Replace native HTML table with project's DataTable component
- Upgrade to InteractiveButton, SearchInput, CustomSelect components
- Implement modern status indicators with icons and colors
- Add card-based layout for filters and statistics
- Replace window.confirm with DeleteConfirmDialog component
- Apply project's animation and hover effects
- Use Lucide React icons instead of emoji
- Follow promptx/frontend-developer standards
- Improve error handling and loading states
- Add responsive design and mobile optimization
This commit is contained in:
imeepos 2025-07-17 13:26:01 +08:00
parent 9f84ffe7f4
commit 3da60d684e
1 changed files with 368 additions and 209 deletions

View File

@ -7,6 +7,26 @@ import {
ExportType, ExportType,
ExportStatus ExportStatus
} from '../types/exportRecord'; } from '../types/exportRecord';
import { DataTable, Column, TableAction } from './DataTable';
import { InteractiveButton } from './InteractiveButton';
import { SearchInput } from './InteractiveInput';
import { CustomSelect } from './CustomSelect';
import { DeleteConfirmDialog } from './DeleteConfirmDialog';
import {
FileText,
Download,
RefreshCw,
Trash2,
Search,
CheckCircle,
XCircle,
Clock,
AlertCircle,
BarChart3,
FileSpreadsheet,
FileJson,
Film
} from 'lucide-react';
interface ExportRecordManagerProps { interface ExportRecordManagerProps {
projectId?: string; projectId?: string;
@ -25,7 +45,9 @@ const ExportRecordManager: React.FC<ExportRecordManagerProps> = ({
const [statistics, setStatistics] = useState<ExportRecordStatistics | null>(null); const [statistics, setStatistics] = useState<ExportRecordStatistics | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// const [selectedRecords] = useState<string[]>([]); const [selectedRecords, setSelectedRecords] = useState<ExportRecord[]>([]);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [recordToDelete, setRecordToDelete] = useState<string | null>(null);
// 过滤和分页状态 // 过滤和分页状态
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
@ -88,14 +110,20 @@ const ExportRecordManager: React.FC<ExportRecordManagerProps> = ({
// 删除导出记录 // 删除导出记录
const handleDeleteRecord = async (recordId: string) => { const handleDeleteRecord = async (recordId: string) => {
if (!window.confirm('确定要删除这条导出记录吗?')) { setRecordToDelete(recordId);
return; setDeleteDialogOpen(true);
} };
// 确认删除
const confirmDelete = async () => {
if (!recordToDelete) return;
try { try {
await invoke('delete_export_record', { recordId }); await invoke('delete_export_record', { recordId: recordToDelete });
await loadExportRecords(); await loadExportRecords();
await loadStatistics(); await loadStatistics();
setDeleteDialogOpen(false);
setRecordToDelete(null);
} catch (err) { } catch (err) {
setError(`删除导出记录失败: ${err}`); setError(`删除导出记录失败: ${err}`);
} }
@ -105,7 +133,8 @@ const ExportRecordManager: React.FC<ExportRecordManagerProps> = ({
const handleValidateFile = async (recordId: string) => { const handleValidateFile = async (recordId: string) => {
try { try {
const exists = await invoke<boolean>('validate_export_file', { recordId }); const exists = await invoke<boolean>('validate_export_file', { recordId });
alert(exists ? '文件存在' : '文件不存在'); // 这里应该使用项目的通知系统而不是alert
console.log(exists ? '文件存在' : '文件不存在');
} catch (err) { } catch (err) {
setError(`验证文件失败: ${err}`); setError(`验证文件失败: ${err}`);
} }
@ -113,12 +142,8 @@ const ExportRecordManager: React.FC<ExportRecordManagerProps> = ({
// 重新导出 // 重新导出
const handleReExport = async (recordId: string) => { const handleReExport = async (recordId: string) => {
// 这里应该打开文件选择对话框,简化处理
const newFilePath = prompt('请输入新的文件路径:');
if (!newFilePath) return;
try { try {
await invoke('re_export_record', { recordId, newFilePath }); await invoke('re_export_record', { recordId });
await loadExportRecords(); await loadExportRecords();
} catch (err) { } catch (err) {
setError(`重新导出失败: ${err}`); setError(`重新导出失败: ${err}`);
@ -127,14 +152,14 @@ const ExportRecordManager: React.FC<ExportRecordManagerProps> = ({
// 清理过期记录 // 清理过期记录
const handleCleanupExpired = async () => { const handleCleanupExpired = async () => {
const days = prompt('请输入要清理多少天前的记录:', '30'); // 这里应该使用自定义对话框而不是prompt
if (!days) return; const days = 30; // 默认30天后续可以改为对话框输入
try { try {
const count = await invoke<number>('cleanup_expired_export_records', { const count = await invoke<number>('cleanup_expired_export_records', {
days: parseInt(days) days
}); });
alert(`已清理 ${count} 条过期记录`); console.log(`已清理 ${count} 条过期记录`);
await loadExportRecords(); await loadExportRecords();
await loadStatistics(); await loadStatistics();
} catch (err) { } catch (err) {
@ -159,235 +184,369 @@ const ExportRecordManager: React.FC<ExportRecordManagerProps> = ({
return `${minutes}m ${seconds % 60}s`; return `${minutes}m ${seconds % 60}s`;
}; };
// 获取状态颜色 // 获取状态颜色和图标
const getStatusColor = (status: ExportStatus): string => { const getStatusInfo = (status: ExportStatus) => {
switch (status) { switch (status) {
case ExportStatus.Success: return 'text-green-600'; case ExportStatus.Success:
case ExportStatus.Failed: return 'text-red-600'; return {
case ExportStatus.InProgress: return 'text-blue-600'; color: 'text-green-600',
case ExportStatus.Cancelled: return 'text-gray-600'; bgColor: 'bg-green-50',
default: return 'text-gray-600'; icon: <CheckCircle className="w-4 h-4" />,
text: '成功'
};
case ExportStatus.Failed:
return {
color: 'text-red-600',
bgColor: 'bg-red-50',
icon: <XCircle className="w-4 h-4" />,
text: '失败'
};
case ExportStatus.InProgress:
return {
color: 'text-blue-600',
bgColor: 'bg-blue-50',
icon: <Clock className="w-4 h-4" />,
text: '进行中'
};
case ExportStatus.Cancelled:
return {
color: 'text-gray-600',
bgColor: 'bg-gray-50',
icon: <AlertCircle className="w-4 h-4" />,
text: '已取消'
};
default:
return {
color: 'text-gray-600',
bgColor: 'bg-gray-50',
icon: <AlertCircle className="w-4 h-4" />,
text: '未知'
};
} }
}; };
// 获取类型图标 // 获取类型图标和名称
const getTypeIcon = (type: ExportType): string => { const getTypeInfo = (type: ExportType) => {
switch (type) { switch (type) {
case ExportType.JianYingV1: return '🎬'; case ExportType.JianYingV1:
case ExportType.JianYingV2: return '🎭'; return { icon: <Film className="w-4 h-4" />, name: '剪映 V1' };
case ExportType.Json: return '📄'; case ExportType.JianYingV2:
case ExportType.Csv: return '📊'; return { icon: <Film className="w-4 h-4" />, name: '剪映 V2' };
case ExportType.Excel: return '📈'; case ExportType.Json:
default: return '📁'; return { icon: <FileJson className="w-4 h-4" />, name: 'JSON' };
case ExportType.Csv:
return { icon: <FileSpreadsheet className="w-4 h-4" />, name: 'CSV' };
case ExportType.Excel:
return { icon: <BarChart3 className="w-4 h-4" />, name: 'Excel' };
default:
return { icon: <FileText className="w-4 h-4" />, name: '其他' };
} }
}; };
// 定义表格列
const columns: Column<ExportRecord>[] = [
{
key: 'export_type',
title: '类型',
width: '120px',
render: (_, record) => {
const typeInfo = getTypeInfo(record.export_type);
return (
<div className="flex items-center gap-2">
<span className="text-primary-600">{typeInfo.icon}</span>
<span className="text-sm font-medium text-gray-900">{typeInfo.name}</span>
</div>
);
}
},
{
key: 'file_path',
title: '文件路径',
render: (_, record) => (
<div className="max-w-xs">
<div className="text-sm text-gray-900 truncate" title={record.file_path}>
{record.file_path}
</div>
</div>
)
},
{
key: 'export_status',
title: '状态',
width: '120px',
render: (_, record) => {
const statusInfo = getStatusInfo(record.export_status);
return (
<div className="flex items-center gap-2">
<div className={`flex items-center gap-1 px-2 py-1 rounded-full ${statusInfo.bgColor}`}>
<span className={statusInfo.color}>{statusInfo.icon}</span>
<span className={`text-xs font-medium ${statusInfo.color}`}>
{statusInfo.text}
</span>
</div>
</div>
);
}
},
{
key: 'file_size',
title: '文件大小',
width: '100px',
align: 'right' as const,
render: (_, record) => (
<span className="text-sm text-gray-900">
{formatFileSize(record.file_size)}
</span>
)
},
{
key: 'export_duration_ms',
title: '耗时',
width: '80px',
align: 'right' as const,
render: (_, record) => (
<span className="text-sm text-gray-900">
{formatDuration(record.export_duration_ms)}
</span>
)
},
{
key: 'created_at',
title: '创建时间',
width: '160px',
render: (_, record) => (
<span className="text-sm text-gray-900">
{new Date(record.created_at).toLocaleString()}
</span>
)
}
];
// 定义表格操作
const tableActions: TableAction<ExportRecord>[] = [
{
key: 'validate',
label: '验证文件',
icon: <Search className="w-4 h-4" />,
onClick: (record) => handleValidateFile(record.id),
variant: 'ghost'
},
{
key: 'reexport',
label: '重新导出',
icon: <RefreshCw className="w-4 h-4" />,
onClick: (record) => handleReExport(record.id),
variant: 'ghost'
},
{
key: 'delete',
label: '删除',
icon: <Trash2 className="w-4 h-4" />,
onClick: (record) => handleDeleteRecord(record.id),
variant: 'danger'
}
];
useEffect(() => { useEffect(() => {
loadExportRecords(); loadExportRecords();
loadStatistics(); loadStatistics();
}, [projectId, matchingResultId, filters, pagination.page]); }, [projectId, matchingResultId, filters, pagination.page]);
return ( return (
<div className={`export-record-manager ${compact ? 'p-0' : 'p-6'}`}> <div className={`export-record-manager ${compact ? 'p-0' : 'p-6'} animate-fade-in`}>
{showHeader && ( {showHeader && (
<div className="header mb-6"> <div className="page-header mb-6">
<h2 className="text-2xl font-bold text-gray-800 mb-4"> <h2 className="text-2xl font-bold text-gradient-primary mb-2">
{projectId && <span className="text-sm text-gray-600 ml-2">()</span>}
{matchingResultId && <span className="text-sm text-gray-600 ml-2">()</span>}
</h2> </h2>
<p className="text-gray-600 text-sm">
{projectId && <span className="ml-2 px-2 py-1 bg-blue-50 text-blue-600 rounded-full text-xs">()</span>}
{matchingResultId && <span className="ml-2 px-2 py-1 bg-purple-50 text-purple-600 rounded-full text-xs">()</span>}
</p>
</div> </div>
)} )}
<div className="content"> <div className="content space-y-6">
{/* 统计信息 */} {/* 统计信息 */}
{statistics && ( {statistics && (
<div className={`stats-grid grid ${compact ? 'grid-cols-2 md:grid-cols-4 gap-3 mb-4' : 'grid-cols-2 md:grid-cols-4 gap-4 mb-6'}`}> <div className={`grid ${compact ? 'grid-cols-2 md:grid-cols-4 gap-3' : 'grid-cols-2 md:grid-cols-4 gap-4'}`}>
<div className={`stat-card bg-blue-50 ${compact ? 'p-3' : 'p-4'} rounded-lg`}> <div className="stat-card hover-glow">
<div className={`${compact ? 'text-xl' : 'text-2xl'} font-bold text-blue-600`}>{statistics.total_exports}</div> <div className="flex items-center justify-between">
<div>
<div className={`${compact ? 'text-xl' : 'text-2xl'} font-bold text-primary-600`}>
{statistics.total_exports}
</div>
<div className="text-sm text-gray-600"></div> <div className="text-sm text-gray-600"></div>
</div> </div>
<div className={`stat-card bg-green-50 ${compact ? 'p-3' : 'p-4'} rounded-lg`}> <div className="p-3 bg-primary-50 rounded-lg">
<div className={`${compact ? 'text-xl' : 'text-2xl'} font-bold text-green-600`}>{statistics.successful_exports}</div> <BarChart3 className="w-6 h-6 text-primary-600" />
</div>
</div>
</div>
<div className="stat-card hover-glow">
<div className="flex items-center justify-between">
<div>
<div className={`${compact ? 'text-xl' : 'text-2xl'} font-bold text-green-600`}>
{statistics.successful_exports}
</div>
<div className="text-sm text-gray-600"></div> <div className="text-sm text-gray-600"></div>
</div> </div>
<div className={`stat-card bg-red-50 ${compact ? 'p-3' : 'p-4'} rounded-lg`}> <div className="p-3 bg-green-50 rounded-lg">
<div className={`${compact ? 'text-xl' : 'text-2xl'} font-bold text-red-600`}>{statistics.failed_exports}</div> <CheckCircle className="w-6 h-6 text-green-600" />
</div>
</div>
</div>
<div className="stat-card hover-glow">
<div className="flex items-center justify-between">
<div>
<div className={`${compact ? 'text-xl' : 'text-2xl'} font-bold text-red-600`}>
{statistics.failed_exports}
</div>
<div className="text-sm text-gray-600"></div> <div className="text-sm text-gray-600"></div>
</div> </div>
<div className={`stat-card bg-purple-50 ${compact ? 'p-3' : 'p-4'} rounded-lg`}> <div className="p-3 bg-red-50 rounded-lg">
<XCircle className="w-6 h-6 text-red-600" />
</div>
</div>
</div>
<div className="stat-card hover-glow">
<div className="flex items-center justify-between">
<div>
<div className={`${compact ? 'text-xl' : 'text-2xl'} font-bold text-purple-600`}> <div className={`${compact ? 'text-xl' : 'text-2xl'} font-bold text-purple-600`}>
{formatFileSize(statistics.total_file_size)} {formatFileSize(statistics.total_file_size)}
</div> </div>
<div className="text-sm text-gray-600"></div> <div className="text-sm text-gray-600"></div>
</div> </div>
<div className="p-3 bg-purple-50 rounded-lg">
<Download className="w-6 h-6 text-purple-600" />
</div>
</div>
</div>
</div> </div>
)} )}
{/* 过滤器 */} {/* 过滤器 */}
<div className="filters flex flex-wrap gap-4 mb-4"> <div className={`card ${compact ? 'mb-4' : 'mb-6'}`}>
<select <div className="card-body">
value={filters.export_type} <div className="flex flex-wrap gap-4 items-end">
onChange={(e) => setFilters(prev => ({ ...prev, export_type: e.target.value as ExportType }))} {/* 搜索框 */}
className="px-3 py-2 border border-gray-300 rounded-md" <div className="flex-1 min-w-64">
> <label className="block text-sm font-medium text-gray-700 mb-2">
<option value=""></option>
<option value={ExportType.JianYingV1}> V1</option> </label>
<option value={ExportType.JianYingV2}> V2</option> <SearchInput
<option value={ExportType.Json}>JSON</option>
<option value={ExportType.Csv}>CSV</option>
<option value={ExportType.Excel}>Excel</option>
</select>
<select
value={filters.export_status}
onChange={(e) => setFilters(prev => ({ ...prev, export_status: e.target.value as ExportStatus }))}
className="px-3 py-2 border border-gray-300 rounded-md"
>
<option value=""></option>
<option value={ExportStatus.Success}></option>
<option value={ExportStatus.Failed}></option>
<option value={ExportStatus.InProgress}></option>
<option value={ExportStatus.Cancelled}></option>
</select>
<input
type="text"
placeholder="搜索文件路径或错误信息..."
value={filters.search_keyword} value={filters.search_keyword}
onChange={(e) => setFilters(prev => ({ ...prev, search_keyword: e.target.value }))} onChange={(value) => setFilters(prev => ({ ...prev, search_keyword: value }))}
className="px-3 py-2 border border-gray-300 rounded-md flex-1 min-w-64" placeholder="搜索文件路径..."
className="w-full"
/> />
</div>
<button {/* 导出类型过滤 */}
onClick={handleCleanupExpired} <div className="min-w-40">
className="px-4 py-2 bg-orange-500 text-white rounded-md hover:bg-orange-600" <label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<CustomSelect
value={filters.export_type}
onChange={(value) => setFilters(prev => ({ ...prev, export_type: value as ExportType | '' }))}
options={[
{ value: '', label: '所有类型' },
{ value: ExportType.JianYingV1, label: '剪映 V1' },
{ value: ExportType.JianYingV2, label: '剪映 V2' },
{ value: ExportType.Json, label: 'JSON' },
{ value: ExportType.Csv, label: 'CSV' },
{ value: ExportType.Excel, label: 'Excel' }
]}
/>
</div>
{/* 状态过滤 */}
<div className="min-w-32">
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<CustomSelect
value={filters.export_status}
onChange={(value) => setFilters(prev => ({ ...prev, export_status: value as ExportStatus | '' }))}
options={[
{ value: '', label: '所有状态' },
{ value: ExportStatus.Success, label: '成功' },
{ value: ExportStatus.Failed, label: '失败' },
{ value: ExportStatus.InProgress, label: '进行中' },
{ value: ExportStatus.Cancelled, label: '已取消' }
]}
/>
</div>
{/* 操作按钮 */}
<div className="flex gap-2">
<InteractiveButton
onClick={loadExportRecords}
variant="secondary"
icon={<RefreshCw className="w-4 h-4" />}
loading={loading}
> >
</button> </InteractiveButton>
<InteractiveButton
onClick={handleCleanupExpired}
variant="danger"
icon={<Trash2 className="w-4 h-4" />}
>
</InteractiveButton>
</div>
</div>
</div> </div>
</div> </div>
{/* 错误信息 */} {/* 错误信息 */}
{error && ( {error && (
<div className="error-message bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-4"> <div className="card bg-red-50 border-red-200 mb-6">
{error} <div className="card-body">
<div className="flex items-center gap-3">
<XCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
<div>
<h4 className="text-red-800 font-medium"></h4>
<p className="text-red-700 text-sm mt-1">{error}</p>
</div>
</div>
</div>
</div> </div>
)} )}
{/* 加载状态 */} {/* 数据表格 */}
{loading && ( <div className="card">
<div className="loading text-center py-8"> <DataTable
<div className="text-gray-600">...</div> data={records}
columns={columns}
actions={tableActions}
loading={loading}
searchable={false} // 我们已经有自定义搜索
sortable={true}
pagination={true}
pageSize={pagination.page_size}
emptyText="暂无导出记录"
selectedRows={selectedRecords}
onSelectionChange={setSelectedRecords}
rowKey="id"
/>
</div> </div>
)}
{/* 导出记录列表 */}
{!loading && (
<div className="records-table">
<div className="overflow-x-auto">
<table className="min-w-full bg-white border border-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{records.map((record) => (
<tr key={record.id} className="hover:bg-gray-50">
<td className="px-4 py-4 whitespace-nowrap">
<div className="flex items-center">
<span className="mr-2">{getTypeIcon(record.export_type)}</span>
<span className="text-sm text-gray-900">
{record.export_type.replace(/([A-Z])/g, ' $1').trim()}
</span>
</div>
</td>
<td className="px-4 py-4">
<div className="text-sm text-gray-900 truncate max-w-xs" title={record.file_path}>
{record.file_path}
</div>
</td>
<td className="px-4 py-4 whitespace-nowrap">
<span className={`text-sm font-medium ${getStatusColor(record.export_status)}`}>
{record.export_status === ExportStatus.Success && '成功'}
{record.export_status === ExportStatus.Failed && '失败'}
{record.export_status === ExportStatus.InProgress && '进行中'}
{record.export_status === ExportStatus.Cancelled && '已取消'}
</span>
{record.error_message && (
<div className="text-xs text-red-600 mt-1" title={record.error_message}>
{record.error_message.substring(0, 50)}...
</div>
)}
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
{formatFileSize(record.file_size)}
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
{formatDuration(record.export_duration_ms)}
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
{new Date(record.created_at).toLocaleString()}
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
<button
onClick={() => handleValidateFile(record.id)}
className="text-blue-600 hover:text-blue-900"
title="验证文件"
>
🔍
</button>
<button
onClick={() => handleReExport(record.id)}
className="text-green-600 hover:text-green-900"
title="重新导出"
>
🔄
</button>
<button
onClick={() => handleDeleteRecord(record.id)}
className="text-red-600 hover:text-red-900"
title="删除记录"
>
🗑
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
{records.length === 0 && !loading && ( {/* 删除确认对话框 */}
<div className="text-center py-8 text-gray-500"> <DeleteConfirmDialog
isOpen={deleteDialogOpen}
</div> onCancel={() => {
)} setDeleteDialogOpen(false);
</div> setRecordToDelete(null);
)} }}
onConfirm={confirmDelete}
title="删除导出记录"
message="确定要删除这条导出记录吗?此操作无法撤销。"
/>
</div> </div>
); );
}; };