+
+ 🔍 重复片段详情
+
+ {segments.length} 个片段
+
+
+
+ {showTimeline && (
+
+
+ {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 (
+
+ )
+ })}
+
+
+ 0s
+ {formatTime(totalDuration ?? 0)}
+
+
+ )}
+
+ {segments.length > 0 ? (
+
+ {segments.map((segment, index) => (
+
+ ))}
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/apps/web/src/pages/duplication/duplication.css b/apps/web/src/pages/duplication/duplication.css
index b536387fa..5e39f2cce 100644
--- a/apps/web/src/pages/duplication/duplication.css
+++ b/apps/web/src/pages/duplication/duplication.css
@@ -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;
+}
diff --git a/apps/web/src/pages/duplication/utils.ts b/apps/web/src/pages/duplication/utils.ts
index 87b1798b4..d2b80873c 100644
--- a/apps/web/src/pages/duplication/utils.ts
+++ b/apps/web/src/pages/duplication/utils.ts
@@ -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) */
diff --git a/apps/web/src/pages/products/components/ProductInfoPanel.tsx b/apps/web/src/pages/products/components/ProductInfoPanel.tsx
index 738c8ebd1..ae587b0b5 100644
--- a/apps/web/src/pages/products/components/ProductInfoPanel.tsx
+++ b/apps/web/src/pages/products/components/ProductInfoPanel.tsx
@@ -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 = ({ product }) =
查重率
-
+
{(product.duplicate_rate ?? 0) > 0
? `${(product.duplicate_rate ?? 0).toFixed(1)}%`
: "-"}
+ {product.visual_similarity != null && (
+
+ 视觉相似度
+ {product.visual_similarity.toFixed(1)}%
+
+ )}
+ {product.match_count != null && (
+
+ 匹配帧数
+ {product.match_count}
+
+ )}
创建时间
{formatDate(product.created_at ?? "")}
diff --git a/apps/web/src/pages/products/products.css b/apps/web/src/pages/products/products.css
index 58bae02de..eeac5e04a 100644
--- a/apps/web/src/pages/products/products.css
+++ b/apps/web/src/pages/products/products.css
@@ -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;
+}
diff --git a/apps/web/src/test/pages/duplication/utils.test.ts b/apps/web/src/test/pages/duplication/utils.test.ts
new file mode 100644
index 000000000..d87371942
--- /dev/null
+++ b/apps/web/src/test/pages/duplication/utils.test.ts
@@ -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")
+ })
+})