diff --git a/apps/web/src/api/duplication/types.ts b/apps/web/src/api/duplication/types.ts index 1566c79a1..ffb1fc08d 100644 --- a/apps/web/src/api/duplication/types.ts +++ b/apps/web/src/api/duplication/types.ts @@ -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 /** 更新时间 */ diff --git a/apps/web/src/api/products/types.ts b/apps/web/src/api/products/types.ts index 9b4fa758d..8ec79b45e 100644 --- a/apps/web/src/api/products/types.ts +++ b/apps/web/src/api/products/types.ts @@ -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 } diff --git a/apps/web/src/api/products/utils.ts b/apps/web/src/api/products/utils.ts index 1293a6cd2..1fe6d1137 100644 --- a/apps/web/src/api/products/utils.ts +++ b/apps/web/src/api/products/utils.ts @@ -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, } } diff --git a/apps/web/src/pages/duplication/DuplicationDetail.tsx b/apps/web/src/pages/duplication/DuplicationDetail.tsx index c2107adbc..08127489e 100755 --- a/apps/web/src/pages/duplication/DuplicationDetail.tsx +++ b/apps/web/src/pages/duplication/DuplicationDetail.tsx @@ -98,7 +98,7 @@ const DuplicationDetail: React.FC = () => {
- +
) diff --git a/apps/web/src/pages/duplication/components/ResultCard.tsx b/apps/web/src/pages/duplication/components/ResultCard.tsx index 27881f3af..57d5c3de2 100644 --- a/apps/web/src/pages/duplication/components/ResultCard.tsx +++ b/apps/web/src/pages/duplication/components/ResultCard.tsx @@ -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 = ({ record, onView, onDelete, onRet /> {rateValue.toFixed(1)}% + + {RISK_LABELS[riskLevel]} + ) : record.status === "failed" ? ( diff --git a/apps/web/src/pages/duplication/components/SegmentsSection.tsx b/apps/web/src/pages/duplication/components/SegmentsSection.tsx index c6ebaccb3..461bc771d 100644 --- a/apps/web/src/pages/duplication/components/SegmentsSection.tsx +++ b/apps/web/src/pages/duplication/components/SegmentsSection.tsx @@ -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 = ({ segments = [] }) => ( -
-

- 🔍 重复片段详情 - - {segments.length} 个片段 - -

+export const SegmentsSection: React.FC = ({ + segments = [], + totalDuration, +}) => { + const showTimeline = segments.length > 0 && totalDuration !== undefined && totalDuration > 0 - {segments.length > 0 ? ( -
- {segments.map((segment, index) => ( - - ))} -
- ) : ( -
-
🎉
-

未发现重复片段,内容原创度很高

-
- )} -
-) + return ( +
+

+ 🔍 重复片段详情 + + {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") + }) +})