修改 质量门控
This commit is contained in:
@@ -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 (
|
||||
<div className="rounded-2xl border border-border bg-white shadow-[0_6px_22px_rgba(0,0,0,0.04)] p-4">
|
||||
<h3 className="text-base font-extrabold mb-3">问题趋势</h3>
|
||||
<div className="text-center py-8 text-txt-muted text-sm">加载趋势数据中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!history || history.length === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-white shadow-[0_6px_22px_rgba(0,0,0,0.04)] p-4">
|
||||
<h3 className="text-base font-extrabold mb-3">问题趋势</h3>
|
||||
<div className="text-center py-8 text-txt-muted text-sm">暂无趋势数据</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="rounded-2xl border border-border bg-white shadow-[0_6px_22px_rgba(0,0,0,0.04)] p-4">
|
||||
<h3 className="text-base font-extrabold mb-3">问题趋势</h3>
|
||||
<div className="overflow-x-auto overflow-y-hidden" style={{ maxWidth: "100%" }}>
|
||||
<div style={{ width, minWidth: width, height }}>
|
||||
<svg width={width} height={height} className="overflow-visible">
|
||||
{/* 网格线 */}
|
||||
{yTicks.map((tick, i) => (
|
||||
<line
|
||||
key={i}
|
||||
x1={padding.left}
|
||||
y1={yScale(tick)}
|
||||
x2={padding.left + chartW}
|
||||
y2={yScale(tick)}
|
||||
stroke="rgba(0,0,0,0.12)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
))}
|
||||
{labels.map((_, i) => (
|
||||
<line
|
||||
key={i}
|
||||
x1={xScale(i)}
|
||||
y1={padding.top}
|
||||
x2={xScale(i)}
|
||||
y2={padding.top + chartH}
|
||||
stroke="rgba(0,0,0,0.12)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
))}
|
||||
{/* 图例 */}
|
||||
<g transform={`translate(${padding.left}, 8)`}>
|
||||
<rect x={0} y={2} width={14} height={10} fill="#dc3545" rx={1} />
|
||||
<text x={18} y={11} fontSize={11} fill="currentColor" className="text-txt">
|
||||
错误
|
||||
</text>
|
||||
<rect x={70} y={2} width={14} height={10} fill="#ffc107" rx={1} />
|
||||
<text x={88} y={11} fontSize={11} fill="currentColor" className="text-txt">
|
||||
警告
|
||||
</text>
|
||||
</g>
|
||||
{/* 面积填充 */}
|
||||
<path d={errorAreaPath} fill="rgba(220, 53, 69, 0.1)" />
|
||||
<path d={warningAreaPath} fill="rgba(255, 193, 7, 0.1)" />
|
||||
{/* 折线 */}
|
||||
<path d={errorPath} fill="none" stroke="#dc3545" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d={warningPath} fill="none" stroke="#ffc107" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
{/* Y 轴刻度 */}
|
||||
{yTicks.map((tick) => (
|
||||
<text
|
||||
key={tick}
|
||||
x={padding.left - 8}
|
||||
y={yScale(tick) + 4}
|
||||
textAnchor="end"
|
||||
fontSize={10}
|
||||
fill="var(--color-txt-muted, #666)"
|
||||
>
|
||||
{tick}
|
||||
</text>
|
||||
))}
|
||||
{/* X 轴刻度 */}
|
||||
{labels.map((label, i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={xScale(i)}
|
||||
y={height - 8}
|
||||
textAnchor="middle"
|
||||
fontSize={10}
|
||||
fill={label ? "var(--color-txt-muted, #666)" : "transparent"}
|
||||
>
|
||||
{label || "#"}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BASE = API.quality;
|
||||
|
||||
async function apiGet<T>(path: string): Promise<T> {
|
||||
@@ -73,6 +267,9 @@ export default function QualityGate({ view }: QualityGateProps) {
|
||||
const [fileContent, setFileContent] = useState<FileContent | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const [trendHistory, setTrendHistory] = useState<PRHistoryItem[]>([]);
|
||||
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<PRHistoryItem[] | { history?: PRHistoryItem[] }>("/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<FileContent>(`/prs/${selectedPR.id}/file?path=${encodeURIComponent(selectedFile)}`)
|
||||
.then(setFileContent)
|
||||
apiGet<Parameters<typeof normalizeFileContent>[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) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 mb-8">
|
||||
<ProblemTrendChart history={trendHistory} loading={trendLoading} />
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-extrabold mb-4">Recent Pull Requests</h3>
|
||||
<PRTable prs={prs.slice(0, 8)} loading={loading} onView={(pr) => openPRDetail(pr.id)} />
|
||||
</>
|
||||
@@ -281,53 +499,84 @@ export default function QualityGate({ view }: QualityGateProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto bg-white">
|
||||
{fileContent ? (
|
||||
<pre className="text-sm font-mono leading-6 p-4">
|
||||
{fileContent.content.split("\n").map((line, i) => {
|
||||
const lineNum = i + 1;
|
||||
const issues = fileContent.issues.filter((iss) => iss.line === lineNum);
|
||||
return (
|
||||
<div key={i} className={`flex ${issues.length > 0 ? "bg-red-50/80" : ""}`}>
|
||||
<span className="w-12 shrink-0 text-right pr-4 text-txt-muted select-none text-xs leading-6">
|
||||
{lineNum}
|
||||
</span>
|
||||
<span className="flex-1 whitespace-pre-wrap">{line}</span>
|
||||
{issues.length > 0 && (
|
||||
<span className="shrink-0 px-2 text-xs text-red-600 max-w-xs truncate" title={issues[0].message}>
|
||||
● {issues[0].message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-txt-muted text-sm">
|
||||
{selectedFile ? "Loading file..." : "Select a file to inspect"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-[220px] shrink-0 border-l border-border overflow-y-auto p-4 bg-surface-muted/60">
|
||||
<h3 className="text-xs font-bold text-txt-muted uppercase mb-3">Issue Panel</h3>
|
||||
{fileContent?.issues.length === 0 && (
|
||||
<p className="text-xs text-txt-muted">No issues</p>
|
||||
)}
|
||||
{fileContent?.issues.map((iss, i) => (
|
||||
<div key={i} className="mb-3 p-2 bg-white border border-border text-xs rounded-lg">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<span className={`font-bold ${
|
||||
iss.severity === "error" ? "text-red-600" : iss.severity === "warning" ? "text-yellow-600" : "text-blue-600"
|
||||
}`}>
|
||||
{iss.severity === "error" ? "❌" : iss.severity === "warning" ? "⚠️" : "ℹ️"}
|
||||
</span>
|
||||
<span className="text-txt-muted">L{iss.line}</span>
|
||||
{/* 代码区:完整代码 + 每行右侧缺陷标注,一行一个滚动条(参考 index copy.html) */}
|
||||
<div className="flex-1 min-h-0 min-w-0 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 min-h-0 overflow-auto bg-[#1e1e1e] font-mono text-[13px] leading-[1.5]">
|
||||
{fileContent ? (
|
||||
<div className="min-w-min">
|
||||
{(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 (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex min-h-[1.5em] items-stretch ${hasIssue ? "code-line-has-issue" : ""}`}
|
||||
>
|
||||
{/* 行号区 */}
|
||||
<div className="w-12 min-w-[48px] shrink-0 flex items-center justify-end pr-2 text-[#6c757d] bg-[#252526] select-none">
|
||||
<span className="mr-1">{lineNum}</span>
|
||||
{hasIssue && (
|
||||
<span
|
||||
className={
|
||||
lineIssues[0]?.severity === "error" || lineIssues[0]?.severity === "high"
|
||||
? "text-[#f14c4c]"
|
||||
: "text-[#cca700]"
|
||||
}
|
||||
title={reasonText}
|
||||
>
|
||||
●
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 代码内容 */}
|
||||
<div
|
||||
className={`flex-1 min-w-0 px-3 py-0 whitespace-pre-wrap break-all text-[#d4d4d4] ${
|
||||
hasIssue ? "bg-red-900/20 border-l-2 border-l-red-500 pl-2" : ""
|
||||
}`}
|
||||
>
|
||||
{displayText}
|
||||
</div>
|
||||
{/* 虚线连接区 */}
|
||||
<div className="w-5 min-w-[20px] shrink-0 bg-[#1e1e1e] relative">
|
||||
{hasIssue && (
|
||||
<span
|
||||
className="absolute left-0 right-0 top-1/2 -mt-px block border-b border-dashed border-red-500/80"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 缺陷标注(与该行对齐,在右侧) */}
|
||||
<div
|
||||
className={`w-[180px] min-w-[180px] shrink-0 py-1.5 px-2 text-[11px] border-l flex items-center ${
|
||||
hasIssue
|
||||
? "bg-red-50/90 border-l-2 border-l-red-500 text-red-800"
|
||||
: "bg-[#252526] border-[#3c3c3c] text-[#9d9d9d]"
|
||||
}`}
|
||||
>
|
||||
{hasIssue && reasonText ? (
|
||||
<>
|
||||
<span className="text-red-500 mr-1.5 shrink-0">⚠</span>
|
||||
<span className="break-words leading-snug">{reasonText}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="invisible">-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-txt">{iss.message}</p>
|
||||
{iss.rule && <p className="text-txt-muted mt-0.5">{iss.rule}</p>}
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full min-h-[200px] text-[#6c757d] text-sm">
|
||||
{selectedFile ? "Loading file..." : "Select a file to inspect"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user