From 80430c674b94c06660154eb76f441f28bae92600 Mon Sep 17 00:00:00 2001 From: Dang Zerong Date: Fri, 13 Mar 2026 12:39:15 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=20=E8=B4=A8=E9=87=8F?= =?UTF-8?q?=E9=97=A8=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/QualityGate.tsx | 361 ++++++++++++++++++++++++++++++++------ 1 file changed, 305 insertions(+), 56 deletions(-) diff --git a/src/pages/QualityGate.tsx b/src/pages/QualityGate.tsx index 1678749..7bc6410 100644 --- a/src/pages/QualityGate.tsx +++ b/src/pages/QualityGate.tsx @@ -31,22 +31,216 @@ type ChangedFile = { deletions: number; }; -type FileContent = { - content: string; - issues: Array<{ - line: number; - severity: string; - message: string; - rule?: string; - }>; +type FileContentIssue = { + line: number; + severity: string; + message: string; + rule?: string; + scanner?: string; }; +type FileContent = { + content: string; + issues: FileContentIssue[]; +}; + +function normalizeFileContent(data: { + content?: string; + issues?: Array<{ line?: number; severity?: string; message?: string; description?: string; rule?: string; scanner?: string }>; + scan_issues?: Array<{ line?: number; severity?: string; message?: string; description?: string; rule?: string; scanner?: string }>; +}): FileContent { + const raw = data.scan_issues ?? data.issues ?? []; + const issues: FileContentIssue[] = raw.map((iss) => { + const line = typeof iss.line === "number" ? iss.line : typeof iss.line === "string" ? parseInt(iss.line, 10) : 1; + return { + line: Number.isFinite(line) ? line : 1, + severity: String(iss.severity ?? "info").toLowerCase(), + message: String(iss.message ?? iss.description ?? ""), + rule: iss.rule, + scanner: iss.scanner, + }; + }); + return { + content: data.content ?? "", + issues, + }; +} + type View = "dashboard" | "pr-list" | "settings"; +type PRHistoryItem = { + pr_number: number; + error_count?: number; + warning_count?: number; + total_issues?: number; +}; + type QualityGateProps = { view: View; }; +const TREND_SLOTS = 15; +const TREND_PX_PER_PR = 80; +const TREND_CHART_HEIGHT = 220; + +function ProblemTrendChart({ + history, + loading, +}: { + history: PRHistoryItem[]; + loading: boolean; +}) { + if (loading) { + return ( +
+

问题趋势

+
加载趋势数据中...
+
+ ); + } + if (!history || history.length === 0) { + return ( +
+

问题趋势

+
暂无趋势数据
+
+ ); + } + + const n = history.length; + const pad = Math.max(0, TREND_SLOTS - n); + const labels = history.map((p) => `#${p.pr_number}`).concat(Array(pad).fill("")); + const errorData: (number | null)[] = history.map((p) => p.error_count ?? 0).concat(Array(pad).fill(null)); + const warningData: (number | null)[] = history.map((p) => p.warning_count ?? 0).concat(Array(pad).fill(null)); + + const width = TREND_SLOTS * TREND_PX_PER_PR; + const height = TREND_CHART_HEIGHT; + const padding = { top: 28, right: 24, bottom: 32, left: 40 }; + const chartW = width - padding.left - padding.right; + const chartH = height - padding.top - padding.bottom; + + const maxVal = Math.max( + 1, + ...errorData.filter((v): v is number => v != null), + ...warningData.filter((v): v is number => v != null) + ); + const yMax = Math.ceil(maxVal / 2) * 2 || 2; + const yTicks = Array.from({ length: yMax + 1 }, (_, i) => i); + + const xScale = (i: number) => padding.left + (i / (labels.length - 1 || 1)) * chartW; + const yScale = (v: number) => padding.top + chartH - (v / yMax) * chartH; + + const toPath = (data: (number | null)[]) => { + const parts: string[] = []; + for (let i = 0; i < data.length; i++) { + const v = data[i]; + if (v == null) continue; + const x = xScale(i); + const y = yScale(v); + parts.push(parts.length === 0 ? `M ${x} ${y}` : `L ${x} ${y}`); + } + return parts.join(" "); + }; + + const toAreaPath = (data: (number | null)[]) => { + const points: { i: number; v: number }[] = []; + data.forEach((v, i) => { + if (v != null) points.push({ i, v }); + }); + if (points.length === 0) return ""; + const linePath = points.map(({ i, v }) => `${i === 0 ? "M" : "L"} ${xScale(i)} ${yScale(v)}`).join(" "); + const lastX = xScale(points[points.length - 1].i); + const baseY = padding.top + chartH; + const startX = xScale(points[0].i); + return `${linePath} L ${lastX} ${baseY} L ${startX} ${baseY} Z`; + }; + + const errorPath = toPath(errorData); + const warningPath = toPath(warningData); + const errorAreaPath = toAreaPath(errorData); + const warningAreaPath = toAreaPath(warningData); + + return ( +
+

问题趋势

+
+
+ + {/* 网格线 */} + {yTicks.map((tick, i) => ( + + ))} + {labels.map((_, i) => ( + + ))} + {/* 图例 */} + + + + 错误 + + + + 警告 + + + {/* 面积填充 */} + + + {/* 折线 */} + + + {/* Y 轴刻度 */} + {yTicks.map((tick) => ( + + {tick} + + ))} + {/* X 轴刻度 */} + {labels.map((label, i) => ( + + {label || "#"} + + ))} + +
+
+
+ ); +} + const BASE = API.quality; async function apiGet(path: string): Promise { @@ -73,6 +267,9 @@ export default function QualityGate({ view }: QualityGateProps) { const [fileContent, setFileContent] = useState(null); const [modalOpen, setModalOpen] = useState(false); + const [trendHistory, setTrendHistory] = useState([]); + const [trendLoading, setTrendLoading] = useState(false); + const fetchPRs = useCallback(async () => { setLoading(true); setError(""); @@ -91,6 +288,23 @@ export default function QualityGate({ view }: QualityGateProps) { fetchPRs(); }, [fetchPRs]); + const fetchTrendHistory = useCallback(async () => { + setTrendLoading(true); + try { + const data = await apiGet("/prs/history?limit=15"); + const list = Array.isArray(data) ? data : data.history ?? []; + setTrendHistory(list); + } catch { + setTrendHistory([]); + } finally { + setTrendLoading(false); + } + }, []); + + useEffect(() => { + if (view === "dashboard") fetchTrendHistory(); + }, [view, fetchTrendHistory]); + const openPRDetail = useCallback(async (prId: number) => { setLoading(true); setError(""); @@ -116,8 +330,8 @@ export default function QualityGate({ view }: QualityGateProps) { useEffect(() => { if (!modalOpen || !selectedPR || !selectedFile) return; setFileContent(null); - apiGet(`/prs/${selectedPR.id}/file?path=${encodeURIComponent(selectedFile)}`) - .then(setFileContent) + apiGet[0]>(`/prs/${selectedPR.id}/file?path=${encodeURIComponent(selectedFile)}`) + .then((data) => setFileContent(normalizeFileContent(data))) .catch(() => setFileContent({ content: "// Failed to load file", issues: [] })); }, [modalOpen, selectedPR, selectedFile]); @@ -183,6 +397,10 @@ export default function QualityGate({ view }: QualityGateProps) { ))} +
+ +
+

Recent Pull Requests

openPRDetail(pr.id)} /> @@ -281,53 +499,84 @@ export default function QualityGate({ view }: QualityGateProps) { ))} -
- {fileContent ? ( -
-                    {fileContent.content.split("\n").map((line, i) => {
-                      const lineNum = i + 1;
-                      const issues = fileContent.issues.filter((iss) => iss.line === lineNum);
-                      return (
-                        
0 ? "bg-red-50/80" : ""}`}> - - {lineNum} - - {line} - {issues.length > 0 && ( - - ● {issues[0].message} - - )} -
- ); - })} -
- ) : ( -
- {selectedFile ? "Loading file..." : "Select a file to inspect"} -
- )} -
- -
-

Issue Panel

- {fileContent?.issues.length === 0 && ( -

No issues

- )} - {fileContent?.issues.map((iss, i) => ( -
-
- - {iss.severity === "error" ? "❌" : iss.severity === "warning" ? "⚠️" : "ℹ️"} - - L{iss.line} + {/* 代码区:完整代码 + 每行右侧缺陷标注,一行一个滚动条(参考 index copy.html) */} +
+
+ {fileContent ? ( +
+ {(fileContent.content ?? "").split("\n").map((line, i) => { + const lineNum = i + 1; + const lineIssues = (fileContent.issues ?? []).filter((iss) => iss.line === lineNum); + const hasIssue = lineIssues.length > 0; + const reasonText = hasIssue + ? lineIssues.map((iss) => (iss.scanner ? `[${iss.scanner}] ` : "") + (iss.message || "")).join(";") + : ""; + const displayText = line === "" ? "\u00A0" : line; + return ( +
+ {/* 行号区 */} +
+ {lineNum} + {hasIssue && ( + + ● + + )} +
+ {/* 代码内容 */} +
+ {displayText} +
+ {/* 虚线连接区 */} +
+ {hasIssue && ( + + )} +
+ {/* 缺陷标注(与该行对齐,在右侧) */} +
+ {hasIssue && reasonText ? ( + <> + + {reasonText} + + ) : ( + - + )} +
+
+ ); + })}
-

{iss.message}

- {iss.rule &&

{iss.rule}

} -
- ))} + ) : ( +
+ {selectedFile ? "Loading file..." : "Select a file to inspect"} +
+ )} +