feat(phase2): 查重功能前端页面 #75

Merged
xiaoxia merged 2 commits from feat/phase2-duplication-ui into develop 2026-06-28 15:50:57 +08:00
6 changed files with 1118 additions and 0 deletions
+234
View File
@@ -0,0 +1,234 @@
/**
* 查重 API 模块
* 提供视频查重相关接口(当前使用 mock 数据,后端就绪后切换)
*/
import apiClient from './client';
/** 查重记录状态 */
export type DuplicationStatus = 'pending' | 'processing' | 'completed' | 'failed';
/** 查重记录 */
export interface DuplicationRecord {
id: string;
/** 原始文件名 */
filename: string;
/** 文件大小(字节) */
file_size: number;
/** 时长(秒) */
duration_seconds?: number;
/** 状态 */
status: DuplicationStatus;
/** 查重率(0-100 */
duplicate_rate?: number;
/** 重复片段数 */
duplicate_count?: number;
/** 创建时间 */
created_at: string;
/** 更新时间 */
updated_at: string;
}
/** 重复片段详情 */
export interface DuplicateSegment {
id: string;
/** 在原始视频中的起始时间(秒) */
source_start: number;
/** 在原始视频中的结束时间(秒) */
source_end: number;
/** 匹配到的已有视频 ID */
matched_video_id: string;
/** 匹配到的已有视频名称 */
matched_video_name: string;
/** 匹配片段在已有视频中的起始时间 */
matched_start: number;
/** 匹配片段在已有视频中的结束时间 */
matched_end: number;
/** 相似度(0-100 */
similarity: number;
}
/** 查重详情 */
export interface DuplicationDetail extends DuplicationRecord {
/** 重复片段列表 */
segments: DuplicateSegment[];
}
/** 上传查重响应 */
export interface DuplicationUploadResponse {
/** 查重记录 ID */
id: string;
/** 状态 */
status: DuplicationStatus;
/** 消息 */
message: string;
}
// ============ Mock 数据 ============
/** mock 查重记录列表 */
const MOCK_RECORDS: DuplicationRecord[] = [
{
id: 'dup-001',
filename: '日常vlog_01.mp4',
file_size: 125_000_000,
duration_seconds: 180,
status: 'completed',
duplicate_rate: 23.5,
duplicate_count: 3,
created_at: '2026-06-27T10:00:00Z',
updated_at: '2026-06-27T10:05:00Z',
},
{
id: 'dup-002',
filename: '美食分享_片段.mp4',
file_size: 45_000_000,
duration_seconds: 60,
status: 'completed',
duplicate_rate: 5.2,
duplicate_count: 1,
created_at: '2026-06-27T11:30:00Z',
updated_at: '2026-06-27T11:32:00Z',
},
{
id: 'dup-003',
filename: '旅行记录_巴黎.mp4',
file_size: 320_000_000,
duration_seconds: 420,
status: 'processing',
created_at: '2026-06-28T09:00:00Z',
updated_at: '2026-06-28T09:00:00Z',
},
{
id: 'dup-004',
filename: '产品展示_新版.mp4',
file_size: 88_000_000,
duration_seconds: 90,
status: 'completed',
duplicate_rate: 67.8,
duplicate_count: 8,
created_at: '2026-06-26T15:00:00Z',
updated_at: '2026-06-26T15:10:00Z',
},
{
id: 'dup-005',
filename: '教程_剪辑技巧.mp4',
file_size: 200_000_000,
duration_seconds: 300,
status: 'failed',
created_at: '2026-06-26T14:00:00Z',
updated_at: '2026-06-26T14:01:00Z',
},
];
/** mock 查重详情 */
const MOCK_DETAIL: DuplicationDetail = {
...MOCK_RECORDS[0],
segments: [
{
id: 'seg-001',
source_start: 10,
source_end: 25,
matched_video_id: 'asset-101',
matched_video_name: '日常vlog_素材库.mp4',
matched_start: 45,
matched_end: 60,
similarity: 92.3,
},
{
id: 'seg-002',
source_start: 60,
source_end: 78,
matched_video_id: 'asset-205',
matched_video_name: '城市风光_合集.mp4',
matched_start: 120,
matched_end: 138,
similarity: 85.7,
},
{
id: 'seg-003',
source_start: 150,
source_end: 165,
matched_video_id: 'asset-310',
matched_video_name: '背景音乐_配套画面.mp4',
matched_start: 30,
matched_end: 45,
similarity: 78.1,
},
],
};
/** 是否使用 mock 数据(后端就绪后改为 false) */
const USE_MOCK = true;
// ============ API 函数 ============
/** 上传视频进行查重 */
export const uploadForDuplication = async (
file: File
): Promise<DuplicationUploadResponse> => {
if (USE_MOCK) {
// 模拟上传延迟
await new Promise((resolve) => setTimeout(resolve, 1500));
return {
id: `dup-${Date.now()}`,
status: 'processing',
message: `文件 "${file.name}" 已上传,正在查重中...`,
};
}
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post('/duplication/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
};
/** 获取查重记录列表 */
export const getDuplicationRecords = async (): Promise<DuplicationRecord[]> => {
if (USE_MOCK) {
await new Promise((resolve) => setTimeout(resolve, 300));
return MOCK_RECORDS;
}
const response = await apiClient.get('/duplication/records');
return response.data;
};
/** 获取查重详情 */
export const getDuplicationDetail = async (
recordId: string
): Promise<DuplicationDetail> => {
if (USE_MOCK) {
await new Promise((resolve) => setTimeout(resolve, 300));
// 返回第一条 mock 详情,实际应按 ID 查找
return { ...MOCK_DETAIL, id: recordId };
}
const response = await apiClient.get(`/duplication/records/${recordId}`);
return response.data;
};
/** 删除查重记录 */
export const deleteDuplicationRecord = async (
recordId: string
): Promise<void> => {
if (USE_MOCK) {
await new Promise((resolve) => setTimeout(resolve, 200));
return;
}
await apiClient.delete(`/duplication/records/${recordId}`);
};
/** 重新查重 */
export const retryDuplication = async (
recordId: string
): Promise<DuplicationUploadResponse> => {
if (USE_MOCK) {
await new Promise((resolve) => setTimeout(resolve, 500));
return {
id: recordId,
status: 'processing',
message: '已重新提交查重',
};
}
const response = await apiClient.post(`/duplication/records/${recordId}/retry`);
return response.data;
};
@@ -17,6 +17,7 @@ import {
VideoCameraOutlined,
HistoryOutlined,
TrophyOutlined,
ScanOutlined,
} from '@ant-design/icons';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/store/authStore';
@@ -42,6 +43,7 @@ const NAV_ITEMS: NavItem[] = [
{ key: 'generate', label: '一键生成', path: '/generate', icon: <VideoCameraOutlined /> },
{ key: 'history', label: '任务历史', path: '/history', icon: <HistoryOutlined /> },
{ key: 'products', label: '成品库', path: '/products', icon: <TrophyOutlined /> },
{ key: 'duplication', label: '查重', path: '/duplication', icon: <ScanOutlined /> },
];
const Header: React.FC = () => {
@@ -0,0 +1,372 @@
/**
* 重复视频对比详情页面
* 展示查重结果中的重复片段详情,支持时间线对比
*/
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import {
Typography,
Card,
Tag,
Button,
Space,
Descriptions,
Progress,
Spin,
Empty,
Row,
Col,
Tooltip,
Divider,
} from 'antd';
import {
ArrowLeftOutlined,
VideoCameraOutlined,
ClockCircleOutlined,
WarningOutlined,
} from '@ant-design/icons';
import { useParams, useNavigate } from 'react-router-dom';
import {
getDuplicationDetail,
type DuplicateSegment,
} from '@/api/duplication';
const { Title, Text, Paragraph } = Typography;
/** 格式化时间(秒 → mm:ss */
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
};
/** 格式化文件大小 */
const formatSize = (bytes: number) => {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
};
/** 查重率颜色 */
const getRateColor = (rate: number) => {
if (rate <= 10) return '#52c41a';
if (rate <= 30) return '#faad14';
return '#ff4d4f';
};
/** 相似度颜色 */
const getSimilarityColor = (similarity: number) => {
if (similarity >= 90) return '#ff4d4f';
if (similarity >= 70) return '#faad14';
return '#52c41a';
};
/** 单个重复片段卡片 */
const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({
segment,
index,
}) => {
const sourceDuration = segment.source_end - segment.source_start;
const matchedDuration = segment.matched_end - segment.matched_start;
return (
<Card
size="small"
title={
<Space>
<Tag color="blue"> {index + 1}</Tag>
<Tag
color={getSimilarityColor(segment.similarity)}
style={{ fontWeight: 'bold' }}
>
{segment.similarity.toFixed(1)}%
</Tag>
</Space>
}
style={{ marginBottom: 16 }}
>
<Row gutter={[16, 16]}>
{/* 原始视频片段 */}
<Col xs={24} md={12}>
<Card
size="small"
type="inner"
title={
<Space>
<VideoCameraOutlined />
<Text strong></Text>
</Space>
}
>
<Descriptions column={1} size="small">
<Descriptions.Item label="时间范围">
<Tag color="blue">
{formatTime(segment.source_start)} -{' '}
{formatTime(segment.source_end)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="片段时长">
{sourceDuration.toFixed(1)}
</Descriptions.Item>
</Descriptions>
{/* 时间线可视化 */}
<div
style={{
marginTop: 12,
background: '#f5f5f5',
borderRadius: 4,
height: 24,
position: 'relative',
overflow: 'hidden',
}}
>
<div
style={{
position: 'absolute',
left: `${(segment.source_start / (segment.source_end + 30)) * 100}%`,
width: `${(sourceDuration / (segment.source_end + 30)) * 100}%`,
height: '100%',
background: 'rgba(24, 144, 255, 0.4)',
border: '1px solid #1890ff',
borderRadius: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text
style={{
fontSize: 10,
color: '#1890ff',
fontWeight: 'bold',
}}
>
{sourceDuration.toFixed(0)}s
</Text>
</div>
</div>
</Card>
</Col>
{/* 匹配到的视频片段 */}
<Col xs={24} md={12}>
<Card
size="small"
type="inner"
title={
<Space>
<WarningOutlined style={{ color: '#faad14' }} />
<Text strong></Text>
</Space>
}
>
<Descriptions column={1} size="small">
<Descriptions.Item label="视频名称">
<Tooltip title={segment.matched_video_name}>
<Text ellipsis style={{ maxWidth: 200 }}>
{segment.matched_video_name}
</Text>
</Tooltip>
</Descriptions.Item>
<Descriptions.Item label="时间范围">
<Tag color="orange">
{formatTime(segment.matched_start)} -{' '}
{formatTime(segment.matched_end)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="片段时长">
{matchedDuration.toFixed(1)}
</Descriptions.Item>
</Descriptions>
{/* 时间线可视化 */}
<div
style={{
marginTop: 12,
background: '#f5f5f5',
borderRadius: 4,
height: 24,
position: 'relative',
overflow: 'hidden',
}}
>
<div
style={{
position: 'absolute',
left: `${(segment.matched_start / (segment.matched_end + 30)) * 100}%`,
width: `${(matchedDuration / (segment.matched_end + 30)) * 100}%`,
height: '100%',
background: 'rgba(250, 173, 20, 0.4)',
border: '1px solid #faad14',
borderRadius: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text
style={{
fontSize: 10,
color: '#faad14',
fontWeight: 'bold',
}}
>
{matchedDuration.toFixed(0)}s
</Text>
</div>
</div>
</Card>
</Col>
</Row>
</Card>
);
};
const DuplicationDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: detail, isLoading } = useQuery({
queryKey: ['duplication-detail', id],
queryFn: () => getDuplicationDetail(id!),
enabled: !!id,
});
if (isLoading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
if (!detail) {
return (
<div style={{ padding: 24 }}>
<Empty description="未找到查重记录">
<Button onClick={() => navigate('/duplication/results')}>
</Button>
</Empty>
</div>
);
}
return (
<div style={{ padding: '24px', maxWidth: 1000, margin: '0 auto' }}>
{/* 顶部导航 */}
<Space style={{ marginBottom: 16 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/duplication/results')}
>
</Button>
</Space>
{/* 基本信息 */}
<Card style={{ marginBottom: 24 }}>
<Title level={4} style={{ marginTop: 0 }}>
<VideoCameraOutlined style={{ marginRight: 8 }} />
{detail.filename}
</Title>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} md={6}>
<Descriptions column={1} size="small">
<Descriptions.Item label="文件大小">
{formatSize(detail.file_size)}
</Descriptions.Item>
</Descriptions>
</Col>
<Col xs={24} sm={12} md={6}>
<Descriptions column={1} size="small">
<Descriptions.Item label="视频时长">
{detail.duration_seconds
? `${Math.floor(detail.duration_seconds / 60)}${detail.duration_seconds % 60}`
: '-'}
</Descriptions.Item>
</Descriptions>
</Col>
<Col xs={24} sm={12} md={6}>
<Descriptions column={1} size="small">
<Descriptions.Item label="提交时间">
{new Date(detail.created_at).toLocaleString('zh-CN')}
</Descriptions.Item>
</Descriptions>
</Col>
<Col xs={24} sm={12} md={6}>
<Descriptions column={1} size="small">
<Descriptions.Item label="查重率">
{detail.duplicate_rate !== undefined ? (
<Space>
<Text
strong
style={{
color: getRateColor(detail.duplicate_rate),
fontSize: 16,
}}
>
{detail.duplicate_rate.toFixed(1)}%
</Text>
</Space>
) : (
'-'
)}
</Descriptions.Item>
</Descriptions>
</Col>
</Row>
{/* 查重率进度条 */}
{detail.duplicate_rate !== undefined && (
<div style={{ marginTop: 16 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
<Progress
percent={Math.round(detail.duplicate_rate)}
strokeColor={getRateColor(detail.duplicate_rate)}
status={
detail.duplicate_rate <= 10
? 'success'
: detail.duplicate_rate <= 30
? 'normal'
: 'exception'
}
/>
</div>
)}
</Card>
{/* 重复片段列表 */}
<Card
title={
<Space>
<ClockCircleOutlined />
<span></span>
<Tag color="blue">{detail.segments?.length ?? 0} </Tag>
</Space>
}
>
{detail.segments && detail.segments.length > 0 ? (
<>
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
</Paragraph>
<Divider style={{ margin: '0 0 16px 0' }} />
{detail.segments.map((segment, index) => (
<SegmentCard key={segment.id} segment={segment} index={index} />
))}
</>
) : (
<Empty description="未发现重复片段" />
)}
</Card>
</div>
);
};
export default DuplicationDetail;
@@ -0,0 +1,311 @@
/**
* 查重结果列表页面
* 展示所有查重记录,支持查看详情、删除、重新查重
*/
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Typography,
Table,
Tag,
Button,
Space,
Popconfirm,
message,
Progress,
Tooltip,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
EyeOutlined,
DeleteOutlined,
ReloadOutlined,
UploadOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
SyncOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import {
getDuplicationRecords,
deleteDuplicationRecord,
retryDuplication,
type DuplicationRecord,
type DuplicationStatus,
} from '@/api/duplication';
const { Title, Text } = Typography;
/** 状态配置 */
const STATUS_CONFIG: Record<
DuplicationStatus,
{ color: string; text: string; icon: React.ReactNode }
> = {
pending: {
color: 'default',
text: '等待中',
icon: <ClockCircleOutlined />,
},
processing: {
color: 'processing',
text: '查重中',
icon: <SyncOutlined spin />,
},
completed: {
color: 'success',
text: '已完成',
icon: <CheckCircleOutlined />,
},
failed: {
color: 'error',
text: '失败',
icon: <CloseCircleOutlined />,
},
};
/** 查重率颜色 */
const getRateColor = (rate: number) => {
if (rate <= 10) return '#52c41a';
if (rate <= 30) return '#faad14';
return '#ff4d4f';
};
/** 格式化文件大小 */
const formatSize = (bytes: number) => {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
};
/** 格式化时长 */
const formatDuration = (seconds?: number) => {
if (!seconds) return '-';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return m > 0 ? `${m}${s}` : `${s}`;
};
const DuplicationResults: React.FC = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
// 获取查重记录
const { data: records = [], isLoading } = useQuery({
queryKey: ['duplication-records'],
queryFn: getDuplicationRecords,
});
// 删除
const deleteMutation = useMutation({
mutationFn: deleteDuplicationRecord,
onSuccess: () => {
message.success('已删除');
queryClient.invalidateQueries({ queryKey: ['duplication-records'] });
},
});
// 重新查重
const retryMutation = useMutation({
mutationFn: retryDuplication,
onSuccess: () => {
message.success('已重新提交查重');
queryClient.invalidateQueries({ queryKey: ['duplication-records'] });
},
});
/** 批量删除 */
const handleBatchDelete = async () => {
const results = await Promise.allSettled(
selectedRowKeys.map((key) => deleteDuplicationRecord(String(key)))
);
const succeeded = results.filter((r) => r.status === 'fulfilled').length;
const failed = results.length - succeeded;
if (failed === 0) {
message.success(`已删除 ${succeeded} 条记录`);
} else {
message.warning(`删除完成:${succeeded} 条成功,${failed} 条失败`);
}
setSelectedRowKeys([]);
queryClient.invalidateQueries({ queryKey: ['duplication-records'] });
};
const columns: ColumnsType<DuplicationRecord> = [
{
title: '文件名',
dataIndex: 'filename',
key: 'filename',
ellipsis: true,
width: 200,
render: (text: string) => (
<Tooltip title={text}>
<Text strong>{text}</Text>
</Tooltip>
),
},
{
title: '文件大小',
dataIndex: 'file_size',
key: 'file_size',
width: 100,
render: (size: number) => formatSize(size),
},
{
title: '时长',
dataIndex: 'duration_seconds',
key: 'duration_seconds',
width: 80,
render: (seconds?: number) => formatDuration(seconds),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: DuplicationStatus) => {
const cfg = STATUS_CONFIG[status];
return (
<Tag color={cfg.color} icon={cfg.icon}>
{cfg.text}
</Tag>
);
},
},
{
title: '查重率',
dataIndex: 'duplicate_rate',
key: 'duplicate_rate',
width: 140,
render: (rate?: number, record?: DuplicationRecord) => {
if (record?.status !== 'completed' || rate === undefined) return '-';
return (
<Space>
<Progress
percent={Math.round(rate)}
size="small"
strokeColor={getRateColor(rate)}
style={{ width: 60 }}
/>
<Text style={{ color: getRateColor(rate), fontSize: 12 }}>
{rate.toFixed(1)}%
</Text>
</Space>
);
},
},
{
title: '重复片段',
dataIndex: 'duplicate_count',
key: 'duplicate_count',
width: 80,
align: 'center',
render: (count?: number, record?: DuplicationRecord) => {
if (record?.status !== 'completed') return '-';
return <Text>{count ?? 0}</Text>;
},
},
{
title: '提交时间',
dataIndex: 'created_at',
key: 'created_at',
width: 160,
render: (time: string) => new Date(time).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'action',
width: 160,
fixed: 'right',
render: (_: unknown, record: DuplicationRecord) => (
<Space size="small">
{record.status === 'completed' && (
<Tooltip title="查看详情">
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => navigate(`/duplication/${record.id}`)}
/>
</Tooltip>
)}
{record.status === 'failed' && (
<Tooltip title="重新查重">
<Button
type="text"
size="small"
icon={<ReloadOutlined />}
onClick={() => retryMutation.mutate(record.id)}
/>
</Tooltip>
)}
<Popconfirm
title="确定删除此记录?"
onConfirm={() => deleteMutation.mutate(record.id)}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 24,
flexWrap: 'wrap',
gap: 12,
}}
>
<Title level={3} style={{ margin: 0 }}>
</Title>
<Space>
{selectedRowKeys.length > 0 && (
<Popconfirm
title={`确定删除选中的 ${selectedRowKeys.length} 条记录?`}
onConfirm={handleBatchDelete}
>
<Button danger icon={<DeleteOutlined />}>
({selectedRowKeys.length})
</Button>
</Popconfirm>
)}
<Button
type="primary"
icon={<UploadOutlined />}
onClick={() => navigate('/duplication')}
>
</Button>
</Space>
</div>
<Table
columns={columns}
dataSource={records}
rowKey="id"
loading={isLoading}
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
}}
pagination={{
pageSize: 10,
showSizeChanger: true,
showTotal: (total) => `${total}`,
}}
scroll={{ x: 900 }}
size="middle"
/>
</div>
);
};
export default DuplicationResults;
@@ -0,0 +1,187 @@
/**
* 上传查重页面
* 用户上传视频文件,系统进行查重检测
*/
import React, { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import {
Button,
Typography,
Upload,
message,
Card,
Progress,
Alert,
Space,
Result,
} from 'antd';
import {
InboxOutlined,
VideoCameraOutlined,
CheckCircleOutlined,
LoadingOutlined,
} from '@ant-design/icons';
import { uploadForDuplication } from '@/api/duplication';
import { useNavigate } from 'react-router-dom';
import type { UploadFile } from 'antd/es/upload';
const { Title, Text, Paragraph } = Typography;
const { Dragger } = Upload;
/** 支持的视频格式 */
const ACCEPT_FORMATS = '.mp4,.avi,.mov,.mkv,.wmv,.flv,.webm';
/** 最大文件大小:2GB */
const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024;
const DuplicationUpload: React.FC = () => {
const navigate = useNavigate();
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [uploadResult, setUploadResult] = useState<{
id: string;
message: string;
} | null>(null);
const [uploading, setUploading] = useState(false);
// 上传查重 mutation
const uploadMutation = useMutation({
mutationFn: (file: File) => uploadForDuplication(file),
onSuccess: (data) => {
setUploading(false);
setUploadResult({
id: data.id,
message: data.message,
});
message.success('查重任务已提交');
},
onError: () => {
setUploading(false);
message.error('上传失败,请重试');
},
});
/** 处理文件上传 */
const handleUpload = (file: File) => {
// 校验文件大小
if (file.size > MAX_FILE_SIZE) {
message.error('文件大小不能超过 2GB');
return false;
}
// 校验文件类型
const ext = file.name.toLowerCase().split('.').pop();
const allowedExts = ACCEPT_FORMATS.replace(/\./g, '').split(',');
if (!allowedExts.includes(ext || '')) {
message.error(`不支持的文件格式,支持:${ACCEPT_FORMATS}`);
return false;
}
setUploading(true);
setUploadResult(null);
uploadMutation.mutate(file);
return false; // 阻止自动上传
};
/** 重置状态 */
const handleReset = () => {
setFileList([]);
setUploadResult(null);
setUploading(false);
};
return (
<div style={{ padding: '24px', maxWidth: 800, margin: '0 auto' }}>
<Title level={3} style={{ marginBottom: 8 }}>
<VideoCameraOutlined style={{ marginRight: 8 }} />
</Title>
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
</Paragraph>
{/* 上传区域 */}
<Card>
<Dragger
accept={ACCEPT_FORMATS}
multiple={false}
fileList={fileList}
beforeUpload={handleUpload}
onChange={({ fileList: newFileList }) => setFileList(newFileList)}
disabled={uploading}
style={{ padding: '20px 0' }}
>
<p className="ant-upload-drag-icon">
{uploading ? (
<LoadingOutlined style={{ fontSize: 48, color: '#1890ff' }} />
) : (
<InboxOutlined style={{ fontSize: 48 }} />
)}
</p>
<p className="ant-upload-text">
{uploading ? '正在上传并查重...' : '点击或拖拽视频文件到此区域'}
</p>
<p className="ant-upload-hint">
MP4AVIMOVMKV 2GB
</p>
</Dragger>
{/* 上传进度 */}
{uploading && (
<div style={{ marginTop: 24, textAlign: 'center' }}>
<Progress
type="circle"
percent={99}
status="active"
format={() => '查重中...'}
size={120}
/>
<Paragraph type="secondary" style={{ marginTop: 16 }}>
...
</Paragraph>
</div>
)}
{/* 上传结果 */}
{uploadResult && !uploading && (
<div style={{ marginTop: 24 }}>
<Result
status="success"
title="查重任务已提交"
subTitle={uploadResult.message}
extra={[
<Button
type="primary"
key="view"
icon={<CheckCircleOutlined />}
onClick={() => navigate('/duplication/results')}
>
</Button>,
<Button key="continue" onClick={handleReset}>
</Button>,
]}
/>
</div>
)}
</Card>
{/* 提示信息 */}
<Alert
type="info"
showIcon
style={{ marginTop: 24 }}
message="查重说明"
description={
<Space direction="vertical" size={4}>
<Text> </Text>
<Text> </Text>
<Text> </Text>
<Text> MP4AVIMOVMKVWMVFLVWebM</Text>
</Space>
}
/>
</div>
);
};
export default DuplicationUpload;
+12
View File
@@ -85,6 +85,18 @@ export const router = createBrowserRouter([
path: 'products',
lazy: () => import('@/pages/products/ProductLibrary').then(m => ({ Component: m.default })),
},
{
path: 'duplication',
lazy: () => import('@/pages/duplication/DuplicationUpload').then(m => ({ Component: m.default })),
},
{
path: 'duplication/results',
lazy: () => import('@/pages/duplication/DuplicationResults').then(m => ({ Component: m.default })),
},
{
path: 'duplication/:id',
lazy: () => import('@/pages/duplication/DuplicationDetail').then(m => ({ Component: m.default })),
},
{
path: 'subscription',
lazy: () => import('@/pages/subscription/Plans').then(m => ({ Component: m.default })),