From cf671b98c2178545b538f06ebf96d80630f7db14 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 4 Sep 2026 00:17:12 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=9F=A5=E9=87=8D=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E5=B1=95=E7=A4=BA=E5=8D=87=E7=BA=A7=20=E2=80=94=20?= =?UTF-8?q?=E9=A3=8E=E9=99=A9=E9=98=88=E5=80=BC15/30=20+=20=E8=A7=86?= =?UTF-8?q?=E8=A7=89=E7=9B=B8=E4=BC=BC=E5=BA=A6/=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E5=B8=A7=E6=95=B0=20+=20=E7=89=87=E6=AE=B5=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E8=BD=B4=20#1662?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getRiskLevel 阈值调整为 <15 低风险 / 15-30 中风险 / >30 高风险 - DuplicationRecord / ProductItem / VideoItem 新增 visual_similarity、match_count 可选字段 - mapVideoToProductItem 透传新字段 - ResultCard 查重率数字旁新增风险等级 Tag - SegmentsSection 新增片段时间轴可视化条(纯CSS,按相似度着色,hover tooltip) - ProductInfoPanel 查重率按风险着色 + 视觉相似度/匹配帧数展示 - 新增 getRiskLevel 边界单测(14.9/15/30/30.1) --- apps/web/src/api/duplication/types.ts | 4 + apps/web/src/api/products/types.ts | 8 ++ apps/web/src/api/products/utils.ts | 2 + .../pages/duplication/DuplicationDetail.tsx | 2 +- .../duplication/components/ResultCard.tsx | 5 +- .../components/SegmentsSection.tsx | 93 ++++++++++++++----- .../web/src/pages/duplication/duplication.css | 58 ++++++++++++ apps/web/src/pages/duplication/utils.ts | 6 +- .../products/components/ProductInfoPanel.tsx | 17 +++- apps/web/src/pages/products/products.css | 16 ++++ .../src/test/pages/duplication/utils.test.ts | 29 ++++++ 11 files changed, 211 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/test/pages/duplication/utils.test.ts 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..47024b34b 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 !== undefined && ( +
+ 视觉相似度 + {product.visual_similarity.toFixed(1)}% +
+ )} + {product.match_count !== undefined && ( +
+ 匹配帧数 + {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") + }) +}) -- 2.54.0 From f24e527e0b722355408f649f5402018237473ed4 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 4 Sep 2026 00:25:04 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(products):=20visual=5Fsimilarity/match?= =?UTF-8?q?=5Fcount=20=E7=A9=BA=E5=80=BC=E6=A3=80=E6=9F=A5=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=20null=EF=BC=88AI=20Review=20=E9=98=BB=E5=A1=9E?= =?UTF-8?q?=E9=A1=B9=E4=BF=AE=E5=A4=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 JSON 可能返回 null,!== undefined 无法拦截,改为 != null 同时检查 null/undefined --- apps/web/src/pages/products/components/ProductInfoPanel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/products/components/ProductInfoPanel.tsx b/apps/web/src/pages/products/components/ProductInfoPanel.tsx index 47024b34b..ae587b0b5 100644 --- a/apps/web/src/pages/products/components/ProductInfoPanel.tsx +++ b/apps/web/src/pages/products/components/ProductInfoPanel.tsx @@ -53,13 +53,13 @@ export const ProductInfoPanel: React.FC = ({ product }) = : "-"}
- {product.visual_similarity !== undefined && ( + {product.visual_similarity != null && (
视觉相似度 {product.visual_similarity.toFixed(1)}%
)} - {product.match_count !== undefined && ( + {product.match_count != null && (
匹配帧数 {product.match_count} -- 2.54.0