feat: 查重结果展示升级 — 风险阈值15/30 + 视觉相似度/匹配帧数 + 片段时间轴 #1662 #1676
@@ -20,6 +20,10 @@ export interface DuplicationRecord {
|
||||
duplicate_rate?: number
|
||||
/** 重复片段数 */
|
||||
duplicate_count?: number
|
||||
/** 视觉相似度(0-100),#1660 新增 */
|
||||
visual_similarity?: number
|
||||
/** 匹配帧数,#1660 新增 */
|
||||
match_count?: number
|
||||
/** 创建时间 */
|
||||
created_at: string
|
||||
/** 更新时间 */
|
||||
|
||||
@@ -23,6 +23,10 @@ export interface ProductItem {
|
||||
project_name?: string
|
||||
/** 查重率(百分比) */
|
||||
duplicate_rate?: number
|
||||
/** 视觉相似度(0-100),#1660 新增 */
|
||||
visual_similarity?: number
|
||||
/** 匹配帧数,#1660 新增 */
|
||||
match_count?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
@@ -72,4 +76,8 @@ export interface VideoItem {
|
||||
download_url: string
|
||||
generated_at: string
|
||||
duplicate_rate?: number
|
||||
/** 视觉相似度(0-100),#1660 新增 */
|
||||
visual_similarity?: number
|
||||
/** 匹配帧数,#1660 新增 */
|
||||
match_count?: number
|
||||
}
|
||||
|
||||
@@ -30,5 +30,7 @@ export function mapVideoToProductItem(video: VideoItem): ProductItem {
|
||||
created_at: video.generated_at,
|
||||
updated_at: video.generated_at,
|
||||
duplicate_rate: video.duplicate_rate,
|
||||
visual_similarity: video.visual_similarity,
|
||||
match_count: video.match_count,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ const DuplicationDetail: React.FC = () => {
|
||||
<div className="dup-detail-grid">
|
||||
<RiskCard riskLevel={riskLevel} similarityPercent={similarityPercent} />
|
||||
<InfoCard detail={detail} />
|
||||
<SegmentsSection segments={detail.segments} />
|
||||
<SegmentsSection segments={detail.segments} totalDuration={detail.duration_seconds} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react"
|
||||
import { Button, Tag, Tooltip } from "@/components/ui"
|
||||
import type { DuplicationRecord } from "@/api/duplication"
|
||||
import { STATUS_CONFIG } from "../constants"
|
||||
import { STATUS_CONFIG, RISK_TAG_VARIANT, RISK_LABELS } from "../constants"
|
||||
import { getRiskLevel, formatSize, formatDuration } from "../utils"
|
||||
|
||||
interface ResultCardProps {
|
||||
@@ -54,6 +54,9 @@ const ResultCard: React.FC<ResultCardProps> = ({ record, onView, onDelete, onRet
|
||||
/>
|
||||
</div>
|
||||
<span className={`dup-score-value ${riskLevel}`}>{rateValue.toFixed(1)}%</span>
|
||||
<Tag variant={RISK_TAG_VARIANT[riskLevel]} className="dup-score-risk-tag">
|
||||
{RISK_LABELS[riskLevel]}
|
||||
</Tag>
|
||||
</>
|
||||
) : record.status === "failed" ? (
|
||||
<Tooltip title="重新查重">
|
||||
|
||||
@@ -2,34 +2,81 @@ import React from "react"
|
||||
import { Tag } from "@/components/ui"
|
||||
import type { DuplicateSegment } from "@/api/duplication"
|
||||
import { SegmentCard } from "./SegmentCard"
|
||||
import { formatTime } from "../utils"
|
||||
|
||||
interface SegmentsSectionProps {
|
||||
segments?: DuplicateSegment[]
|
||||
/** 视频总时长(秒),用于渲染时间轴 */
|
||||
totalDuration?: number
|
||||
}
|
||||
|
||||
/** 片段相似度 → 风险等级(时间轴配色用) */
|
||||
const getSegmentRisk = (similarity: number): "low" | "medium" | "high" => {
|
||||
if (similarity >= 90) return "high"
|
||||
if (similarity >= 70) return "medium"
|
||||
return "low"
|
||||
}
|
||||
|
||||
/**
|
||||
* 重复片段列表区域
|
||||
* 重复片段列表区域(含时间轴可视化)
|
||||
*/
|
||||
export const SegmentsSection: React.FC<SegmentsSectionProps> = ({ segments = [] }) => (
|
||||
<div className="dup-checks-section">
|
||||
<h3>
|
||||
🔍 重复片段详情
|
||||
<Tag variant="primary" style={{ marginLeft: 8 }}>
|
||||
{segments.length} 个片段
|
||||
</Tag>
|
||||
</h3>
|
||||
export const SegmentsSection: React.FC<SegmentsSectionProps> = ({
|
||||
segments = [],
|
||||
totalDuration,
|
||||
}) => {
|
||||
const showTimeline = segments.length > 0 && totalDuration !== undefined && totalDuration > 0
|
||||
|
||||
{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>
|
||||
)
|
||||
return (
|
||||
<div className="dup-checks-section">
|
||||
<h3>
|
||||
🔍 重复片段详情
|
||||
<Tag variant="primary" style={{ marginLeft: 8 }}>
|
||||
{segments.length} 个片段
|
||||
</Tag>
|
||||
</h3>
|
||||
|
||||
{showTimeline && (
|
||||
<div className="dup-timeline">
|
||||
<div className="dup-timeline-bar">
|
||||
{segments.map((seg, i) => {
|
||||
const left = (seg.source_start / totalDuration) * 100
|
||||
const width = Math.max(
|
||||
((seg.source_end - seg.source_start) / totalDuration) * 100,
|
||||
0.5,
|
||||
)
|
||||
const segRisk = getSegmentRisk(seg.similarity)
|
||||
return (
|
||||
<div
|
||||
key={seg.id ?? i}
|
||||
className={`dup-timeline-segment ${segRisk}`}
|
||||
style={{
|
||||
left: `${Math.min(left, 100)}%`,
|
||||
width: `${Math.min(width, 100 - Math.min(left, 100))}%`,
|
||||
}}
|
||||
title={`${formatTime(seg.source_start)} - ${formatTime(seg.source_end)} · 相似度 ${seg.similarity.toFixed(0)}% · ${seg.matched_video_name}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="dup-timeline-labels">
|
||||
<span>0s</span>
|
||||
<span>{formatTime(totalDuration ?? 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -831,3 +831,61 @@
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
查重率风险标签(列表卡片)
|
||||
============================================================ */
|
||||
.dup-score-risk-tag {
|
||||
flex-shrink: 0;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
重复片段时间轴可视化(#1662)
|
||||
============================================================ */
|
||||
.dup-timeline {
|
||||
margin: 16px 0;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.dup-timeline-bar {
|
||||
position: relative;
|
||||
height: 24px;
|
||||
background: var(--bg-secondary, #f1f5f9);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dup-timeline-segment {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
height: 20px;
|
||||
border-radius: 3px;
|
||||
opacity: 0.8;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.dup-timeline-segment:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dup-timeline-segment.low {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.dup-timeline-segment.medium {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.dup-timeline-segment.high {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.dup-timeline-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/** 根据查重率获取风险等级 */
|
||||
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"
|
||||
if (rate < 15) return "low" // <15% 绿色(安全)
|
||||
if (rate <= 30) return "medium" // 15-30% 黄色(注意)
|
||||
return "high" // >30% 红色(危险)
|
||||
}
|
||||
|
||||
/** 格式化时间(秒 → mm:ss) */
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react"
|
||||
import type { ProductItem } from "../../../api/products"
|
||||
import { STATUS_MAP } from "../constants"
|
||||
import { formatDuration, formatFileSize, formatDate } from "../detailUtils"
|
||||
import { getRiskLevel } from "../../duplication/utils"
|
||||
|
||||
interface ProductInfoPanelProps {
|
||||
product: ProductItem
|
||||
@@ -44,12 +45,26 @@ export const ProductInfoPanel: React.FC<ProductInfoPanelProps> = ({ product }) =
|
||||
</div>
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">查重率</span>
|
||||
<span className="xx-detail-meta-value">
|
||||
<span
|
||||
className={`xx-detail-meta-value dup-risk-text dup-risk-${getRiskLevel(product.duplicate_rate)}`}
|
||||
>
|
||||
{(product.duplicate_rate ?? 0) > 0
|
||||
? `${(product.duplicate_rate ?? 0).toFixed(1)}%`
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
{product.visual_similarity != null && (
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">视觉相似度</span>
|
||||
<span className="xx-detail-meta-value">{product.visual_similarity.toFixed(1)}%</span>
|
||||
</div>
|
||||
)}
|
||||
{product.match_count != null && (
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">匹配帧数</span>
|
||||
<span className="xx-detail-meta-value">{product.match_count}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">创建时间</span>
|
||||
<span className="xx-detail-meta-value">{formatDate(product.created_at ?? "")}</span>
|
||||
|
||||
@@ -1076,3 +1076,19 @@
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
}
|
||||
|
||||
/* 查重率风险颜色(#1662) */
|
||||
.xx-detail-meta-value.dup-risk-low {
|
||||
color: var(--success-color, #22c55e);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.xx-detail-meta-value.dup-risk-medium {
|
||||
color: var(--warning-color, #f59e0b);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.xx-detail-meta-value.dup-risk-high {
|
||||
color: var(--error-color, #ef4444);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { getRiskLevel } from "@/pages/duplication/utils"
|
||||
|
||||
describe("getRiskLevel (#1662 阈值 <15 / 15-30 / >30)", () => {
|
||||
it("undefined 返回 low(兼容无数据)", () => {
|
||||
expect(getRiskLevel(undefined)).toBe("low")
|
||||
})
|
||||
|
||||
it("<15% 为低风险", () => {
|
||||
expect(getRiskLevel(0)).toBe("low")
|
||||
expect(getRiskLevel(10)).toBe("low")
|
||||
expect(getRiskLevel(14.9)).toBe("low")
|
||||
})
|
||||
|
||||
it("15% 边界为中风险", () => {
|
||||
expect(getRiskLevel(15)).toBe("medium")
|
||||
})
|
||||
|
||||
it("15-30% 为中风险", () => {
|
||||
expect(getRiskLevel(20)).toBe("medium")
|
||||
expect(getRiskLevel(30)).toBe("medium")
|
||||
})
|
||||
|
||||
it(">30% 为高风险", () => {
|
||||
expect(getRiskLevel(30.1)).toBe("high")
|
||||
expect(getRiskLevel(80)).toBe("high")
|
||||
expect(getRiskLevel(100)).toBe("high")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user