feat(phase2): 查重功能前端页面 #75
@@ -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">
|
||||
支持 MP4、AVI、MOV、MKV 等格式,单个文件不超过 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>• 支持的视频格式:MP4、AVI、MOV、MKV、WMV、FLV、WebM</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DuplicationUpload;
|
||||
@@ -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 })),
|
||||
|
||||
Reference in New Issue
Block a user