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 (
+
+
问题趋势
+
+
+
+
+
+
+ );
+}
+
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"}
+
+ )}
+