Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e82071f98e | |||
| aa9a44b7b8 | |||
| 12770b397f | |||
| abc5877bf6 |
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* 模板编辑器 API
|
||||
* 对接后端 /api/v1/templates 路由
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
import type {
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types"
|
||||
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
/** 模板模式(后端枚举值) */
|
||||
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"
|
||||
|
||||
/** 模式显示名称映射 */
|
||||
export const MODE_LABELS: Record<TemplateMode, string> = {
|
||||
pip: "混剪",
|
||||
voice_over: "人物口播",
|
||||
one_take: "一镜到底",
|
||||
voice_pip: "口播+混剪",
|
||||
}
|
||||
|
||||
/** 模式颜色映射 */
|
||||
export const MODE_COLORS: Record<TemplateMode, string> = {
|
||||
pip: "blue",
|
||||
voice_over: "green",
|
||||
one_take: "orange",
|
||||
voice_pip: "purple",
|
||||
}
|
||||
|
||||
/** 标题配置 */
|
||||
export interface TitleConfig {
|
||||
ai_auto_select: boolean
|
||||
content: string
|
||||
font_preset: string
|
||||
font_color: string
|
||||
font_size: number
|
||||
position: string
|
||||
}
|
||||
|
||||
/** 字幕配置 */
|
||||
export interface SubtitleConfig {
|
||||
enabled: boolean
|
||||
position: string
|
||||
font: string
|
||||
color: string
|
||||
size: number
|
||||
animation: string
|
||||
}
|
||||
|
||||
/** BGM 配置 */
|
||||
export interface BgmConfig {
|
||||
enabled: boolean
|
||||
music_id: string
|
||||
}
|
||||
|
||||
/** 模板片段 */
|
||||
export interface TemplateSegment {
|
||||
id?: string
|
||||
segment_order: number
|
||||
duration_min: number
|
||||
duration_max: number
|
||||
material_type: string | null
|
||||
}
|
||||
|
||||
/** 剪辑模板 */
|
||||
export interface EditingTemplate {
|
||||
id: string
|
||||
name: string
|
||||
mode: TemplateMode
|
||||
category: string
|
||||
tags: string[]
|
||||
title_config: TitleConfig
|
||||
subtitle_config: SubtitleConfig
|
||||
bgm_config: BgmConfig
|
||||
estimated_duration: number
|
||||
segments: TemplateSegment[]
|
||||
/** 水印配置(后端就绪后启用) */
|
||||
watermark_config?: WatermarkConfig
|
||||
/** 片头片尾配置(后端就绪后启用) */
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
/** 混剪配置 */
|
||||
pip_config?: PipConfig
|
||||
/** 滤镜调色配置 */
|
||||
filter_config?: FilterConfig
|
||||
/** 绿幕抠像配置 */
|
||||
green_screen_config?: ChromaKeyConfig
|
||||
/** 贴纸配置 */
|
||||
sticker_config?: StickerConfig
|
||||
/** 封面配置 */
|
||||
cover_config?: CoverConfig
|
||||
is_active?: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 模板分类 */
|
||||
export interface TemplateCategory {
|
||||
id: string
|
||||
name: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
/** 创建/更新模板请求体 */
|
||||
export interface SaveTemplatePayload {
|
||||
name: string
|
||||
mode: TemplateMode
|
||||
category: string
|
||||
tags: string[]
|
||||
title_config: TitleConfig
|
||||
subtitle_config: SubtitleConfig
|
||||
bgm_config: BgmConfig
|
||||
estimated_duration: number
|
||||
segments: Omit<TemplateSegment, "id">[]
|
||||
/** 水印配置(后端就绪后启用) */
|
||||
watermark_config?: WatermarkConfig
|
||||
/** 片头片尾配置(后端就绪后启用) */
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
/** 混剪配置 */
|
||||
pip_config?: PipConfig
|
||||
/** 滤镜调色配置 */
|
||||
filter_config?: FilterConfig
|
||||
/** 绿幕抠像配置 */
|
||||
green_screen_config?: ChromaKeyConfig
|
||||
/** 贴纸配置 */
|
||||
sticker_config?: StickerConfig
|
||||
/** 封面配置 */
|
||||
cover_config?: CoverConfig
|
||||
}
|
||||
|
||||
/** 使用模板生成请求体 */
|
||||
export interface GenerateFromTemplatePayload {
|
||||
voiceover_duration: number
|
||||
}
|
||||
|
||||
/** 验证警告详情 */
|
||||
export interface ValidationWarningDetails {
|
||||
/** 相关字段名 */
|
||||
field?: string
|
||||
/** 期望值 */
|
||||
expected?: string | number
|
||||
/** 实际值 */
|
||||
actual?: string | number
|
||||
/** 建议值 */
|
||||
suggested?: string | number
|
||||
}
|
||||
|
||||
/** 验证/生成响应 */
|
||||
export interface ValidateWarning {
|
||||
code: string
|
||||
message: string
|
||||
details?: ValidationWarningDetails
|
||||
}
|
||||
|
||||
/** 使用模板生成响应 */
|
||||
export interface GenerateFromTemplateResponse {
|
||||
template: EditingTemplate
|
||||
warnings: ValidateWarning[]
|
||||
}
|
||||
|
||||
/** 列表响应(带分页) */
|
||||
export interface ListTemplatesResponse {
|
||||
items: EditingTemplate[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 分类列表响应 */
|
||||
export interface ListCategoriesResponse {
|
||||
items: TemplateCategory[]
|
||||
}
|
||||
|
||||
// ============ API 函数 ============
|
||||
|
||||
/** 获取模板列表 */
|
||||
export const getEditingTemplates = async (params?: {
|
||||
category?: string
|
||||
tag?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
}): Promise<EditingTemplate[]> => {
|
||||
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
|
||||
params: {
|
||||
skip: params?.skip ?? 0,
|
||||
limit: params?.limit ?? 50,
|
||||
},
|
||||
})
|
||||
let list = response.data.items
|
||||
if (params?.category) list = list.filter((t) => t.category === params.category)
|
||||
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!))
|
||||
return list
|
||||
}
|
||||
|
||||
/** 获取模板详情 */
|
||||
export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建模板 */
|
||||
export const createEditingTemplate = async (
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.post<EditingTemplate>("/templates", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新模板 */
|
||||
export const updateEditingTemplate = async (
|
||||
id: string,
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除模板 */
|
||||
export const deleteEditingTemplate = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/templates/${id}`)
|
||||
}
|
||||
|
||||
/** 获取模板分类列表 */
|
||||
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
|
||||
const response = await apiClient.get<ListCategoriesResponse>("/templates/categories/list")
|
||||
return response.data.items
|
||||
}
|
||||
|
||||
/** 使用模板生成视频(调用 validate 端点) */
|
||||
export const generateFromTemplate = async (
|
||||
templateId: string,
|
||||
data: GenerateFromTemplatePayload,
|
||||
): Promise<GenerateFromTemplateResponse> => {
|
||||
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||
`/templates/${templateId}/validate`,
|
||||
data,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* 模板编辑器常量
|
||||
*/
|
||||
import type { TemplateMode } from "./types"
|
||||
|
||||
/** 模式显示名称映射 */
|
||||
export const MODE_LABELS: Record<TemplateMode, string> = {
|
||||
pip: "混剪",
|
||||
voice_over: "人物口播",
|
||||
one_take: "一镜到底",
|
||||
voice_pip: "口播+混剪",
|
||||
}
|
||||
|
||||
/** 模式颜色映射 */
|
||||
export const MODE_COLORS: Record<TemplateMode, string> = {
|
||||
pip: "blue",
|
||||
voice_over: "green",
|
||||
one_take: "orange",
|
||||
voice_pip: "purple",
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* 模板编辑器 API — 目录化入口
|
||||
* 保持与原 editing-planner.ts 相同导出,向后兼容
|
||||
*/
|
||||
|
||||
// 类型
|
||||
export type {
|
||||
TemplateMode,
|
||||
TitleConfig,
|
||||
SubtitleConfig,
|
||||
BgmConfig,
|
||||
TemplateSegment,
|
||||
EditingTemplate,
|
||||
TemplateCategory,
|
||||
SaveTemplatePayload,
|
||||
GenerateFromTemplatePayload,
|
||||
ValidationWarningDetails,
|
||||
ValidateWarning,
|
||||
GenerateFromTemplateResponse,
|
||||
ListTemplatesResponse,
|
||||
ListCategoriesResponse,
|
||||
} from "./types"
|
||||
|
||||
// 常量
|
||||
export { MODE_LABELS, MODE_COLORS } from "./constants"
|
||||
|
||||
// API 函数
|
||||
export {
|
||||
getEditingTemplates,
|
||||
getEditingTemplate,
|
||||
createEditingTemplate,
|
||||
updateEditingTemplate,
|
||||
deleteEditingTemplate,
|
||||
getTemplateCategories,
|
||||
generateFromTemplate,
|
||||
} from "./templates"
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* 模板编辑器 API 函数
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type {
|
||||
EditingTemplate,
|
||||
TemplateCategory,
|
||||
SaveTemplatePayload,
|
||||
GenerateFromTemplatePayload,
|
||||
GenerateFromTemplateResponse,
|
||||
ListTemplatesResponse,
|
||||
ListCategoriesResponse,
|
||||
} from "./types"
|
||||
|
||||
/** 获取模板列表 */
|
||||
export const getEditingTemplates = async (params?: {
|
||||
category?: string
|
||||
tag?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
}): Promise<EditingTemplate[]> => {
|
||||
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
|
||||
params: {
|
||||
skip: params?.skip ?? 0,
|
||||
limit: params?.limit ?? 50,
|
||||
},
|
||||
})
|
||||
let list = response.data.items
|
||||
if (params?.category) list = list.filter((t) => t.category === params.category)
|
||||
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!))
|
||||
return list
|
||||
}
|
||||
|
||||
/** 获取模板详情 */
|
||||
export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建模板 */
|
||||
export const createEditingTemplate = async (
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.post<EditingTemplate>("/templates", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新模板 */
|
||||
export const updateEditingTemplate = async (
|
||||
id: string,
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除模板 */
|
||||
export const deleteEditingTemplate = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/templates/${id}`)
|
||||
}
|
||||
|
||||
/** 获取模板分类列表 */
|
||||
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
|
||||
const response = await apiClient.get<ListCategoriesResponse>("/templates/categories/list")
|
||||
return response.data.items
|
||||
}
|
||||
|
||||
/** 使用模板生成视频(调用 validate 端点) */
|
||||
export const generateFromTemplate = async (
|
||||
templateId: string,
|
||||
data: GenerateFromTemplatePayload,
|
||||
): Promise<GenerateFromTemplateResponse> => {
|
||||
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||
`/templates/${templateId}/validate`,
|
||||
data,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* 模板编辑器类型定义
|
||||
*/
|
||||
import type {
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types"
|
||||
|
||||
/** 模板模式(后端枚举值) */
|
||||
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"
|
||||
|
||||
/** 标题配置 */
|
||||
export interface TitleConfig {
|
||||
ai_auto_select: boolean
|
||||
content: string
|
||||
font_preset: string
|
||||
font_color: string
|
||||
font_size: number
|
||||
position: string
|
||||
}
|
||||
|
||||
/** 字幕配置 */
|
||||
export interface SubtitleConfig {
|
||||
enabled: boolean
|
||||
position: string
|
||||
font: string
|
||||
color: string
|
||||
size: number
|
||||
animation: string
|
||||
}
|
||||
|
||||
/** BGM 配置 */
|
||||
export interface BgmConfig {
|
||||
enabled: boolean
|
||||
music_id: string
|
||||
}
|
||||
|
||||
/** 模板片段 */
|
||||
export interface TemplateSegment {
|
||||
id?: string
|
||||
segment_order: number
|
||||
duration_min: number
|
||||
duration_max: number
|
||||
material_type: string | null
|
||||
}
|
||||
|
||||
/** 剪辑模板 */
|
||||
export interface EditingTemplate {
|
||||
id: string
|
||||
name: string
|
||||
mode: TemplateMode
|
||||
category: string
|
||||
tags: string[]
|
||||
title_config: TitleConfig
|
||||
subtitle_config: SubtitleConfig
|
||||
bgm_config: BgmConfig
|
||||
estimated_duration: number
|
||||
segments: TemplateSegment[]
|
||||
watermark_config?: WatermarkConfig
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
pip_config?: PipConfig
|
||||
filter_config?: FilterConfig
|
||||
green_screen_config?: ChromaKeyConfig
|
||||
sticker_config?: StickerConfig
|
||||
cover_config?: CoverConfig
|
||||
is_active?: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 模板分类 */
|
||||
export interface TemplateCategory {
|
||||
id: string
|
||||
name: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
/** 创建/更新模板请求体 */
|
||||
export interface SaveTemplatePayload {
|
||||
name: string
|
||||
mode: TemplateMode
|
||||
category: string
|
||||
tags: string[]
|
||||
title_config: TitleConfig
|
||||
subtitle_config: SubtitleConfig
|
||||
bgm_config: BgmConfig
|
||||
estimated_duration: number
|
||||
segments: Omit<TemplateSegment, "id">[]
|
||||
watermark_config?: WatermarkConfig
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
pip_config?: PipConfig
|
||||
filter_config?: FilterConfig
|
||||
green_screen_config?: ChromaKeyConfig
|
||||
sticker_config?: StickerConfig
|
||||
cover_config?: CoverConfig
|
||||
}
|
||||
|
||||
/** 使用模板生成请求体 */
|
||||
export interface GenerateFromTemplatePayload {
|
||||
voiceover_duration: number
|
||||
}
|
||||
|
||||
/** 验证警告详情 */
|
||||
export interface ValidationWarningDetails {
|
||||
field?: string
|
||||
expected?: string | number
|
||||
actual?: string | number
|
||||
suggested?: string | number
|
||||
}
|
||||
|
||||
/** 验证警告 */
|
||||
export interface ValidateWarning {
|
||||
code: string
|
||||
message: string
|
||||
details?: ValidationWarningDetails
|
||||
}
|
||||
|
||||
/** 使用模板生成响应 */
|
||||
export interface GenerateFromTemplateResponse {
|
||||
template: EditingTemplate
|
||||
warnings: ValidateWarning[]
|
||||
}
|
||||
|
||||
/** 列表响应(带分页) */
|
||||
export interface ListTemplatesResponse {
|
||||
items: EditingTemplate[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 分类列表响应 */
|
||||
export interface ListCategoriesResponse {
|
||||
items: TemplateCategory[]
|
||||
}
|
||||
@@ -2,130 +2,27 @@
|
||||
* 查重结果列表页面 — V21 设计系统
|
||||
* 胶囊筛选 + 卡片列表,零 antd 依赖
|
||||
*/
|
||||
import React, { useState, useMemo } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Button, Tag, Tooltip } from "@/components/ui"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import {
|
||||
getDuplicationRecords,
|
||||
deleteDuplicationRecord,
|
||||
retryDuplication,
|
||||
type DuplicationStatus,
|
||||
} from "@/api/duplication"
|
||||
import "./duplication.css"
|
||||
import React from "react"
|
||||
import { Button } from "@/components/ui"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
|
||||
/** 风险等级分类 */
|
||||
type RiskFilter = "all" | "high" | "medium" | "low"
|
||||
|
||||
/** 状态配置 */
|
||||
const STATUS_CONFIG: Record<
|
||||
DuplicationStatus,
|
||||
{
|
||||
variant: "primary" | "warning" | "success" | "error"
|
||||
text: string
|
||||
icon: string
|
||||
}
|
||||
> = {
|
||||
pending: { variant: "primary", text: "等待中", icon: "⏳" },
|
||||
processing: { variant: "warning", text: "查重中", icon: "🔄" },
|
||||
completed: { variant: "success", text: "已完成", icon: "✅" },
|
||||
failed: { variant: "error", text: "失败", icon: "❌" },
|
||||
}
|
||||
|
||||
/** 根据查重率获取风险等级 */
|
||||
const getRiskLevel = (rate?: number): "low" | "medium" | "high" => {
|
||||
if (rate === undefined) return "low"
|
||||
if (rate <= 10) return "low"
|
||||
if (rate <= 30) return "medium"
|
||||
return "high"
|
||||
}
|
||||
|
||||
/** 风险等级标签 */
|
||||
const RISK_LABELS: Record<string, string> = {
|
||||
low: "低风险",
|
||||
medium: "中风险",
|
||||
high: "高风险",
|
||||
}
|
||||
|
||||
/** 格式化文件大小 */
|
||||
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 totalSec = Math.round(seconds)
|
||||
const m = Math.floor(totalSec / 60)
|
||||
const s = totalSec % 60
|
||||
return m > 0 ? `${m}分${s}秒` : `${s}秒`
|
||||
}
|
||||
|
||||
/** 简易 toast */
|
||||
interface ToastState {
|
||||
message: string
|
||||
type: "success" | "error" | "warning"
|
||||
}
|
||||
|
||||
const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "low", label: "低风险" },
|
||||
{ key: "medium", label: "中风险" },
|
||||
{ key: "high", label: "高风险" },
|
||||
]
|
||||
import "./duplication.css"
|
||||
import useDuplicationResults from "./hooks/useDuplicationResults"
|
||||
import FilterBar from "./components/FilterBar"
|
||||
import EmptyState from "./components/EmptyState"
|
||||
import ResultCard from "./components/ResultCard"
|
||||
|
||||
const DuplicationResults: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [riskFilter, setRiskFilter] = useState<RiskFilter>("all")
|
||||
const [toast, setToast] = useState<ToastState | null>(null)
|
||||
|
||||
const showToast = (message: string, type: "success" | "error" | "warning") => {
|
||||
setToast({ message, type })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
|
||||
// 获取查重记录
|
||||
const { data: records = [], isLoading } = useQuery({
|
||||
queryKey: ["duplication-records"],
|
||||
queryFn: getDuplicationRecords,
|
||||
})
|
||||
|
||||
// 删除
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteDuplicationRecord,
|
||||
onSuccess: () => {
|
||||
showToast("已删除", "success")
|
||||
queryClient.invalidateQueries({ queryKey: ["duplication-records"] })
|
||||
},
|
||||
onError: () => {
|
||||
showToast("删除失败", "error")
|
||||
},
|
||||
})
|
||||
|
||||
// 重新查重
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: retryDuplication,
|
||||
onSuccess: () => {
|
||||
showToast("已重新提交查重", "success")
|
||||
queryClient.invalidateQueries({ queryKey: ["duplication-records"] })
|
||||
},
|
||||
onError: () => {
|
||||
showToast("重新查重失败", "error")
|
||||
},
|
||||
})
|
||||
|
||||
/** 按风险等级筛选 */
|
||||
const filteredRecords = useMemo(() => {
|
||||
if (riskFilter === "all") return records
|
||||
return records.filter((r) => {
|
||||
if (r.status !== "completed") return riskFilter === "low"
|
||||
return getRiskLevel(r.duplicate_rate) === riskFilter
|
||||
})
|
||||
}, [records, riskFilter])
|
||||
const {
|
||||
isLoading,
|
||||
filteredRecords,
|
||||
riskFilter,
|
||||
setRiskFilter,
|
||||
toast,
|
||||
handleDelete,
|
||||
handleRetry,
|
||||
handleView,
|
||||
handleUpload,
|
||||
} = useDuplicationResults()
|
||||
|
||||
return (
|
||||
<div className="dup-page">
|
||||
@@ -136,136 +33,31 @@ const DuplicationResults: React.FC = () => {
|
||||
title="查重记录"
|
||||
actions={
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
{/* 筛选胶囊 */}
|
||||
<div className="dup-filter">
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
className={`dup-filter-btn ${riskFilter === opt.key ? "active" : ""}`}
|
||||
onClick={() => setRiskFilter(opt.key)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => navigate("/app/duplication")}
|
||||
>
|
||||
<FilterBar value={riskFilter} onChange={setRiskFilter} />
|
||||
<Button buttonType="primary" buttonSize="md" onClick={handleUpload}>
|
||||
📤 上传查重
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 加载中 */}
|
||||
{isLoading && (
|
||||
<div className="dup-results-empty">
|
||||
<div className="dup-results-empty-icon">⏳</div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!isLoading && filteredRecords.length === 0 && (
|
||||
<div className="dup-results-empty">
|
||||
<div className="dup-results-empty-icon">📭</div>
|
||||
<p>
|
||||
{riskFilter === "all"
|
||||
? "暂无查重记录,上传视频开始查重吧"
|
||||
: `没有${RISK_LABELS[riskFilter]}的记录`}
|
||||
</p>
|
||||
</div>
|
||||
{/* 加载中 / 空状态 */}
|
||||
{(isLoading || filteredRecords.length === 0) && (
|
||||
<EmptyState isLoading={isLoading} riskFilter={riskFilter} />
|
||||
)}
|
||||
|
||||
{/* 结果卡片列表 */}
|
||||
{!isLoading && filteredRecords.length > 0 && (
|
||||
<div className="dup-results-list">
|
||||
{filteredRecords.map((record) => {
|
||||
const statusCfg = STATUS_CONFIG[record.status]
|
||||
const riskLevel = getRiskLevel(record.duplicate_rate)
|
||||
const rateValue = record.duplicate_rate
|
||||
|
||||
return (
|
||||
<div
|
||||
key={record.id}
|
||||
className="dup-result-card"
|
||||
onClick={() => {
|
||||
if (record.status === "completed") {
|
||||
navigate(`/duplication/${record.id}`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 缩略图 */}
|
||||
<div className="dup-result-card-thumb">🎬</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="dup-result-card-body">
|
||||
<h4>{record.filename}</h4>
|
||||
<div className="dup-result-card-meta">
|
||||
<Tag variant={statusCfg.variant}>
|
||||
{statusCfg.icon} {statusCfg.text}
|
||||
</Tag>
|
||||
<span>{formatSize(record.file_size)}</span>
|
||||
<span>{formatDuration(record.duration_seconds)}</span>
|
||||
<span>{new Date(record.created_at).toLocaleDateString("zh-CN")}</span>
|
||||
{record.status === "completed" && record.duplicate_count !== undefined && (
|
||||
<span>{record.duplicate_count} 个重复片段</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 查重率 */}
|
||||
<div className="dup-result-card-score">
|
||||
{record.status === "completed" && rateValue !== undefined ? (
|
||||
<>
|
||||
<div className="dup-score-bar">
|
||||
<div
|
||||
className={`dup-score-bar-fill ${riskLevel}`}
|
||||
style={{ width: `${Math.min(rateValue, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`dup-score-value ${riskLevel}`}>
|
||||
{rateValue.toFixed(1)}%
|
||||
</span>
|
||||
</>
|
||||
) : record.status === "failed" ? (
|
||||
<Tooltip title="重新查重">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
retryMutation.mutate(record.id)
|
||||
}}
|
||||
>
|
||||
🔄 重试
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
{record.status === "processing" ? "分析中..." : "—"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (window.confirm("确定删除此记录?")) {
|
||||
deleteMutation.mutate(record.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
🗑️
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{filteredRecords.map((record) => (
|
||||
<ResultCard
|
||||
key={record.id}
|
||||
record={record}
|
||||
onView={handleView}
|
||||
onDelete={handleDelete}
|
||||
onRetry={handleRetry}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from "react"
|
||||
import { RISK_LABELS } from "../constants"
|
||||
import type { RiskFilter } from "../types"
|
||||
|
||||
interface EmptyStateProps {
|
||||
isLoading: boolean
|
||||
riskFilter: RiskFilter
|
||||
}
|
||||
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({ isLoading, riskFilter }) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="dup-results-empty">
|
||||
<div className="dup-results-empty-icon">⏳</div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dup-results-empty">
|
||||
<div className="dup-results-empty-icon">📭</div>
|
||||
<p>
|
||||
{riskFilter === "all"
|
||||
? "暂无查重记录,上传视频开始查重吧"
|
||||
: `没有${RISK_LABELS[riskFilter]}的记录`}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyState
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from "react"
|
||||
import type { RiskFilter } from "../types"
|
||||
import { FILTER_OPTIONS } from "../constants"
|
||||
|
||||
interface FilterBarProps {
|
||||
value: RiskFilter
|
||||
onChange: (value: RiskFilter) => void
|
||||
}
|
||||
|
||||
const FilterBar: React.FC<FilterBarProps> = ({ value, onChange }) => {
|
||||
return (
|
||||
<div className="dup-filter">
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
className={`dup-filter-btn ${value === opt.key ? "active" : ""}`}
|
||||
onClick={() => onChange(opt.key)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterBar
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from "react"
|
||||
import { Button, Tag, Tooltip } from "@/components/ui"
|
||||
import type { DuplicationRecord } from "@/api/duplication"
|
||||
import { STATUS_CONFIG } from "../constants"
|
||||
import { getRiskLevel, formatSize, formatDuration } from "../utils"
|
||||
|
||||
interface ResultCardProps {
|
||||
record: DuplicationRecord
|
||||
onView: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
onRetry: (id: string) => void
|
||||
}
|
||||
|
||||
const ResultCard: React.FC<ResultCardProps> = ({ record, onView, onDelete, onRetry }) => {
|
||||
const statusCfg = STATUS_CONFIG[record.status]
|
||||
const riskLevel = getRiskLevel(record.duplicate_rate)
|
||||
const rateValue = record.duplicate_rate
|
||||
|
||||
const handleClick = () => {
|
||||
if (record.status === "completed") {
|
||||
onView(record.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={record.id} className="dup-result-card" onClick={handleClick}>
|
||||
{/* 缩略图 */}
|
||||
<div className="dup-result-card-thumb">🎬</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="dup-result-card-body">
|
||||
<h4>{record.filename}</h4>
|
||||
<div className="dup-result-card-meta">
|
||||
<Tag variant={statusCfg.variant}>
|
||||
{statusCfg.icon} {statusCfg.text}
|
||||
</Tag>
|
||||
<span>{formatSize(record.file_size)}</span>
|
||||
<span>{formatDuration(record.duration_seconds)}</span>
|
||||
<span>{new Date(record.created_at).toLocaleDateString("zh-CN")}</span>
|
||||
{record.status === "completed" && record.duplicate_count !== undefined && (
|
||||
<span>{record.duplicate_count} 个重复片段</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 查重率 */}
|
||||
<div className="dup-result-card-score">
|
||||
{record.status === "completed" && rateValue !== undefined ? (
|
||||
<>
|
||||
<div className="dup-score-bar">
|
||||
<div
|
||||
className={`dup-score-bar-fill ${riskLevel}`}
|
||||
style={{ width: `${Math.min(rateValue, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`dup-score-value ${riskLevel}`}>{rateValue.toFixed(1)}%</span>
|
||||
</>
|
||||
) : record.status === "failed" ? (
|
||||
<Tooltip title="重新查重">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRetry(record.id)
|
||||
}}
|
||||
>
|
||||
🔄 重试
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
{record.status === "processing" ? "分析中..." : "—"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (window.confirm("确定删除此记录?")) {
|
||||
onDelete(record.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
🗑️
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResultCard
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { DuplicationStatus } from "@/api/duplication"
|
||||
import type { RiskFilter } from "./types"
|
||||
|
||||
/** 状态配置 */
|
||||
export const STATUS_CONFIG: Record<
|
||||
DuplicationStatus,
|
||||
{
|
||||
variant: "primary" | "warning" | "success" | "error"
|
||||
text: string
|
||||
icon: string
|
||||
}
|
||||
> = {
|
||||
pending: { variant: "primary", text: "等待中", icon: "⏳" },
|
||||
processing: { variant: "warning", text: "查重中", icon: "🔄" },
|
||||
completed: { variant: "success", text: "已完成", icon: "✅" },
|
||||
failed: { variant: "error", text: "失败", icon: "❌" },
|
||||
}
|
||||
|
||||
/** 风险等级标签 */
|
||||
export const RISK_LABELS: Record<string, string> = {
|
||||
low: "低风险",
|
||||
medium: "中风险",
|
||||
high: "高风险",
|
||||
}
|
||||
|
||||
export const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "low", label: "低风险" },
|
||||
{ key: "medium", label: "中风险" },
|
||||
{ key: "high", label: "高风险" },
|
||||
]
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState, useMemo } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import {
|
||||
getDuplicationRecords,
|
||||
deleteDuplicationRecord,
|
||||
retryDuplication,
|
||||
type DuplicationRecord,
|
||||
} from "@/api/duplication"
|
||||
import type { RiskFilter, ToastState } from "../types"
|
||||
import { getRiskLevel } from "../utils"
|
||||
|
||||
interface UseDuplicationResultsReturn {
|
||||
records: DuplicationRecord[]
|
||||
isLoading: boolean
|
||||
filteredRecords: DuplicationRecord[]
|
||||
riskFilter: RiskFilter
|
||||
setRiskFilter: (filter: RiskFilter) => void
|
||||
toast: ToastState | null
|
||||
handleDelete: (id: string) => void
|
||||
handleRetry: (id: string) => void
|
||||
handleView: (id: string) => void
|
||||
handleUpload: () => void
|
||||
}
|
||||
|
||||
const useDuplicationResults = (): UseDuplicationResultsReturn => {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [riskFilter, setRiskFilter] = useState<RiskFilter>("all")
|
||||
const [toast, setToast] = useState<ToastState | null>(null)
|
||||
|
||||
const showToast = (message: string, type: "success" | "error" | "warning") => {
|
||||
setToast({ message, type })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
|
||||
// 获取查重记录
|
||||
const { data: records = [], isLoading } = useQuery({
|
||||
queryKey: ["duplication-records"],
|
||||
queryFn: getDuplicationRecords,
|
||||
})
|
||||
|
||||
// 删除
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteDuplicationRecord,
|
||||
onSuccess: () => {
|
||||
showToast("已删除", "success")
|
||||
queryClient.invalidateQueries({ queryKey: ["duplication-records"] })
|
||||
},
|
||||
onError: () => {
|
||||
showToast("删除失败", "error")
|
||||
},
|
||||
})
|
||||
|
||||
// 重新查重
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: retryDuplication,
|
||||
onSuccess: () => {
|
||||
showToast("已重新提交查重", "success")
|
||||
queryClient.invalidateQueries({ queryKey: ["duplication-records"] })
|
||||
},
|
||||
onError: () => {
|
||||
showToast("重新查重失败", "error")
|
||||
},
|
||||
})
|
||||
|
||||
/** 按风险等级筛选 */
|
||||
const filteredRecords = useMemo(() => {
|
||||
if (riskFilter === "all") return records
|
||||
return records.filter((r) => {
|
||||
if (r.status !== "completed") return riskFilter === "low"
|
||||
return getRiskLevel(r.duplicate_rate) === riskFilter
|
||||
})
|
||||
}, [records, riskFilter])
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
deleteMutation.mutate(id)
|
||||
}
|
||||
|
||||
const handleRetry = (id: string) => {
|
||||
retryMutation.mutate(id)
|
||||
}
|
||||
|
||||
const handleView = (id: string) => {
|
||||
navigate(`/duplication/${id}`)
|
||||
}
|
||||
|
||||
const handleUpload = () => {
|
||||
navigate("/app/duplication")
|
||||
}
|
||||
|
||||
return {
|
||||
records,
|
||||
isLoading,
|
||||
filteredRecords,
|
||||
riskFilter,
|
||||
setRiskFilter,
|
||||
toast,
|
||||
handleDelete,
|
||||
handleRetry,
|
||||
handleView,
|
||||
handleUpload,
|
||||
}
|
||||
}
|
||||
|
||||
export default useDuplicationResults
|
||||
@@ -0,0 +1,8 @@
|
||||
/** 风险等级分类 */
|
||||
export type RiskFilter = "all" | "high" | "medium" | "low"
|
||||
|
||||
/** 简易 toast */
|
||||
export interface ToastState {
|
||||
message: string
|
||||
type: "success" | "error" | "warning"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** 根据查重率获取风险等级 */
|
||||
export const getRiskLevel = (rate?: number): "low" | "medium" | "high" => {
|
||||
if (rate === undefined) return "low"
|
||||
if (rate <= 10) return "low"
|
||||
if (rate <= 30) return "medium"
|
||||
return "high"
|
||||
}
|
||||
|
||||
/** 格式化文件大小 */
|
||||
export 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`
|
||||
}
|
||||
|
||||
/** 格式化时长 */
|
||||
export const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return "-"
|
||||
const totalSec = Math.round(seconds)
|
||||
const m = Math.floor(totalSec / 60)
|
||||
const s = totalSec % 60
|
||||
return m > 0 ? `${m}分${s}秒` : `${s}秒`
|
||||
}
|
||||
Reference in New Issue
Block a user