Compare commits

...

1 Commits

Author SHA1 Message Date
xiaoxia 078569cf6c refactor(duplication-detail): 三阶段重构 DuplicationDetail(323→110行, -66%)
CI/CD Pipeline / CI Gate (pull_request) CI运行中 [frontend-only]
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 9s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 35s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m17s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m21s
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 49s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m2s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m49s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 45s
AI Code Review / AI Code Review (pull_request) Successful in 1m23s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m4s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m51s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 15m46s
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 / Frontend Unit Tests (pull_request) Failing after 22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 7s
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
- Phase 1: 抽 constants(RISK_DESC/RISK_LABEL/ToastState)+ utils(formatTime/formatSize/formatDuration/getRiskLevel)
- Phase 2: 抽 4 个 UI 组件(RiskCard/InfoCard/SegmentsSection/SegmentCard)
- Phase 3: 抽 useDuplicationDetail 业务 Hook
- 主文件 323→110 行,职责清晰
2026-07-27 16:07:48 +08:00
8 changed files with 303 additions and 240 deletions
+23 -239
View File
@@ -3,137 +3,29 @@
* 风险评估 + 基本信息 + 检测项列表 + 匹配片段
* 零 antd 依赖
*/
import React, { useState, useCallback } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Button, Tag, Tooltip } from "@/components/ui"
import { useParams, useNavigate } from "react-router-dom"
import { getDuplicationDetail, retryDuplication, type DuplicateSegment } from "@/api/duplication"
import "./duplication.css"
import React from "react"
import { Button, Tag } from "@/components/ui"
import PageHead from "@/components/layout/PageHead"
/** 格式化时间(秒 → 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 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}`
}
/** 根据查重率获取风险等级 */
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_DESC: Record<string, string> = {
low: "查重率较低,内容原创度高",
medium: "存在一定重复,建议修改部分片段",
high: "重复率较高,建议大幅修改或替换",
}
/** 风险等级标签变体 */
const RISK_TAG_VARIANT: Record<string, "success" | "warning" | "error"> = {
low: "success",
medium: "warning",
high: "error",
}
/** 风险等级文字 */
const RISK_LABEL: Record<string, string> = {
low: "低风险",
medium: "中风险",
high: "高风险",
}
/** 简易 toast */
interface ToastState {
message: string
type: "success" | "error" | "warning"
}
/** 单个重复片段卡片 */
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
const riskLevel = segment.similarity >= 90 ? "high" : segment.similarity >= 70 ? "medium" : "low"
return (
<div className="dup-check-item">
<div className="dup-check-icon">🎬</div>
<div className="dup-check-body">
<h4>
{index + 1}{segment.matched_video_name}
</h4>
<p>
{formatTime(segment.source_start)} - {formatTime(segment.source_end)}
{sourceDuration.toFixed(0)}s {formatTime(segment.matched_start)} -{" "}
{formatTime(segment.matched_end)}{matchedDuration.toFixed(0)}s
</p>
</div>
<div className={`dup-check-bar`}>
<div
className={`dup-check-bar-fill ${riskLevel}`}
style={{ width: `${Math.min(segment.similarity, 100)}%` }}
/>
</div>
<span className={`dup-check-value ${riskLevel}`}>{segment.similarity.toFixed(1)}%</span>
</div>
)
}
import { RiskCard } from "./components/RiskCard"
import { InfoCard } from "./components/InfoCard"
import { SegmentsSection } from "./components/SegmentsSection"
import { useDuplicationDetail } from "./hooks/useDuplicationDetail"
import { RISK_TAG_VARIANT, RISK_LABEL } from "./constants"
import { formatSize, formatDuration } from "./utils"
import "./duplication.css"
const DuplicationDetail: React.FC = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [toast, setToast] = useState<ToastState | null>(null)
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
setToast({ message, type })
setTimeout(() => setToast(null), 3000)
}, [])
const {
data: detail,
detail,
isLoading,
isError,
} = useQuery({
queryKey: ["duplication-detail", id],
queryFn: () => getDuplicationDetail(id!),
enabled: !!id,
})
// 重新查重
const retryMutation = useMutation({
mutationFn: retryDuplication,
onSuccess: () => {
showToast("已重新提交查重", "success")
queryClient.invalidateQueries({ queryKey: ["duplication-detail", id] })
},
onError: () => {
showToast("重新查重失败", "error")
},
})
toast,
riskLevel,
similarityPercent,
handleRetry,
handleDownloadReport,
handleBack,
} = useDuplicationDetail()
if (isLoading) {
return (
@@ -155,7 +47,7 @@ const DuplicationDetail: React.FC = () => {
<Button
buttonType="primary"
buttonSize="md"
onClick={() => navigate("/app/duplication/results")}
onClick={handleBack}
style={{ marginTop: 16 }}
>
@@ -165,10 +57,6 @@ const DuplicationDetail: React.FC = () => {
)
}
const riskLevel = getRiskLevel(detail.duplicate_rate)
const similarityPercent =
detail.duplicate_rate !== undefined ? detail.duplicate_rate.toFixed(1) : "—"
return (
<div className="dup-page">
{/* Toast */}
@@ -194,21 +82,11 @@ const DuplicationDetail: React.FC = () => {
}
actions={
<div className="dup-detail-actions" style={{ display: "flex", gap: 8 }}>
<Button
buttonType="secondary"
buttonSize="md"
onClick={() => {
showToast("报告下载功能开发中", "warning")
}}
>
<Button buttonType="secondary" buttonSize="md" onClick={handleDownloadReport}>
📥
</Button>
{detail.status === "failed" && (
<Button
buttonType="primary"
buttonSize="md"
onClick={() => retryMutation.mutate(detail.id)}
>
<Button buttonType="primary" buttonSize="md" onClick={handleRetry}>
🔄
</Button>
)}
@@ -218,103 +96,9 @@ const DuplicationDetail: React.FC = () => {
{/* 内容网格 */}
<div className="dup-detail-grid">
{/* 风险评估卡片 */}
<div className="dup-risk-card">
<h3>📊 </h3>
<div className={`dup-risk-circle ${riskLevel}`}>
<span className="dup-risk-value">{similarityPercent}%</span>
<span className="dup-risk-label"></span>
</div>
<p className="dup-risk-desc">{RISK_DESC[riskLevel]}</p>
</div>
{/* 基本信息卡片 */}
<div className="dup-info-detail-card">
<h3>📋 </h3>
<div className="dup-info-rows">
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<Tooltip title={detail.filename}>
<span
className="dup-info-row-value"
style={{
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{detail.filename}
</span>
</Tooltip>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">{formatSize(detail.file_size)}</span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">{formatDuration(detail.duration_seconds)}</span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">
<Tag
variant={
detail.status === "completed"
? "success"
: detail.status === "failed"
? "error"
: detail.status === "processing"
? "warning"
: "info"
}
>
{detail.status === "completed"
? "✅ 已完成"
: detail.status === "failed"
? "❌ 失败"
: detail.status === "processing"
? "🔄 查重中"
: "⏳ 等待中"}
</Tag>
</span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">{detail.duplicate_count ?? 0} </span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">
{new Date(detail.created_at).toLocaleString("zh-CN")}
</span>
</div>
</div>
</div>
{/* 检测项列表 */}
<div className="dup-checks-section">
<h3>
🔍
<Tag variant="primary" style={{ marginLeft: 8 }}>
{detail.segments?.length ?? 0}
</Tag>
</h3>
{detail.segments && detail.segments.length > 0 ? (
<div className="dup-checks-list">
{detail.segments.map((segment, index) => (
<SegmentCard key={segment.id} segment={segment} index={index} />
))}
</div>
) : (
<div className="dup-results-empty" style={{ padding: "32px 0" }}>
<div className="dup-results-empty-icon">🎉</div>
<p></p>
</div>
)}
</div>
<RiskCard riskLevel={riskLevel} similarityPercent={similarityPercent} />
<InfoCard detail={detail} />
<SegmentsSection segments={detail.segments} />
</div>
</div>
)
@@ -0,0 +1,72 @@
import React from "react"
import { Tag, Tooltip } from "@/components/ui"
import { formatSize, formatDuration } from "../utils"
import type { DuplicationDetail } from "@/api/duplication"
interface InfoCardProps {
detail: DuplicationDetail
}
/**
* 基本信息卡片
*/
export const InfoCard: React.FC<InfoCardProps> = ({ detail }) => {
const statusMap: Record<string, { text: string; variant: string }> = {
completed: { text: "✅ 已完成", variant: "success" },
failed: { text: "❌ 失败", variant: "error" },
processing: { text: "🔄 查重中", variant: "warning" },
pending: { text: "⏳ 等待中", variant: "info" },
}
const status = statusMap[detail.status] || {
text: detail.status,
variant: "info",
}
return (
<div className="dup-info-detail-card">
<h3>📋 </h3>
<div className="dup-info-rows">
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<Tooltip title={detail.filename}>
<span
className="dup-info-row-value"
style={{
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{detail.filename}
</span>
</Tooltip>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">{formatSize(detail.file_size)}</span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">{formatDuration(detail.duration_seconds)}</span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">
<Tag variant={status.variant as "success"}>{status.text}</Tag>
</span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">{detail.duplicate_count ?? 0} </span>
</div>
<div className="dup-info-row">
<span className="dup-info-row-label"></span>
<span className="dup-info-row-value">
{new Date(detail.created_at).toLocaleString("zh-CN")}
</span>
</div>
</div>
</div>
)
}
@@ -0,0 +1,21 @@
import React from "react"
import { RISK_DESC } from "../constants"
interface RiskCardProps {
riskLevel: string
similarityPercent: string
}
/**
* 风险评估卡片
*/
export const RiskCard: React.FC<RiskCardProps> = ({ riskLevel, similarityPercent }) => (
<div className="dup-risk-card">
<h3>📊 </h3>
<div className={`dup-risk-circle ${riskLevel}`}>
<span className="dup-risk-value">{similarityPercent}%</span>
<span className="dup-risk-label"></span>
</div>
<p className="dup-risk-desc">{RISK_DESC[riskLevel]}</p>
</div>
)
@@ -0,0 +1,40 @@
import React from "react"
import type { DuplicateSegment } from "@/api/duplication"
import { formatTime } from "../utils"
interface SegmentCardProps {
segment: DuplicateSegment
index: number
}
/**
* 单个重复片段卡片
*/
export const SegmentCard: React.FC<SegmentCardProps> = ({ segment, index }) => {
const sourceDuration = segment.source_end - segment.source_start
const matchedDuration = segment.matched_end - segment.matched_start
const riskLevel = segment.similarity >= 90 ? "high" : segment.similarity >= 70 ? "medium" : "low"
return (
<div className="dup-check-item">
<div className="dup-check-icon">🎬</div>
<div className="dup-check-body">
<h4>
{index + 1}{segment.matched_video_name}
</h4>
<p>
{formatTime(segment.source_start)} - {formatTime(segment.source_end)}
{sourceDuration.toFixed(0)}s {formatTime(segment.matched_start)} -{" "}
{formatTime(segment.matched_end)}{matchedDuration.toFixed(0)}s
</p>
</div>
<div className={`dup-check-bar`}>
<div
className={`dup-check-bar-fill ${riskLevel}`}
style={{ width: `${Math.min(segment.similarity, 100)}%` }}
/>
</div>
<span className={`dup-check-value ${riskLevel}`}>{segment.similarity.toFixed(1)}%</span>
</div>
)
}
@@ -0,0 +1,35 @@
import React from "react"
import { Tag } from "@/components/ui"
import type { DuplicateSegment } from "@/api/duplication"
import { SegmentCard } from "./SegmentCard"
interface SegmentsSectionProps {
segments?: DuplicateSegment[]
}
/**
* 重复片段列表区域
*/
export const SegmentsSection: React.FC<SegmentsSectionProps> = ({ segments = [] }) => (
<div className="dup-checks-section">
<h3>
🔍
<Tag variant="primary" style={{ marginLeft: 8 }}>
{segments.length}
</Tag>
</h3>
{segments.length > 0 ? (
<div className="dup-checks-list">
{segments.map((segment, index) => (
<SegmentCard key={segment.id} segment={segment} index={index} />
))}
</div>
) : (
<div className="dup-results-empty" style={{ padding: "32px 0" }}>
<div className="dup-results-empty-icon">🎉</div>
<p></p>
</div>
)}
</div>
)
+28 -1
View File
@@ -16,7 +16,28 @@ export const STATUS_CONFIG: Record<
failed: { variant: "error", text: "失败", icon: "❌" },
}
/** 风险等级标签 */
/** 风险等级描述 */
export const RISK_DESC: Record<string, string> = {
low: "查重率较低,内容原创度高",
medium: "存在一定重复,建议修改部分片段",
high: "重复率较高,建议大幅修改或替换",
}
/** 风险等级标签变体 */
export const RISK_TAG_VARIANT: Record<string, "success" | "warning" | "error"> = {
low: "success",
medium: "warning",
high: "error",
}
/** 风险等级文字(详情页用) */
export const RISK_LABEL: Record<string, string> = {
low: "低风险",
medium: "中风险",
high: "高风险",
}
/** 风险等级标签(列表页用) */
export const RISK_LABELS: Record<string, string> = {
low: "低风险",
medium: "中风险",
@@ -29,3 +50,9 @@ export const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [
{ key: "medium", label: "中风险" },
{ key: "high", label: "高风险" },
]
/** Toast 类型 */
export interface ToastState {
message: string
type: "success" | "error" | "warning"
}
@@ -0,0 +1,77 @@
import { useState, useCallback } from "react"
import { useParams, useNavigate } from "react-router-dom"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { getDuplicationDetail, retryDuplication } from "@/api/duplication"
import type { ToastState } from "../constants"
import { getRiskLevel } from "../utils"
/**
* 查重详情业务 Hook
*/
export const useDuplicationDetail = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [toast, setToast] = useState<ToastState | null>(null)
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
setToast({ message, type })
setTimeout(() => setToast(null), 3000)
}, [])
const {
data: detail,
isLoading,
isError,
} = useQuery({
queryKey: ["duplication-detail", id],
queryFn: () => getDuplicationDetail(id!),
enabled: !!id,
})
// 重新查重
const retryMutation = useMutation({
mutationFn: retryDuplication,
onSuccess: () => {
showToast("已重新提交查重", "success")
queryClient.invalidateQueries({ queryKey: ["duplication-detail", id] })
},
onError: () => {
showToast("重新查重失败", "error")
},
})
const handleRetry = useCallback(() => {
if (!detail) return
retryMutation.mutate(detail.id)
}, [detail, retryMutation])
const handleDownloadReport = useCallback(() => {
showToast("报告下载功能开发中", "warning")
}, [showToast])
const handleBack = useCallback(() => {
navigate("/app/duplication/results")
}, [navigate])
const riskLevel = detail ? getRiskLevel(detail.duplicate_rate) : "low"
const similarityPercent =
detail?.duplicate_rate !== undefined ? detail.duplicate_rate.toFixed(1) : "—"
return {
// 数据
detail,
isLoading,
isError,
// 状态
toast,
riskLevel,
similarityPercent,
retryLoading: retryMutation.isPending,
// 操作
showToast,
handleRetry,
handleDownloadReport,
handleBack,
}
}
+7
View File
@@ -6,6 +6,13 @@ export const getRiskLevel = (rate?: number): "low" | "medium" | "high" => {
return "high"
}
/** 格式化时间(秒 → mm:ss */
export 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")}`
}
/** 格式化文件大小 */
export const formatSize = (bytes: number) => {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`