2026-03-12 16:33:33 +08:00
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
|
|
|
|
import { API } from "../config";
|
|
|
|
|
|
|
|
|
|
|
|
type PRScan = {
|
|
|
|
|
|
id: number;
|
|
|
|
|
|
pr_number: number;
|
|
|
|
|
|
repo_name: string;
|
|
|
|
|
|
pr_title: string;
|
|
|
|
|
|
pr_url: string;
|
|
|
|
|
|
source_branch: string;
|
|
|
|
|
|
target_branch: string;
|
|
|
|
|
|
author: string;
|
|
|
|
|
|
state: "open" | "merged" | "closed";
|
|
|
|
|
|
scan_status: "pending" | "completed";
|
|
|
|
|
|
issues_count: number;
|
|
|
|
|
|
security_issues: number;
|
|
|
|
|
|
created_at: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
type PRDetail = PRScan & {
|
|
|
|
|
|
scan_result: Record<string, unknown>;
|
|
|
|
|
|
scan_details_with_code: unknown[];
|
|
|
|
|
|
ai_review: unknown;
|
|
|
|
|
|
report_path: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
type ChangedFile = {
|
|
|
|
|
|
filename: string;
|
|
|
|
|
|
status: string;
|
|
|
|
|
|
additions: number;
|
|
|
|
|
|
deletions: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-13 12:39:15 +08:00
|
|
|
|
type FileContentIssue = {
|
|
|
|
|
|
line: number;
|
|
|
|
|
|
severity: string;
|
|
|
|
|
|
message: string;
|
|
|
|
|
|
rule?: string;
|
|
|
|
|
|
scanner?: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-12 16:33:33 +08:00
|
|
|
|
type FileContent = {
|
|
|
|
|
|
content: string;
|
2026-03-13 12:39:15 +08:00
|
|
|
|
issues: FileContentIssue[];
|
2026-03-12 16:33:33 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-13 12:39:15 +08:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 16:33:33 +08:00
|
|
|
|
type View = "dashboard" | "pr-list" | "settings";
|
|
|
|
|
|
|
2026-03-13 12:39:15 +08:00
|
|
|
|
type PRHistoryItem = {
|
|
|
|
|
|
pr_number: number;
|
|
|
|
|
|
error_count?: number;
|
|
|
|
|
|
warning_count?: number;
|
|
|
|
|
|
total_issues?: number;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-12 16:33:33 +08:00
|
|
|
|
type QualityGateProps = {
|
|
|
|
|
|
view: View;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-13 12:39:15 +08:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 16:33:33 +08:00
|
|
|
|
const BASE = API.quality;
|
|
|
|
|
|
|
|
|
|
|
|
async function apiGet<T>(path: string): Promise<T> {
|
|
|
|
|
|
const res = await fetch(`${BASE}${path}`);
|
|
|
|
|
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
|
|
|
|
return res.json();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function apiPost<T>(path: string): Promise<T> {
|
|
|
|
|
|
const res = await fetch(`${BASE}${path}`, { method: "POST" });
|
|
|
|
|
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
|
|
|
|
return res.json();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function QualityGate({ view }: QualityGateProps) {
|
|
|
|
|
|
const [prs, setPrs] = useState<PRScan[]>([]);
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
const [error, setError] = useState("");
|
|
|
|
|
|
const [filter, setFilter] = useState<"all" | "open" | "merged" | "closed">("all");
|
|
|
|
|
|
|
|
|
|
|
|
const [selectedPR, setSelectedPR] = useState<PRDetail | null>(null);
|
|
|
|
|
|
const [changedFiles, setChangedFiles] = useState<ChangedFile[]>([]);
|
|
|
|
|
|
const [selectedFile, setSelectedFile] = useState<string>("");
|
|
|
|
|
|
const [fileContent, setFileContent] = useState<FileContent | null>(null);
|
|
|
|
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
|
|
|
|
|
2026-03-13 12:39:15 +08:00
|
|
|
|
const [trendHistory, setTrendHistory] = useState<PRHistoryItem[]>([]);
|
|
|
|
|
|
const [trendLoading, setTrendLoading] = useState(false);
|
|
|
|
|
|
|
2026-03-12 16:33:33 +08:00
|
|
|
|
const fetchPRs = useCallback(async () => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
setError("");
|
|
|
|
|
|
try {
|
|
|
|
|
|
const query = filter === "all" ? "" : `?state=${filter}`;
|
|
|
|
|
|
const data = await apiGet<{ prs: PRScan[] } | PRScan[]>(`/prs${query}`);
|
|
|
|
|
|
setPrs(Array.isArray(data) ? data : data.prs || []);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError((e as Error).message);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [filter]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
fetchPRs();
|
|
|
|
|
|
}, [fetchPRs]);
|
|
|
|
|
|
|
2026-03-13 12:39:15 +08:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-03-12 16:33:33 +08:00
|
|
|
|
const openPRDetail = useCallback(async (prId: number) => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
setError("");
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [detail, files] = await Promise.all([
|
|
|
|
|
|
apiGet<PRDetail>(`/prs/${prId}`),
|
|
|
|
|
|
apiGet<{ files: ChangedFile[] } | ChangedFile[]>(`/prs/${prId}/files`),
|
|
|
|
|
|
]);
|
|
|
|
|
|
setSelectedPR(detail);
|
|
|
|
|
|
const fileList = Array.isArray(files) ? files : files.files || [];
|
|
|
|
|
|
setChangedFiles(fileList);
|
|
|
|
|
|
if (fileList.length > 0) {
|
|
|
|
|
|
setSelectedFile(fileList[0].filename);
|
|
|
|
|
|
}
|
|
|
|
|
|
setModalOpen(true);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError((e as Error).message);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!modalOpen || !selectedPR || !selectedFile) return;
|
|
|
|
|
|
setFileContent(null);
|
2026-03-13 12:39:15 +08:00
|
|
|
|
apiGet<Parameters<typeof normalizeFileContent>[0]>(`/prs/${selectedPR.id}/file?path=${encodeURIComponent(selectedFile)}`)
|
|
|
|
|
|
.then((data) => setFileContent(normalizeFileContent(data)))
|
2026-03-12 16:33:33 +08:00
|
|
|
|
.catch(() => setFileContent({ content: "// Failed to load file", issues: [] }));
|
|
|
|
|
|
}, [modalOpen, selectedPR, selectedFile]);
|
|
|
|
|
|
|
|
|
|
|
|
const handleMerge = async () => {
|
|
|
|
|
|
if (!selectedPR) return;
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await apiPost(`/prs/${selectedPR.id}/merge`);
|
|
|
|
|
|
setModalOpen(false);
|
|
|
|
|
|
fetchPRs();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError((e as Error).message);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleClose = async () => {
|
|
|
|
|
|
if (!selectedPR) return;
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await apiPost(`/prs/${selectedPR.id}/close`);
|
|
|
|
|
|
setModalOpen(false);
|
|
|
|
|
|
fetchPRs();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setError((e as Error).message);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const stats = {
|
|
|
|
|
|
pending: prs.filter((p) => p.state === "open").length,
|
|
|
|
|
|
passed: prs.filter((p) => p.state === "merged").length,
|
|
|
|
|
|
rejected: prs.filter((p) => p.state === "closed").length,
|
|
|
|
|
|
totalIssues: prs.reduce((s, p) => s + p.issues_count, 0),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="h-full overflow-y-auto p-8">
|
|
|
|
|
|
{error && (
|
|
|
|
|
|
<div className="mb-4 px-4 py-2 bg-red-50 text-red-700 text-sm flex justify-between items-center rounded-xl border border-red-200">
|
|
|
|
|
|
{error}
|
|
|
|
|
|
<button className="font-bold text-red-400 hover:text-red-600" onClick={() => setError("")}>✕</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{view === "dashboard" && (
|
|
|
|
|
|
<>
|
2026-03-14 01:40:48 +08:00
|
|
|
|
<h2 className="text-xl font-extrabold mb-6">总览</h2>
|
2026-03-12 16:33:33 +08:00
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-4 gap-5 mb-8">
|
|
|
|
|
|
{[
|
2026-03-14 01:40:48 +08:00
|
|
|
|
{ label: "打开 PR", value: stats.pending, color: "text-yellow-600", bg: "bg-yellow-50" },
|
|
|
|
|
|
{ label: "已合并", value: stats.passed, color: "text-green-600", bg: "bg-green-50" },
|
|
|
|
|
|
{ label: "已拒绝", value: stats.rejected, color: "text-red-600", bg: "bg-red-50" },
|
|
|
|
|
|
{ label: "问题总数", value: stats.totalIssues, color: "text-magenta", bg: "bg-magenta-50" },
|
2026-03-12 16:33:33 +08:00
|
|
|
|
].map((s) => (
|
|
|
|
|
|
<div key={s.label} className={`${s.bg} p-6 border border-border rounded-2xl shadow-[0_4px_16px_rgba(0,0,0,0.04)]`}>
|
|
|
|
|
|
<div className={`text-3xl font-extrabold ${s.color}`}>{s.value}</div>
|
|
|
|
|
|
<div className="text-sm font-bold text-txt-muted mt-2">{s.label}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-13 12:39:15 +08:00
|
|
|
|
<div className="mt-8 mb-8">
|
|
|
|
|
|
<ProblemTrendChart history={trendHistory} loading={trendLoading} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-14 01:40:48 +08:00
|
|
|
|
<h3 className="text-lg font-extrabold mb-4">近期 Pull Request</h3>
|
2026-03-12 16:33:33 +08:00
|
|
|
|
<PRTable prs={prs.slice(0, 8)} loading={loading} onView={(pr) => openPRDetail(pr.id)} />
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{view === "pr-list" && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="flex items-center justify-between mb-6">
|
2026-03-14 01:40:48 +08:00
|
|
|
|
<h2 className="text-xl font-extrabold">Pull Request 列表</h2>
|
2026-03-12 16:33:33 +08:00
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
{(["all", "open", "merged", "closed"] as const).map((f) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={f}
|
|
|
|
|
|
onClick={() => setFilter(f)}
|
|
|
|
|
|
className={`px-3 py-1.5 text-xs font-bold border rounded-lg transition-colors ${
|
|
|
|
|
|
filter === f
|
|
|
|
|
|
? "border-magenta text-magenta bg-magenta-50"
|
|
|
|
|
|
: "border-border text-txt-muted hover:border-magenta"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2026-03-14 01:40:48 +08:00
|
|
|
|
{f === "all" ? "全部" : f === "open" ? "打开" : f === "merged" ? "已合并" : "已关闭"}
|
2026-03-12 16:33:33 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<button className="btn-outline text-xs px-3 py-1.5" onClick={fetchPRs}>
|
2026-03-14 01:40:48 +08:00
|
|
|
|
刷新
|
2026-03-12 16:33:33 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<PRTable prs={prs} loading={loading} onView={(pr) => openPRDetail(pr.id)} />
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{view === "settings" && (
|
|
|
|
|
|
<div className="max-w-xl">
|
2026-03-14 01:40:48 +08:00
|
|
|
|
<h2 className="text-xl font-extrabold mb-6">设置</h2>
|
2026-03-12 16:33:33 +08:00
|
|
|
|
<div className="card mb-4">
|
2026-03-14 01:40:48 +08:00
|
|
|
|
<h3 className="font-bold mb-2">Webhook 配置</h3>
|
|
|
|
|
|
<p className="text-sm text-txt-muted mb-3">将此 URL 添加到 Gitea 仓库的 webhook 设置:</p>
|
2026-03-12 16:33:33 +08:00
|
|
|
|
<code className="block bg-surface-muted p-3 text-sm font-mono break-all rounded-lg">
|
|
|
|
|
|
POST {window.location.origin}/quality-api/webhook/gitea
|
|
|
|
|
|
</code>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="card">
|
2026-03-14 01:40:48 +08:00
|
|
|
|
<h3 className="font-bold mb-2">快速说明</h3>
|
2026-03-12 16:33:33 +08:00
|
|
|
|
<ul className="text-sm text-txt-muted list-disc list-inside space-y-1">
|
2026-03-14 01:40:48 +08:00
|
|
|
|
<li>支持 Gitea Push 与 Pull Request 事件</li>
|
|
|
|
|
|
<li>自动运行 Pylint、Flake8、ESLint 与 Bandit</li>
|
|
|
|
|
|
<li>可选 AI 审查(DeepSeek-V3)</li>
|
|
|
|
|
|
<li>扫描摘要可推送到飞书通知</li>
|
2026-03-12 16:33:33 +08:00
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{modalOpen && selectedPR && (
|
|
|
|
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
|
|
|
|
|
<div className="bg-white w-[92vw] h-[88vh] flex flex-col rounded-2xl overflow-hidden shadow-[0_20px_60px_rgba(0,0,0,0.2)]">
|
|
|
|
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 className="text-lg font-extrabold">
|
|
|
|
|
|
#{selectedPR.pr_number} {selectedPR.pr_title}
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
<p className="text-xs text-txt-muted mt-1">
|
|
|
|
|
|
{selectedPR.author} · {selectedPR.source_branch} → {selectedPR.target_branch} · {selectedPR.repo_name}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="w-8 h-8 flex items-center justify-center text-txt-muted hover:text-txt font-bold text-lg"
|
|
|
|
|
|
onClick={() => setModalOpen(false)}
|
|
|
|
|
|
>
|
|
|
|
|
|
✕
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-1 min-h-0">
|
|
|
|
|
|
<div className="w-[260px] shrink-0 border-r border-border overflow-y-auto p-4 bg-surface-muted/35">
|
2026-03-14 01:40:48 +08:00
|
|
|
|
<h3 className="text-xs font-bold text-txt-muted uppercase mb-3">变更文件</h3>
|
2026-03-12 16:33:33 +08:00
|
|
|
|
{changedFiles.map((f) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={f.filename}
|
|
|
|
|
|
className={`block w-full text-left px-3 py-2 text-sm truncate transition-colors rounded-lg ${
|
|
|
|
|
|
selectedFile === f.filename
|
|
|
|
|
|
? "bg-magenta-50 text-magenta font-bold"
|
|
|
|
|
|
: "text-txt hover:bg-surface-muted"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
onClick={() => setSelectedFile(f.filename)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className={`inline-block w-4 mr-1 text-xs font-bold ${
|
|
|
|
|
|
f.status === "added" ? "text-green-600" : f.status === "removed" ? "text-red-600" : "text-yellow-600"
|
|
|
|
|
|
}`}>
|
|
|
|
|
|
{f.status === "added" ? "A" : f.status === "removed" ? "D" : "M"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{f.filename.split("/").pop()}
|
|
|
|
|
|
<span className="text-xs text-txt-muted ml-1 block truncate">{f.filename}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-13 12:39:15 +08:00
|
|
|
|
{/* 代码区:完整代码 + 每行右侧缺陷标注,一行一个滚动条(参考 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]">
|
2026-03-14 01:40:48 +08:00
|
|
|
|
{fileContent ? (
|
2026-03-13 12:39:15 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-03-12 16:33:33 +08:00
|
|
|
|
</div>
|
2026-03-13 12:39:15 +08:00
|
|
|
|
) : (
|
|
|
|
|
|
<div className="flex items-center justify-center h-full min-h-[200px] text-[#6c757d] text-sm">
|
2026-03-14 01:40:48 +08:00
|
|
|
|
{selectedFile ? "加载文件..." : "请选择要检查的文件"}
|
2026-03-13 12:39:15 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-03-12 16:33:33 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex justify-end gap-3 px-6 py-4 border-t border-border shrink-0">
|
|
|
|
|
|
<button className="btn-outline" onClick={() => setModalOpen(false)}>
|
2026-03-14 01:40:48 +08:00
|
|
|
|
关闭
|
2026-03-12 16:33:33 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="bg-red-600 text-white font-bold text-sm px-6 py-2.5 border-none cursor-pointer hover:opacity-90 disabled:opacity-50 rounded-xl"
|
|
|
|
|
|
onClick={handleClose}
|
|
|
|
|
|
disabled={loading || selectedPR.state !== "open"}
|
|
|
|
|
|
>
|
2026-03-14 01:40:48 +08:00
|
|
|
|
拒绝
|
2026-03-12 16:33:33 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="btn-magenta"
|
|
|
|
|
|
onClick={handleMerge}
|
|
|
|
|
|
disabled={loading || selectedPR.state !== "open"}
|
|
|
|
|
|
>
|
2026-03-14 01:40:48 +08:00
|
|
|
|
批准并合并
|
2026-03-12 16:33:33 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function PRTable({
|
|
|
|
|
|
prs,
|
|
|
|
|
|
loading,
|
|
|
|
|
|
onView,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
prs: PRScan[];
|
|
|
|
|
|
loading: boolean;
|
|
|
|
|
|
onView: (pr: PRScan) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
if (loading && prs.length === 0) {
|
2026-03-14 01:40:48 +08:00
|
|
|
|
return <div className="text-center py-8 text-txt-muted text-sm">加载中...</div>;
|
2026-03-12 16:33:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (prs.length === 0) {
|
2026-03-14 01:40:48 +08:00
|
|
|
|
return <div className="text-center py-8 text-txt-muted text-sm">暂无 PR 记录</div>;
|
2026-03-12 16:33:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="overflow-x-auto rounded-2xl border border-border bg-white shadow-[0_6px_22px_rgba(0,0,0,0.04)]">
|
|
|
|
|
|
<table className="w-full text-sm">
|
|
|
|
|
|
<thead>
|
2026-03-14 01:40:48 +08:00
|
|
|
|
<tr className="bg-surface-muted text-left">
|
2026-03-12 16:33:33 +08:00
|
|
|
|
<th className="px-4 py-3 border-b border-border font-bold">PR#</th>
|
2026-03-14 01:40:48 +08:00
|
|
|
|
<th className="px-4 py-3 border-b border-border font-bold">标题</th>
|
|
|
|
|
|
<th className="px-4 py-3 border-b border-border font-bold">仓库</th>
|
|
|
|
|
|
<th className="px-4 py-3 border-b border-border font-bold">作者</th>
|
|
|
|
|
|
<th className="px-4 py-3 border-b border-border font-bold">分支</th>
|
|
|
|
|
|
<th className="px-4 py-3 border-b border-border font-bold">状态</th>
|
|
|
|
|
|
<th className="px-4 py-3 border-b border-border font-bold">问题数</th>
|
|
|
|
|
|
<th className="px-4 py-3 border-b border-border font-bold">创建时间</th>
|
|
|
|
|
|
<th className="px-4 py-3 border-b border-border font-bold">操作</th>
|
2026-03-12 16:33:33 +08:00
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{prs.map((pr) => (
|
|
|
|
|
|
<tr key={pr.id} className="border-b border-border hover:bg-surface-muted/50 transition-colors">
|
|
|
|
|
|
<td className="px-4 py-3 font-mono text-xs">#{pr.pr_number}</td>
|
|
|
|
|
|
<td className="px-4 py-3 font-bold max-w-[220px] truncate">{pr.pr_title}</td>
|
|
|
|
|
|
<td className="px-4 py-3 text-txt-muted text-xs">{pr.repo_name}</td>
|
|
|
|
|
|
<td className="px-4 py-3 text-txt-muted">{pr.author}</td>
|
|
|
|
|
|
<td className="px-4 py-3 text-xs font-mono text-txt-muted">
|
|
|
|
|
|
{pr.source_branch} → {pr.target_branch}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="px-4 py-3">
|
|
|
|
|
|
<span className={`badge ${
|
|
|
|
|
|
pr.state === "open"
|
|
|
|
|
|
? "bg-yellow-100 text-yellow-700"
|
|
|
|
|
|
: pr.state === "merged"
|
|
|
|
|
|
? "bg-green-100 text-green-700"
|
|
|
|
|
|
: "bg-red-100 text-red-700"
|
|
|
|
|
|
}`}>
|
2026-03-14 01:40:48 +08:00
|
|
|
|
{pr.state === "open" ? "打开" : pr.state === "merged" ? "已合并" : "已关闭"}
|
2026-03-12 16:33:33 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="px-4 py-3">
|
|
|
|
|
|
<span className={`font-bold ${pr.issues_count > 0 ? "text-red-600" : "text-green-600"}`}>
|
|
|
|
|
|
{pr.issues_count}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="px-4 py-3 text-xs text-txt-muted">
|
2026-03-14 01:40:48 +08:00
|
|
|
|
{new Date(pr.created_at).toLocaleString("zh-CN")}
|
2026-03-12 16:33:33 +08:00
|
|
|
|
</td>
|
|
|
|
|
|
<td className="px-4 py-3">
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="text-magenta font-bold text-xs hover:underline"
|
|
|
|
|
|
onClick={() => onView(pr)}
|
|
|
|
|
|
>
|
2026-03-14 01:40:48 +08:00
|
|
|
|
查看 →
|
2026-03-12 16:33:33 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|