Compare commits

..

4 Commits

Author SHA1 Message Date
xiaoxia e82071f98e fix: 统一 ResultCard id 参数类型为 string
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m14s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m57s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 47s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m3s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m46s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m39s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 9s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 39s
AI Code Review / AI Code Review (pull_request) Successful in 1m25s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 37s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 15m11s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 38s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 14s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-27 12:42:59 +08:00
xiaoxia aa9a44b7b8 fix: 统一 id 参数类型为 string,修复 TS2345 类型不兼容
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 37s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 39s
2026-07-27 12:42:57 +08:00
xiaoxia 12770b397f Merge branch 'develop' into refactor/duplication-results-split
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 20s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m43s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m55s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 43s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m34s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m48s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m3s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 37s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 58s
AI Code Review / AI Code Review (pull_request) Successful in 2m0s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 6m14s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
2026-07-27 12:33:35 +08:00
xiaoxia abc5877bf6 refactor: 拆分 DuplicationResults 页面(275→67行,-76%)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1m12s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m16s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m3s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m29s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m44s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 44s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m24s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m18s
AI Code Review / AI Code Review (pull_request) Successful in 1m46s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 43s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m47s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 7m23s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 24s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
- 抽 types/constants/utils 为独立文件
- 抽 3 个 UI 组件:FilterBar / EmptyState / ResultCard
- 抽业务 Hook:useDuplicationResults
- 主文件精简为纯组合层,保持导入路径向后兼容
2026-07-27 09:39:38 +08:00
13 changed files with 596 additions and 512 deletions
+243
View File
@@ -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",
}
-36
View File
@@ -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
}
-138
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
/** 风险等级分类 */
export type RiskFilter = "all" | "high" | "medium" | "low"
/** 简易 toast */
export interface ToastState {
message: string
type: "success" | "error" | "warning"
}
+23
View File
@@ -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}`
}