diff --git a/apps/web/src/api/duplication.ts b/apps/web/src/api/duplication.ts new file mode 100644 index 000000000..d828f9235 --- /dev/null +++ b/apps/web/src/api/duplication.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + if (USE_MOCK) { + await new Promise((resolve) => setTimeout(resolve, 200)); + return; + } + await apiClient.delete(`/duplication/records/${recordId}`); +}; + +/** 重新查重 */ +export const retryDuplication = async ( + recordId: string +): Promise => { + 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; +}; diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx index 8a8f8b7a8..3ad0be292 100644 --- a/apps/web/src/components/layout/Header.tsx +++ b/apps/web/src/components/layout/Header.tsx @@ -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: }, { key: 'history', label: '任务历史', path: '/history', icon: }, { key: 'products', label: '成品库', path: '/products', icon: }, + { key: 'duplication', label: '查重', path: '/duplication', icon: }, ]; const Header: React.FC = () => { diff --git a/apps/web/src/pages/duplication/DuplicationDetail.tsx b/apps/web/src/pages/duplication/DuplicationDetail.tsx new file mode 100644 index 000000000..888ee7e9e --- /dev/null +++ b/apps/web/src/pages/duplication/DuplicationDetail.tsx @@ -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 ( + + 片段 {index + 1} + + 相似度 {segment.similarity.toFixed(1)}% + + + } + style={{ marginBottom: 16 }} + > + + {/* 原始视频片段 */} + + + + 原始视频片段 + + } + > + + + + {formatTime(segment.source_start)} -{' '} + {formatTime(segment.source_end)} + + + + {sourceDuration.toFixed(1)}秒 + + + + {/* 时间线可视化 */} +
+
+ + {sourceDuration.toFixed(0)}s + +
+
+
+ + + {/* 匹配到的视频片段 */} + + + + 匹配到的已有视频 + + } + > + + + + + {segment.matched_video_name} + + + + + + {formatTime(segment.matched_start)} -{' '} + {formatTime(segment.matched_end)} + + + + {matchedDuration.toFixed(1)}秒 + + + + {/* 时间线可视化 */} +
+
+ + {matchedDuration.toFixed(0)}s + +
+
+
+ +
+
+ ); +}; + +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 ( +
+ +
+ ); + } + + if (!detail) { + return ( +
+ + + +
+ ); + } + + return ( +
+ {/* 顶部导航 */} + + + + + {/* 基本信息 */} + + + <VideoCameraOutlined style={{ marginRight: 8 }} /> + {detail.filename} + + + + + + + {formatSize(detail.file_size)} + + + + + + + {detail.duration_seconds + ? `${Math.floor(detail.duration_seconds / 60)}分${detail.duration_seconds % 60}秒` + : '-'} + + + + + + + {new Date(detail.created_at).toLocaleString('zh-CN')} + + + + + + + {detail.duplicate_rate !== undefined ? ( + + + {detail.duplicate_rate.toFixed(1)}% + + + ) : ( + '-' + )} + + + + + + {/* 查重率进度条 */} + {detail.duplicate_rate !== undefined && ( +
+ + 查重率概览 + + +
+ )} +
+ + {/* 重复片段列表 */} + + + 重复片段详情 + {detail.segments?.length ?? 0} 个片段 + + } + > + {detail.segments && detail.segments.length > 0 ? ( + <> + + 以下片段与素材库中的已有视频存在重复,高相似度片段建议进行替换或裁剪。 + + + {detail.segments.map((segment, index) => ( + + ))} + + ) : ( + + )} + +
+ ); +}; + +export default DuplicationDetail; diff --git a/apps/web/src/pages/duplication/DuplicationResults.tsx b/apps/web/src/pages/duplication/DuplicationResults.tsx new file mode 100644 index 000000000..d0aac3af0 --- /dev/null +++ b/apps/web/src/pages/duplication/DuplicationResults.tsx @@ -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: , + }, + processing: { + color: 'processing', + text: '查重中', + icon: , + }, + completed: { + color: 'success', + text: '已完成', + icon: , + }, + failed: { + color: 'error', + text: '失败', + icon: , + }, +}; + +/** 查重率颜色 */ +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([]); + + // 获取查重记录 + 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 = [ + { + title: '文件名', + dataIndex: 'filename', + key: 'filename', + ellipsis: true, + width: 200, + render: (text: string) => ( + + {text} + + ), + }, + { + 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 ( + + {cfg.text} + + ); + }, + }, + { + title: '查重率', + dataIndex: 'duplicate_rate', + key: 'duplicate_rate', + width: 140, + render: (rate?: number, record?: DuplicationRecord) => { + if (record?.status !== 'completed' || rate === undefined) return '-'; + return ( + + + + {rate.toFixed(1)}% + + + ); + }, + }, + { + title: '重复片段', + dataIndex: 'duplicate_count', + key: 'duplicate_count', + width: 80, + align: 'center', + render: (count?: number, record?: DuplicationRecord) => { + if (record?.status !== 'completed') return '-'; + return {count ?? 0}; + }, + }, + { + 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) => ( + + {record.status === 'completed' && ( + + + + )} + + + + + `共 ${total} 条`, + }} + scroll={{ x: 900 }} + size="middle" + /> + + ); +}; + +export default DuplicationResults; diff --git a/apps/web/src/pages/duplication/DuplicationUpload.tsx b/apps/web/src/pages/duplication/DuplicationUpload.tsx new file mode 100644 index 000000000..22b8336b4 --- /dev/null +++ b/apps/web/src/pages/duplication/DuplicationUpload.tsx @@ -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([]); + 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 ( +
+ + <VideoCameraOutlined style={{ marginRight: 8 }} /> + 视频查重 + + + 上传视频文件,系统将自动检测与已有素材的重复片段,帮助您避免重复内容。 + + + {/* 上传区域 */} + + setFileList(newFileList)} + disabled={uploading} + style={{ padding: '20px 0' }} + > +

+ {uploading ? ( + + ) : ( + + )} +

+

+ {uploading ? '正在上传并查重...' : '点击或拖拽视频文件到此区域'} +

+

+ 支持 MP4、AVI、MOV、MKV 等格式,单个文件不超过 2GB +

+
+ + {/* 上传进度 */} + {uploading && ( +
+ '查重中...'} + size={120} + /> + + 正在分析视频内容,请稍候... + +
+ )} + + {/* 上传结果 */} + {uploadResult && !uploading && ( +
+ } + onClick={() => navigate('/duplication/results')} + > + 查看结果 + , + , + ]} + /> +
+ )} +
+ + {/* 提示信息 */} + + • 系统会对比您上传的视频与素材库中的已有视频 + • 查重完成后,可查看重复片段的具体位置 + • 查重过程通常需要几分钟,取决于视频大小 + • 支持的视频格式:MP4、AVI、MOV、MKV、WMV、FLV、WebM + + } + /> +
+ ); +}; + +export default DuplicationUpload; diff --git a/apps/web/src/router/index.tsx b/apps/web/src/router/index.tsx index 1b8dc812c..18871ca05 100644 --- a/apps/web/src/router/index.tsx +++ b/apps/web/src/router/index.tsx @@ -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 })),