165 lines
6.1 KiB
TypeScript
165 lines
6.1 KiB
TypeScript
// ============================================================
|
||
// OutputPreview — 任务输出预览(图片 / 视频)
|
||
// 使用 useTaskDetail() Hook 获取任务详情,组件仅负责渲染
|
||
// ============================================================
|
||
|
||
import { useMemo } from 'react';
|
||
import { Image, Empty, Spin, Typography, Button, Tag, theme as antTheme } from 'antd';
|
||
import {
|
||
EyeOutlined,
|
||
DownloadOutlined,
|
||
CloseCircleOutlined,
|
||
} from '@ant-design/icons';
|
||
|
||
import { useHomeContext } from '@/contexts/home-context';
|
||
import { useTaskDetail } from '@/hooks/use-api';
|
||
import type { TaskStatusString } from '@/services/modules';
|
||
|
||
const { Text } = Typography;
|
||
|
||
// ---------- 状态标签配置 ----------
|
||
|
||
const STATUS_CONFIG: Record<TaskStatusString, { color: string; label: string }> = {
|
||
pending: { color: 'blue', label: '排队中' },
|
||
submitted: { color: 'cyan', label: '已提交' },
|
||
processing: { color: 'orange', label: '处理中' },
|
||
success: { color: 'green', label: '已完成' },
|
||
failed: { color: 'red', label: '失败' },
|
||
};
|
||
|
||
// ---------- 工具函数 ----------
|
||
|
||
function isVideoUrl(url: string): boolean {
|
||
return /\.(mp4|webm|mov|avi|mkv)(\?|$)/i.test(url);
|
||
}
|
||
|
||
function splitAssets(assets: Array<{ url: string }>): { images: string[]; videos: string[] } {
|
||
const images: string[] = [];
|
||
const videos: string[] = [];
|
||
assets.forEach(({ url }) => {
|
||
if (isVideoUrl(url)) videos.push(url);
|
||
else images.push(url);
|
||
});
|
||
return { images, videos };
|
||
}
|
||
|
||
// ---------- 组件 ----------
|
||
|
||
export function OutputPreview() {
|
||
const { token } = antTheme.useToken();
|
||
const { selectedTask } = useHomeContext();
|
||
|
||
// 数据获取(Hook 自动处理:taskId 为 null 时不请求、竞态、loading/error)
|
||
const { data: detail, loading } = useTaskDetail(selectedTask?.id);
|
||
|
||
// 从 output_assets 提取图片/视频
|
||
const { images, videos } = useMemo(() => {
|
||
if (!detail?.output_assets || detail.output_assets.length === 0) {
|
||
return { images: [], videos: [] };
|
||
}
|
||
return splitAssets(detail.output_assets);
|
||
}, [detail]);
|
||
|
||
const status = selectedTask?.status as TaskStatusString | undefined;
|
||
const statusCfg = status
|
||
? (STATUS_CONFIG[status] || { color: 'default', label: status })
|
||
: { color: 'default', label: '未知' };
|
||
|
||
// ---- 无选中任务 ----
|
||
if (!selectedTask) {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||
<Empty description="选择任务记录查看输出" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---- 处理中 ----
|
||
if (status === 'pending' || status === 'submitted' || status === 'processing') {
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 16 }}>
|
||
<Spin size="large" />
|
||
<Tag color={statusCfg.color}>{statusCfg.label}</Tag>
|
||
<Text type="secondary">任务正在处理中,请稍候...</Text>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---- 失败 ----
|
||
if (status === 'failed') {
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 12 }}>
|
||
<CloseCircleOutlined style={{ fontSize: 48, color: '#ff4d4f' }} />
|
||
<Text type="danger" strong>任务执行失败</Text>
|
||
{detail?.error_message && (
|
||
<Text type="secondary" style={{ maxWidth: 300, textAlign: 'center', fontSize: 12 }}>
|
||
{detail.error_message}
|
||
</Text>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---- 加载中 ----
|
||
if (loading) {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||
<Spin size="large" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---- 无结果 ----
|
||
if (!detail || images.length + videos.length === 0) {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||
<Empty description="暂无输出结果" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---- 展示 ----
|
||
return (
|
||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||
<div style={{
|
||
padding: '12px 16px',
|
||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
flexShrink: 0,
|
||
}}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<EyeOutlined style={{ fontSize: 14 }} />
|
||
<Text strong style={{ fontSize: 13 }}>输出预览</Text>
|
||
<Tag color={statusCfg.color} style={{ fontSize: 11 }}>{statusCfg.label}</Tag>
|
||
</div>
|
||
{videos.length > 0 && (
|
||
<Button size="small" type="link" icon={<DownloadOutlined />} href={videos[0]} target="_blank">
|
||
下载视频
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ flex: 1, overflow: 'auto', padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
{videos.map((url, i) => (
|
||
<div key={i} style={{ borderRadius: 6, overflow: 'hidden', background: '#000' }}>
|
||
<video controls style={{ width: '100%', maxHeight: 360, display: 'block' }} src={url}>
|
||
您的浏览器不支持视频播放
|
||
</video>
|
||
</div>
|
||
))}
|
||
{images.length === 1 ? (
|
||
<Image src={images[0]} alt="输出结果" style={{ borderRadius: 6, width: '100%', objectFit: 'contain' }} />
|
||
) : (
|
||
<Image.PreviewGroup>
|
||
{images.map((url, i) => (
|
||
<Image key={i} src={url} alt={`输出结果 ${i + 1}`}
|
||
style={{ borderRadius: 6, width: '100%', objectFit: 'contain', marginBottom: 8 }} />
|
||
))}
|
||
</Image.PreviewGroup>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
} |