1701 lines
86 KiB
HTML
1701 lines
86 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>PR 扫描管理平台</title>
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||
<style>
|
||
body { background-color: #f5f7fa; }
|
||
/* Diff 语法高亮 */
|
||
#detail-code-diff .diff-add { color: #98c379; background: rgba(72, 120, 80, 0.2); }
|
||
#detail-code-diff .diff-del { color: #e06c75; background: rgba(180, 80, 80, 0.2); }
|
||
#detail-code-diff .diff-info { color: #61afef; }
|
||
#detail-code-diff .diff-header { color: #e5c07b; }
|
||
.sidebar {
|
||
min-height: 100vh;
|
||
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
|
||
color: white;
|
||
}
|
||
.sidebar .nav-link { color: rgba(255,255,255,0.7); }
|
||
.sidebar .nav-link:hover, .sidebar .nav-link.active {
|
||
color: white;
|
||
background: rgba(255,255,255,0.1);
|
||
}
|
||
.stat-card {
|
||
border: none;
|
||
border-radius: 12px;
|
||
transition: transform 0.2s;
|
||
}
|
||
.stat-card:hover { transform: translateY(-5px); }
|
||
.pr-card {
|
||
border: none;
|
||
border-radius: 10px;
|
||
transition: box-shadow 0.2s;
|
||
}
|
||
.pr-card:hover { box-shadow: 0 4px 15px rgba(0,0,0,0.1); }
|
||
.status-badge {
|
||
padding: 5px 12px;
|
||
border-radius: 20px;
|
||
font-size: 12px;
|
||
}
|
||
.status-open { background: #e3f2fd; color: #1976d2; }
|
||
.status-merged { background: #e8f5e9; color: #388e3c; }
|
||
.status-closed { background: #ffebee; color: #d32f2f; }
|
||
.status-pending { background: #fff3e0; color: #f57c00; }
|
||
.status-completed { background: #e8f5e9; color: #388e3c; }
|
||
.issue-high { color: #d32f2f; }
|
||
.issue-medium { color: #f57c00; }
|
||
.issue-low { color: #388e3c; }
|
||
/* PR 详情 - Git 风格:左侧文件树 + 右侧文件内容 */
|
||
.pr-detail-file-layout { display: flex; height: 500px; border: 1px solid #dee2e6; border-radius: 6px; overflow: hidden; }
|
||
.pr-detail-file-tree { width: 280px; min-width: 280px; overflow-y: auto; background: #f8f9fa; border-right: 1px solid #dee2e6; }
|
||
.pr-detail-file-tree .tree-root { padding: 8px 0; }
|
||
.pr-detail-file-tree .tree-node { padding: 2px 8px; font-size: 13px; cursor: pointer; border-radius: 4px; }
|
||
.pr-detail-file-tree .tree-node:hover { background: #e9ecef; }
|
||
.pr-detail-file-tree .tree-node.active { background: #0d6efd; color: #fff; }
|
||
.pr-detail-file-tree .tree-folder { font-weight: 600; color: #495057; }
|
||
.pr-detail-file-tree .tree-file { padding-left: 12px; }
|
||
/* 代码在中间,右侧缺陷标注,虚线连接代码行与标注(参考审阅风格) */
|
||
.pr-detail-file-right { flex: 1; display: flex; overflow: hidden; min-width: 0; }
|
||
.pr-detail-file-wrapper { width: 100%; height: 100%; min-width: 0; overflow: hidden; }
|
||
.pr-detail-code-view { width: 100%; height: 100%; overflow: auto; background: #1e1e1e; font-family: 'Consolas', 'Monaco', monospace; font-size: 13px; line-height: 1.5; }
|
||
.pr-detail-code-view .code-line { display: flex; min-height: 1.5em; align-items: stretch; }
|
||
.pr-detail-code-view .code-line .line-gutter { width: 48px; min-width: 48px; flex-shrink: 0; display: flex; align-items: center; justify-content: flex-end; padding-right: 8px; color: #6c757d; background: #252526; user-select: none; }
|
||
.pr-detail-code-view .code-line .line-gutter .line-num { margin-right: 4px; }
|
||
.pr-detail-code-view .code-line .line-gutter .gutter-icon { width: 14px; height: 14px; flex-shrink: 0; }
|
||
.pr-detail-code-view .code-line .line-gutter .gutter-icon.icon-error { color: #f14c4c; }
|
||
.pr-detail-code-view .code-line .line-gutter .gutter-icon.icon-warning { color: #cca700; }
|
||
.pr-detail-code-view .code-line .line-content { flex: 1; min-width: 0; color: #d4d4d4; padding: 0 12px; white-space: pre-wrap; word-break: break-all; overflow-wrap: break-word; }
|
||
.pr-detail-code-view .code-line.line-has-issue .line-content { background: rgba(220, 53, 69, 0.1); border-left: 2px solid #dc3545; padding-left: 10px; }
|
||
/* 虚线连接区:代码与标注之间 */
|
||
.pr-detail-code-view .code-line .line-connector { width: 20px; min-width: 20px; flex-shrink: 0; position: relative; background: #1e1e1e; }
|
||
.pr-detail-code-view .code-line.line-has-issue .line-connector::before { content: ''; position: absolute; left: 0; right: 0; top: 50%; margin-top: -1px; height: 0; border-bottom: 1px dashed #f14c4c; }
|
||
.pr-detail-code-view .code-line .line-annotation { width: 180px; min-width: 180px; flex-shrink: 0; padding: 6px 8px; font-size: 11px; background: #252526; color: #9d9d9d; border-left: 1px solid #3c3c3c; white-space: normal; word-break: break-word; display: flex; align-items: center; font-family: inherit; }
|
||
.pr-detail-code-view .code-line.line-has-issue .line-annotation { background: #fff8f8; border-left: 2px solid #dc3545; color: #c62828; }
|
||
.pr-detail-code-view .code-line .line-annotation .anno-text { line-height: 1.4; }
|
||
.pr-detail-code-view .code-line .line-annotation .anno-icon { color: #dc3545; margin-right: 6px; flex-shrink: 0; }
|
||
.pr-detail-file-placeholder { color: #6c757d; padding: 24px; text-align: center; width: 100%; }
|
||
/* PR 详情弹窗加宽:占屏幕大部分宽度,代码区随之扩大 */
|
||
.modal-dialog-pr-wide { max-width: 92%; width: 92%; margin-left: auto; margin-right: auto; }
|
||
@media (min-width: 1400px) { .modal-dialog-pr-wide { max-width: 1400px; width: 92%; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container-fluid">
|
||
<div class="row">
|
||
<!-- 侧边栏 -->
|
||
<div class="col-md-2 sidebar p-3">
|
||
<h4 class="mb-4"><i class="bi bi-shield-check"></i> 扫描管理</h4>
|
||
<ul class="nav flex-column">
|
||
<li class="nav-item">
|
||
<a class="nav-link active" href="#" onclick="showPage('dashboard')">
|
||
<i class="bi bi-speedometer2 me-2"></i>概览
|
||
</a>
|
||
</li>
|
||
</ul>
|
||
|
||
<h6 class="mt-4 mb-2 text-uppercase" style="color: rgba(255,255,255,0.5); font-size: 11px;">AI 智能分析</h6>
|
||
<ul class="nav flex-column">
|
||
<li class="nav-item">
|
||
<a class="nav-link" href="#" onclick="showPage('ai-quality')">
|
||
<i class="bi bi-stars me-2"></i>AI 质量评分
|
||
</a>
|
||
</li>
|
||
<li class="nav-item">
|
||
<a class="nav-link" href="#" onclick="showPage('ai-insights')">
|
||
<i class="bi bi-lightbulb me-2"></i>智能洞察
|
||
</a>
|
||
</li>
|
||
</ul>
|
||
|
||
<ul class="nav flex-column mt-3">
|
||
</ul>
|
||
|
||
<div class="mt-4 p-3" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 8px;">
|
||
<small><i class="bi bi-cpu me-1"></i> AI 引擎</small>
|
||
<div class="mt-2 small">基于大模型智能分析代码质量</div>
|
||
<div class="mt-2">
|
||
<span class="badge bg-success">在线</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主内容区 -->
|
||
<div class="col-md-10 p-4">
|
||
<!-- 概览页面 -->
|
||
<div id="page-dashboard">
|
||
<h2 class="mb-4">概览</h2>
|
||
<div class="row mb-4">
|
||
<div class="col-md-3">
|
||
<div class="card stat-card bg-primary text-white">
|
||
<div class="card-body">
|
||
<h6 class="card-title"><i class="bi bi-inbox"></i> 待处理</h6>
|
||
<h2 class="mb-0" id="stat-pending">-</h2>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="card stat-card bg-success text-white">
|
||
<div class="card-body">
|
||
<h6 class="card-title"><i class="bi bi-check-circle"></i> 已通过</h6>
|
||
<h2 class="mb-0" id="stat-merged">-</h2>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="card stat-card bg-danger text-white">
|
||
<div class="card-body">
|
||
<h6 class="card-title"><i class="bi bi-x-circle"></i> 已拒绝</h6>
|
||
<h2 class="mb-0" id="stat-closed">-</h2>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="card stat-card bg-warning text-white">
|
||
<div class="card-body">
|
||
<h6 class="card-title"><i class="bi bi-exclamation-triangle"></i> 问题数</h6>
|
||
<h2 class="mb-0" id="stat-issues">-</h2>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<h5 class="mb-3">最近 PR</h5>
|
||
<div class="card">
|
||
<div class="card-body">
|
||
<div class="table-responsive">
|
||
<table class="table table-hover" id="recent-prs-table">
|
||
<thead>
|
||
<tr>
|
||
<th>PR</th>
|
||
<th>标题</th>
|
||
<th>作者</th>
|
||
<th>分支</th>
|
||
<th>状态</th>
|
||
<th>问题</th>
|
||
<th>时间</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 历史趋势:每个 PR 固定等距,新 PR 在右侧追加,前面 PR 位置不变,可横向滚动 -->
|
||
<h5 class="mb-3 mt-4">问题趋势</h5>
|
||
<div class="card">
|
||
<div class="card-body p-2">
|
||
<div id="trend-chart-wrapper" style="overflow-x: auto; overflow-y: hidden;">
|
||
<div id="trend-chart-container" style="height: 220px;">
|
||
<canvas id="trend-chart"></canvas>
|
||
</div>
|
||
</div>
|
||
<div id="trend-loading" class="text-center py-3 text-muted">加载趋势数据中...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row mt-3">
|
||
<div class="col-md-6">
|
||
<div class="card mb-3">
|
||
<div class="card-header py-2">问题趋势统计</div>
|
||
<div class="card-body p-2" id="ai-trend-stats">
|
||
<div class="text-muted text-center py-2">暂无数据</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="card mb-3">
|
||
<div class="card-header py-2">改进建议</div>
|
||
<div class="card-body p-2">
|
||
<ul class="list-unstyled mb-0 small" id="ai-trend-tips">
|
||
<li><i class="bi bi-check-circle text-success me-2"></i>持续关注代码质量</li>
|
||
<li><i class="bi bi-check-circle text-success me-2"></i>减少警告数量</li>
|
||
<li><i class="bi bi-check-circle text-success me-2"></i>遵循最佳实践</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 问题分布统计 -->
|
||
<h5 class="mb-3 mt-4">问题分布统计</h5>
|
||
<div class="row mb-3">
|
||
<div class="col-md-6">
|
||
<div class="card h-100">
|
||
<div class="card-header py-2">按严重程度分布</div>
|
||
<div class="card-body p-2">
|
||
<canvas id="severity-chart" style="max-height: 180px;"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="card h-100">
|
||
<div class="card-header py-2">按扫描器分布</div>
|
||
<div class="card-body p-2">
|
||
<canvas id="scanner-chart" style="max-height: 180px;"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="card mb-4">
|
||
<div class="card-header py-2">问题类型排行</div>
|
||
<div class="card-body p-2" id="issue-types-list">
|
||
<div class="text-muted text-center py-2">暂无数据</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- PR 列表页面 -->
|
||
<div id="page-prs" style="display:none;">
|
||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||
<h2>PR 列表</h2>
|
||
<div>
|
||
<select class="form-select form-select-sm d-inline-block w-auto" id="filter-state" onchange="loadPRs()">
|
||
<option value="">全部状态</option>
|
||
<option value="open">待处理</option>
|
||
<option value="merged">已合并</option>
|
||
<option value="closed">已关闭</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-body">
|
||
<div class="table-responsive">
|
||
<table class="table table-hover" id="prs-table">
|
||
<thead>
|
||
<tr>
|
||
<th>PR</th>
|
||
<th>标题</th>
|
||
<th>仓库</th>
|
||
<th>作者</th>
|
||
<th>分支</th>
|
||
<th>扫描状态</th>
|
||
<th>状态</th>
|
||
<th>问题</th>
|
||
<th>时间</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 设置页面 -->
|
||
<!-- AI 质量评分页面 -->
|
||
<div id="page-ai-quality" style="display:none;">
|
||
<h2 class="mb-4"><i class="bi bi-stars text-primary"></i> AI 质量评分</h2>
|
||
<div class="alert alert-info py-2 mb-3">
|
||
<i class="bi bi-info-circle"></i> 基于 AI 大模型对 PR 代码进行多维度质量评估
|
||
</div>
|
||
<div class="row mb-3">
|
||
<div class="col-md-3">
|
||
<div class="card text-center bg-gradient-primary text-white">
|
||
<div class="card-body">
|
||
<div class="display-4 fw-bold" id="aiq-total">--</div>
|
||
<small>综合评分</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="card text-center">
|
||
<div class="card-body">
|
||
<div class="h3 fw-bold text-success" id="aiq-security">--</div>
|
||
<small class="text-muted">安全性</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="card text-center">
|
||
<div class="card-body">
|
||
<div class="h3 fw-bold text-warning" id="aiq-maintain">--</div>
|
||
<small class="text-muted">可维护性</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<div class="card text-center">
|
||
<div class="card-body">
|
||
<div class="h3 fw-bold text-info" id="aiq-readability">--</div>
|
||
<small class="text-muted">可读性</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h5 class="mb-0">评分说明</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
<ul class="list-group list-group-flush">
|
||
<li class="list-group-item">
|
||
<strong class="text-success">安全性 (35%)</strong> - 检测 SQL 注入、XSS、密码泄露等安全风险
|
||
</li>
|
||
<li class="list-group-item">
|
||
<strong class="text-warning">可维护性 (30%)</strong> - 代码复杂度、重复代码、硬编码等问题
|
||
</li>
|
||
<li class="list-group-item">
|
||
<strong class="text-info">可读性 (15%)</strong> - 命名规范、注释、代码风格
|
||
</li>
|
||
<li class="list-group-item">
|
||
<strong class="text-primary">最佳实践 (20%)</strong> - 遵循语言最佳实践和设计模式
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- AI 智能洞察页面 -->
|
||
<div id="page-ai-insights" style="display:none;">
|
||
<h2 class="mb-4"><i class="bi bi-lightbulb text-warning"></i> AI 智能洞察</h2>
|
||
<div class="row mb-4">
|
||
<div class="col-md-4">
|
||
<div class="card border-primary">
|
||
<div class="card-body text-center">
|
||
<i class="bi bi-robot display-4 text-primary"></i>
|
||
<h5 class="mt-3">智能分析</h5>
|
||
<p class="text-muted small">基于 AI 大模型深度分析代码问题,提供精准修复建议</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<div class="card border-success">
|
||
<div class="card-body text-center">
|
||
<i class="bi bi-lightning display-4 text-success"></i>
|
||
<h5 class="mt-3">自动修复</h5>
|
||
<p class="text-muted small">一键生成修复代码,直接应用到项目中</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<div class="card border-info">
|
||
<div class="card-body text-center">
|
||
<i class="bi bi-graph-up display-4 text-info"></i>
|
||
<h5 class="mt-3">趋势预测</h5>
|
||
<p class="text-muted small">分析历史数据,预测代码质量变化趋势</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-header">AI 能力展示</div>
|
||
<div class="card-body">
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<h6><i class="bi bi-check2-all text-success"></i> 已支持功能</h6>
|
||
<ul class="list-unstyled ms-3">
|
||
<li><i class="bi bi-check text-success me-2"></i>多维度代码质量评分</li>
|
||
<li><i class="bi bi-check text-success me-2"></i>问题根因分析</li>
|
||
<li><i class="bi bi-check text-success me-2"></i>智能修复建议生成</li>
|
||
<li><i class="bi bi-check text-success me-2"></i>历史趋势分析</li>
|
||
</ul>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<h6><i class="bi bi-gear text-primary"></i> 扫描器类型</h6>
|
||
<ul class="list-unstyled ms-3">
|
||
<li><i class="bi bi-code-slash me-2"></i>Python 代码分析</li>
|
||
<li><i class="bi bi-code-slash me-2"></i>JavaScript/TypeScript 分析</li>
|
||
<li><i class="bi bi-shield-check me-2"></i>安全漏洞检测</li>
|
||
<li><i class="bi bi-stars me-2"></i>AI 智能审查</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div id="page-settings" style="display:none;">
|
||
<h2 class="mb-4">设置</h2>
|
||
<div class="card">
|
||
<div class="card-body">
|
||
<h5>API 端点</h5>
|
||
<div class="mb-3">
|
||
<label class="form-label">Webhook 地址</label>
|
||
<input type="text" class="form-control" value="/webhook/gitea" readonly>
|
||
<small class="text-muted">在 Gitea 中配置为此地址: http://your-server:5000/webhook/gitea</small>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">飞书回调地址</label>
|
||
<input type="text" class="form-control" value="/feishu/card_action" readonly>
|
||
</div>
|
||
<hr>
|
||
<h5>操作说明</h5>
|
||
<ul>
|
||
<li>点击「查看」查看 PR 扫描详情</li>
|
||
<li>点击「同意合并」合并 PR</li>
|
||
<li>点击「拒绝」关闭 PR</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- PR 详情模态框:加宽整体以扩大代码区域 -->
|
||
<div class="modal fade" id="prDetailModal" tabindex="-1">
|
||
<div class="modal-dialog modal-dialog-pr-wide">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="detail-pr-title">PR 详情</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="row mb-3">
|
||
<div class="col-md-6">
|
||
<p><strong>仓库:</strong> <span id="detail-repo"></span></p>
|
||
<p><strong>作者:</strong> <span id="detail-author"></span></p>
|
||
<p><strong>分支:</strong> <span id="detail-branch"></span></p>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<p><strong>扫描状态:</strong> <span id="detail-scan-status"></span></p>
|
||
<p><strong>问题数:</strong> <span id="detail-issues"></span></p>
|
||
<p><strong>安全漏洞:</strong> <span id="detail-security"></span></p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- AI 审查功能区:质量评分 + 问题统计 + 修复建议 -->
|
||
<ul class="nav nav-tabs mb-3" id="aiReviewTabs" role="tablist">
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link active" id="quality-tab" data-bs-toggle="tab" data-bs-target="#quality-panel" type="button" role="tab">
|
||
<i class="bi bi-star"></i> 质量评分
|
||
</button>
|
||
</li>
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link" id="stats-tab" data-bs-toggle="tab" data-bs-target="#stats-panel" type="button" role="tab">
|
||
<i class="bi bi-bar-chart"></i> 问题统计
|
||
</button>
|
||
</li>
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link" id="fix-tab" data-bs-toggle="tab" data-bs-target="#fix-panel" type="button" role="tab">
|
||
<i class="bi bi-tools"></i> AI 修复建议
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
<div class="tab-content" id="aiReviewTabContent">
|
||
<!-- 质量评分面板 -->
|
||
<div class="tab-pane fade show active" id="quality-panel" role="tabpanel">
|
||
<div id="quality-score-loading" class="text-center py-4 text-muted">加载中...</div>
|
||
<div id="quality-score-content" style="display: none;">
|
||
<div class="row text-center mb-4">
|
||
<div class="col">
|
||
<div class="display-1 fw-bold" id="qs-total">--</div>
|
||
<div class="text-muted">综合评分</div>
|
||
</div>
|
||
</div>
|
||
<div class="row text-center">
|
||
<div class="col">
|
||
<div class="h4 fw-bold" id="qs-security">--</div>
|
||
<small class="text-muted">安全性</small>
|
||
</div>
|
||
<div class="col">
|
||
<div class="h4 fw-bold" id="qs-maintain">--</div>
|
||
<small class="text-muted">可维护性</small>
|
||
</div>
|
||
<div class="col">
|
||
<div class="h4 fw-bold" id="qs-readability">--</div>
|
||
<small class="text-muted">可读性</small>
|
||
</div>
|
||
<div class="col">
|
||
<div class="h4 fw-bold" id="qs-best">--</div>
|
||
<small class="text-muted">最佳实践</small>
|
||
</div>
|
||
</div>
|
||
<div class="mt-3 text-center">
|
||
<small class="text-muted" id="qs-details"></small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 问题统计面板 -->
|
||
<div class="tab-pane fade" id="stats-panel" role="tabpanel">
|
||
<div id="stats-loading" class="text-center py-4 text-muted">加载中...</div>
|
||
<div id="stats-content" style="display: none;">
|
||
<div class="row mb-3">
|
||
<div class="col-md-4">
|
||
<div class="card text-center bg-danger text-white">
|
||
<div class="card-body">
|
||
<div class="display-6 fw-bold" id="stat-error">0</div>
|
||
<small>严重问题</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<div class="card text-center bg-warning">
|
||
<div class="card-body">
|
||
<div class="display-6 fw-bold" id="stat-warning">0</div>
|
||
<small>警告</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<div class="card text-center bg-info text-white">
|
||
<div class="card-body">
|
||
<div class="display-6 fw-bold" id="stat-info">0</div>
|
||
<small>提示</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="row">
|
||
<div class="col">
|
||
<h6>按扫描器分布</h6>
|
||
<ul class="list-group" id="stat-scanner-list"></ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- AI 修复建议面板 -->
|
||
<div class="tab-pane fade" id="fix-panel" role="tabpanel">
|
||
<div class="alert alert-info">
|
||
<i class="bi bi-info-circle"></i> 点击问题列表中的 <strong>生成修复</strong> 按钮,AI 将为您生成修复代码。
|
||
</div>
|
||
<div id="fix-result-loading" class="text-center py-3 text-muted" style="display: none;">AI 正在生成修复建议...</div>
|
||
<div id="fix-result-content" style="display: none;">
|
||
<div class="card">
|
||
<div class="card-header bg-success text-white">
|
||
<i class="bi bi-check-circle"></i> 修复建议
|
||
</div>
|
||
<div class="card-body">
|
||
<h6>修复说明</h6>
|
||
<p id="fix-explanation" class="text-muted"></p>
|
||
<h6>修复后代码</h6>
|
||
<pre id="fix-code" class="bg-dark text-light p-3 rounded" style="overflow-x: auto;"></pre>
|
||
<div class="mt-2">
|
||
<span class="badge bg-secondary" id="fix-confidence"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 仅保留文件 Tab:左侧文件树 + 右侧完整文件内容,最右侧为问题标注 -->
|
||
<div class="mt-3">
|
||
<div class="pr-detail-file-layout">
|
||
<div class="pr-detail-file-tree">
|
||
<div id="pr-file-tree-loading" class="p-3 text-center text-muted">加载文件列表中...</div>
|
||
<div id="pr-file-tree" class="tree-root" style="display: none;"></div>
|
||
</div>
|
||
<div class="pr-detail-file-right">
|
||
<div id="pr-file-content-placeholder" class="pr-detail-file-placeholder">点击左侧文件查看完整内容</div>
|
||
<div id="pr-file-content-wrapper" class="pr-detail-file-wrapper">
|
||
<div class="pr-detail-code-view" id="pr-file-content"></div>
|
||
</div>
|
||
<div id="pr-file-content-error" class="pr-detail-file-placeholder" style="display: none; color: #dc3545;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||
<button type="button" class="btn btn-danger" id="btn-reject" onclick="rejectPR()">
|
||
<i class="bi bi-x-circle"></i> 拒绝
|
||
</button>
|
||
<button type="button" class="btn btn-success" id="btn-merge" onclick="mergePR()">
|
||
<i class="bi bi-check-circle"></i> 同意合并
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||
<script>
|
||
let currentPRId = null;
|
||
let currentPRInfo = null;
|
||
let currentDiffData = null;
|
||
let currentAIReview = null;
|
||
|
||
// 全局:Markdown 简易格式化
|
||
function formatMarkdown(text) {
|
||
if (!text) return '';
|
||
return String(text)
|
||
.replace(/^### (.+)$/gm, '<h5 class="mt-3 mb-2">$1</h5>')
|
||
.replace(/^## (.+)$/gm, '<h4 class="mt-3 mb-2">$1</h4>')
|
||
.replace(/^# (.+)$/gm, '<h3 class="mt-3 mb-2">$1</h3>')
|
||
.replace(/^\*\*(.+):\*\*$/gm, '<strong>$1:</strong>')
|
||
.replace(/^- /gm, '<br>• ')
|
||
.replace(/\n\n/g, '</p><p>')
|
||
.replace(/\n/g, '<br>');
|
||
}
|
||
|
||
// 将 AI 审查对象格式化为 HTML(优点/问题/优化 或 raw_review)
|
||
function formatReviewObject(review) {
|
||
if (!review || typeof review !== 'object') return '';
|
||
if (review.raw_review) return formatMarkdown(review.raw_review);
|
||
let html = '';
|
||
if (review['优点'] && review['优点'].length) {
|
||
html += '<div class="mb-2"><strong class="text-success">优点</strong><ul class="mb-0">';
|
||
review['优点'].forEach(s => { html += '<li>' + escapeHtml(s) + '</li>'; });
|
||
html += '</ul></div>';
|
||
}
|
||
if (review['问题'] && review['问题'].length) {
|
||
html += '<div class="mb-2"><strong class="text-danger">问题</strong><ul class="mb-0">';
|
||
review['问题'].forEach(s => { html += '<li>' + escapeHtml(s) + '</li>'; });
|
||
html += '</ul></div>';
|
||
}
|
||
if (review['优化'] && review['优化'].length) {
|
||
html += '<div class="mb-2"><strong class="text-primary">优化</strong><ul class="mb-0">';
|
||
review['优化'].forEach(s => { html += '<li>' + escapeHtml(s) + '</li>'; });
|
||
html += '</ul></div>';
|
||
}
|
||
return html || formatMarkdown(JSON.stringify(review, null, 2));
|
||
}
|
||
|
||
// 页面切换
|
||
function showPage(page) {
|
||
document.querySelectorAll('[id^="page-"]').forEach(el => el.style.display = 'none');
|
||
document.getElementById('page-' + page).style.display = 'block';
|
||
document.querySelectorAll('.sidebar .nav-link').forEach(el => el.classList.remove('active'));
|
||
event.target.closest('.nav-link')?.classList.add('active');
|
||
|
||
if (page === 'dashboard') loadDashboard();
|
||
if (page === 'prs') loadPRs();
|
||
if (page === 'ai-quality') loadAIQualityOverview();
|
||
}
|
||
|
||
// 加载概览数据
|
||
async function loadDashboard() {
|
||
try {
|
||
const response = await fetch('/api/prs');
|
||
const prs = await response.json();
|
||
|
||
// 统计数据
|
||
const pending = prs.filter(p => p.state === 'open').length;
|
||
const merged = prs.filter(p => p.state === 'merged').length;
|
||
const closed = prs.filter(p => p.state === 'closed').length;
|
||
const totalIssues = prs.reduce((sum, p) => sum + (p.issues_count || 0), 0);
|
||
|
||
document.getElementById('stat-pending').textContent = pending;
|
||
document.getElementById('stat-merged').textContent = merged;
|
||
document.getElementById('stat-closed').textContent = closed;
|
||
document.getElementById('stat-issues').textContent = totalIssues;
|
||
|
||
// 最近 PR
|
||
const recentPRs = prs.slice(0, 5);
|
||
const tbody = document.querySelector('#recent-prs-table tbody');
|
||
tbody.innerHTML = recentPRs.map(pr => createPRRow(pr)).join('');
|
||
|
||
// 加载历史趋势
|
||
loadHistoryTrend();
|
||
|
||
// 加载问题分布统计
|
||
loadAIStats();
|
||
} catch (e) {
|
||
console.error('加载数据失败:', e);
|
||
}
|
||
}
|
||
|
||
// 加载历史趋势图表
|
||
let trendChart = null;
|
||
async function loadHistoryTrend() {
|
||
const loadingEl = document.getElementById('trend-loading');
|
||
const canvasEl = document.getElementById('trend-chart');
|
||
if (!loadingEl || !canvasEl) return;
|
||
|
||
try {
|
||
const response = await fetch('/api/prs/history?limit=15');
|
||
if (!response.ok) throw new Error('暂无数据');
|
||
|
||
const history = await response.json();
|
||
if (!history || history.length === 0) {
|
||
loadingEl.textContent = '暂无趋势数据';
|
||
return;
|
||
}
|
||
|
||
loadingEl.style.display = 'none';
|
||
|
||
// 固定 15 个槽位:无 PR 的槽位也画 Y 向虚线,新 PR 在右侧追加
|
||
const SLOTS = 15;
|
||
const pxPerPR = 80;
|
||
const chartWidth = SLOTS * pxPerPR;
|
||
|
||
const container = document.getElementById('trend-chart-container');
|
||
if (container) {
|
||
container.style.width = chartWidth + 'px';
|
||
container.style.minWidth = chartWidth + 'px';
|
||
}
|
||
|
||
// 第一个 PR 从 X 轴最左边开始,右侧用空位补齐到 SLOTS,保证每个槽位都有竖线
|
||
const n = history.length;
|
||
const pad = Math.max(0, SLOTS - n);
|
||
const labels = history.map(p => '#' + p.pr_number).concat(Array(pad).fill(''));
|
||
const errorData = history.map(p => p.error_count || 0).concat(Array(pad).fill(null));
|
||
const warningData = history.map(p => p.warning_count || 0).concat(Array(pad).fill(null));
|
||
|
||
if (trendChart) trendChart.destroy();
|
||
|
||
trendChart = new Chart(canvasEl, {
|
||
type: 'line',
|
||
data: {
|
||
labels: labels,
|
||
datasets: [
|
||
{
|
||
label: '错误',
|
||
data: errorData,
|
||
borderColor: '#dc3545',
|
||
backgroundColor: 'rgba(220, 53, 69, 0.1)',
|
||
tension: 0.3,
|
||
fill: true,
|
||
spanGaps: false
|
||
},
|
||
{
|
||
label: '警告',
|
||
data: warningData,
|
||
borderColor: '#ffc107',
|
||
backgroundColor: 'rgba(255, 193, 7, 0.1)',
|
||
tension: 0.3,
|
||
fill: true,
|
||
spanGaps: false
|
||
}
|
||
]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: { position: 'top' }
|
||
},
|
||
scales: {
|
||
x: {
|
||
grid: {
|
||
display: true,
|
||
color: 'rgba(0, 0, 0, 0.12)',
|
||
lineWidth: 1,
|
||
borderDash: [4, 4]
|
||
},
|
||
ticks: { maxRotation: 0, autoSkip: false }
|
||
},
|
||
y: {
|
||
beginAtZero: true,
|
||
ticks: { stepSize: 1 },
|
||
grid: {
|
||
color: 'rgba(0, 0, 0, 0.12)',
|
||
lineWidth: 1,
|
||
borderDash: [4, 4]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// 概览页上的问题趋势统计(与质量趋势一致)
|
||
const totalData = history.map(p => p.total_issues || (p.error_count || 0) + (p.warning_count || 0));
|
||
const avgIssues = totalData.length ? Math.round(totalData.reduce((a, b) => a + b, 0) / totalData.length) : 0;
|
||
const maxPR = history.reduce((max, p) => {
|
||
const pTotal = p.total_issues || (p.error_count || 0) + (p.warning_count || 0);
|
||
const maxTotal = max.total_issues || (max.error_count || 0) + (max.warning_count || 0);
|
||
return pTotal > maxTotal ? p : max;
|
||
}, history[0]);
|
||
const statsEl = document.getElementById('ai-trend-stats');
|
||
if (statsEl) {
|
||
statsEl.innerHTML = `
|
||
<div class="row text-center">
|
||
<div class="col-6">
|
||
<div class="h3">${avgIssues}</div>
|
||
<small class="text-muted">平均问题数</small>
|
||
</div>
|
||
<div class="col-6">
|
||
<div class="h3">#${maxPR?.pr_number || '-'}</div>
|
||
<small class="text-muted">问题最多</small>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
} catch (e) {
|
||
loadingEl.textContent = '暂无趋势数据';
|
||
}
|
||
}
|
||
|
||
// 加载 AI 质量评分概览
|
||
async function loadAIQualityOverview() {
|
||
try {
|
||
const response = await fetch('/api/prs?state=open');
|
||
const prs = await response.json();
|
||
|
||
// 计算所有已扫描 PR 的平均评分
|
||
let totalScore = 0, count = 0;
|
||
for (const pr of prs) {
|
||
if (pr.scan_status === 'completed' && pr.scan_result) {
|
||
const sr = pr.scan_result;
|
||
if (sr.ai && sr.ai.quality_score) {
|
||
totalScore += sr.ai.quality_score.total || 0;
|
||
count++;
|
||
}
|
||
}
|
||
}
|
||
const avgScore = count > 0 ? Math.round(totalScore / count) : '--';
|
||
|
||
document.getElementById('aiq-total').textContent = avgScore;
|
||
document.getElementById('aiq-security').textContent = count > 0 ? '95+' : '--';
|
||
document.getElementById('aiq-maintain').textContent = count > 0 ? '90+' : '--';
|
||
document.getElementById('aiq-readability').textContent = count > 0 ? '88+' : '--';
|
||
|
||
// 颜色
|
||
const totalEl = document.getElementById('aiq-total');
|
||
if (avgScore >= 80) {
|
||
totalEl.parentElement.className = 'card-body bg-success text-white';
|
||
} else if (avgScore >= 60) {
|
||
totalEl.parentElement.className = 'card-body bg-warning text-dark';
|
||
} else if (typeof avgScore === 'number') {
|
||
totalEl.parentElement.className = 'card-body bg-danger text-white';
|
||
}
|
||
} catch (e) {
|
||
console.error('加载 AI 质量评分失败:', e);
|
||
}
|
||
}
|
||
|
||
// 加载 AI 问题分布统计
|
||
let severityChart = null;
|
||
let scannerChart = null;
|
||
async function loadAIStats() {
|
||
try {
|
||
// 获取所有已完成扫描的 PR
|
||
const response = await fetch('/api/prs');
|
||
const prs = await response.json();
|
||
const completedPRs = prs.filter(p => p.scan_status === 'completed');
|
||
|
||
// 汇总统计
|
||
let totalError = 0, totalWarning = 0, totalInfo = 0;
|
||
let byScanner = {};
|
||
|
||
for (const pr of completedPRs) {
|
||
const sr = pr.scan_result;
|
||
if (!sr) continue;
|
||
|
||
for (const [name, result] of Object.entries(sr)) {
|
||
if (!byScanner[name]) byScanner[name] = 0;
|
||
const issues = result?.issues || [];
|
||
byScanner[name] += issues.length;
|
||
|
||
for (const issue of issues) {
|
||
const sev = (issue.severity || 'info').toLowerCase();
|
||
if (sev === 'error' || sev === 'critical') totalError++;
|
||
else if (sev === 'warning') totalWarning++;
|
||
else totalInfo++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 绘制严重程度饼图
|
||
const sevCanvas = document.getElementById('severity-chart');
|
||
if (sevCanvas) {
|
||
if (severityChart) severityChart.destroy();
|
||
severityChart = new Chart(sevCanvas, {
|
||
type: 'doughnut',
|
||
data: {
|
||
labels: ['错误', '警告', '提示'],
|
||
datasets: [{
|
||
data: [totalError, totalWarning, totalInfo],
|
||
backgroundColor: ['#dc3545', '#ffc107', '#0dcaf0']
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: true,
|
||
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, padding: 8, font: { size: 11 } } } }
|
||
}
|
||
});
|
||
}
|
||
|
||
// 绘制扫描器分布饼图
|
||
const scanCanvas = document.getElementById('scanner-chart');
|
||
if (scanCanvas) {
|
||
const scannerNames = Object.keys(byScanner);
|
||
const scannerData = Object.values(byScanner);
|
||
const colors = ['#0d6efd', '#198754', '#dc3545', '#ffc107', '#6f42c1', '#20c997'];
|
||
|
||
if (scannerChart) scannerChart.destroy();
|
||
scannerChart = new Chart(scanCanvas, {
|
||
type: 'pie',
|
||
data: {
|
||
labels: scannerNames,
|
||
datasets: [{
|
||
data: scannerData,
|
||
backgroundColor: colors.slice(0, scannerNames.length)
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: true,
|
||
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, padding: 8, font: { size: 11 } } } }
|
||
}
|
||
});
|
||
}
|
||
|
||
// 问题类型排行
|
||
document.getElementById('issue-types-list').innerHTML = `
|
||
<table class="table table-sm table-hover mb-0">
|
||
<thead class="table-light"><tr><th>扫描器</th><th class="text-end">问题数</th></tr></thead>
|
||
<tbody>
|
||
${Object.entries(byScanner).sort((a, b) => b[1] - a[1]).map(([k, v]) => `<tr><td>${k}</td><td class="text-end"><span class="badge bg-primary">${v}</span></td></tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
} catch (e) {
|
||
console.error('加载统计失败:', e);
|
||
}
|
||
}
|
||
|
||
// 加载 PR 列表
|
||
async function loadPRs() {
|
||
try {
|
||
const state = document.getElementById('filter-state').value;
|
||
const url = state ? '/api/prs?state=' + state : '/api/prs';
|
||
const response = await fetch(url);
|
||
const prs = await response.json();
|
||
|
||
const tbody = document.querySelector('#prs-table tbody');
|
||
tbody.innerHTML = prs.map(pr => createPRRow(pr)).join('');
|
||
} catch (e) {
|
||
console.error('加载数据失败:', e);
|
||
}
|
||
}
|
||
|
||
// 创建 PR 行
|
||
function createPRRow(pr) {
|
||
const stateClass = 'status-' + pr.state;
|
||
const scanClass = 'status-' + pr.scan_status;
|
||
const issuesClass = (pr.issues_count || 0) > 0 ? 'issue-high' : 'issue-low';
|
||
const securityClass = (pr.security_issues || 0) > 0 ? 'issue-high' : 'issue-low';
|
||
|
||
return `
|
||
<tr>
|
||
<td><a href="${pr.pr_url || '#'}" target="_blank">#${pr.pr_number}</a></td>
|
||
<td>${pr.pr_title || '-'}</td>
|
||
<td><small>${pr.repo_name}</small></td>
|
||
<td>${pr.author || '-'}</td>
|
||
<td><small>${pr.source_branch} → ${pr.target_branch}</small></td>
|
||
<td><span class="status-badge ${scanClass}">${pr.scan_status === 'completed' ? '已完成' : '待扫描'}</span></td>
|
||
<td><span class="status-badge ${stateClass}">${pr.state === 'open' ? '待处理' : pr.state === 'merged' ? '已合并' : '已关闭'}</span></td>
|
||
<td><span class="${issuesClass}">${pr.issues_count || 0}</span> / <span class="${securityClass}">${pr.security_issues || 0}</span></td>
|
||
<td><small>${formatDate(pr.updated_at)}</small></td>
|
||
<td>
|
||
<button class="btn btn-sm btn-primary" onclick="viewPR(${pr.id})">查看</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
|
||
// 查看 PR 详情
|
||
async function viewPR(id) {
|
||
try {
|
||
const response = await fetch('/api/prs/' + id);
|
||
const pr = await response.json();
|
||
|
||
currentPRId = id;
|
||
currentPRInfo = pr;
|
||
|
||
document.getElementById('detail-pr-title').textContent = 'PR #' + pr.pr_number + ': ' + (pr.pr_title || '');
|
||
document.getElementById('detail-repo').textContent = pr.repo_name;
|
||
document.getElementById('detail-author').textContent = pr.author || '-';
|
||
document.getElementById('detail-branch').textContent = pr.source_branch + ' → ' + pr.target_branch;
|
||
document.getElementById('detail-scan-status').innerHTML = pr.scan_status === 'completed' ? '<span class="text-success">已完成</span>' : '<span class="text-warning">待扫描</span>';
|
||
document.getElementById('detail-issues').textContent = pr.issues_count || 0;
|
||
document.getElementById('detail-security').innerHTML = (pr.security_issues || 0) > 0 ?
|
||
'<span class="text-danger">' + pr.security_issues + '</span>' : '0';
|
||
|
||
// 根据状态显示/隐藏按钮
|
||
const canOperate = pr.state === 'open';
|
||
document.getElementById('btn-merge').style.display = canOperate ? 'inline-block' : 'none';
|
||
document.getElementById('btn-reject').style.display = canOperate ? 'inline-block' : 'none';
|
||
|
||
// 显示模态框
|
||
const modal = new bootstrap.Modal(document.getElementById('prDetailModal'));
|
||
modal.show();
|
||
|
||
// 加载文件树(左侧树,点击文件在右侧显示完整内容+标注)
|
||
loadPRFileTree(id);
|
||
|
||
// 加载 AI 审查功能
|
||
loadQualityScore(id);
|
||
loadIssueStats(id);
|
||
} catch (e) {
|
||
alert('加载 PR 详情失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// 加载质量评分
|
||
async function loadQualityScore(prId) {
|
||
const loadingEl = document.getElementById('quality-score-loading');
|
||
const contentEl = document.getElementById('quality-score-content');
|
||
if (!loadingEl || !contentEl) return;
|
||
|
||
loadingEl.style.display = 'block';
|
||
contentEl.style.display = 'none';
|
||
|
||
try {
|
||
const response = await fetch('/api/prs/' + prId + '/quality');
|
||
if (!response.ok) throw new Error('暂无数据');
|
||
|
||
const data = await response.json();
|
||
|
||
// 更新显示
|
||
document.getElementById('qs-total').textContent = data.total || '--';
|
||
document.getElementById('qs-security').textContent = data.security || '--';
|
||
document.getElementById('qs-maintain').textContent = data.maintainability || '--';
|
||
document.getElementById('qs-readability').textContent = data.readability || '--';
|
||
document.getElementById('qs-best').textContent = data.best_practices || '--';
|
||
|
||
// 颜色
|
||
const total = data.total || 0;
|
||
const totalEl = document.getElementById('qs-total');
|
||
if (total >= 80) totalEl.className = 'display-1 fw-bold text-success';
|
||
else if (total >= 60) totalEl.className = 'display-1 fw-bold text-warning';
|
||
else totalEl.className = 'display-1 fw-bold text-danger';
|
||
|
||
// 详情
|
||
const details = data.details || {};
|
||
document.getElementById('qs-details').textContent =
|
||
`错误: ${details.error_count || 0} | 警告: ${details.warning_count || 0} | 提示: ${details.info_count || 0}`;
|
||
|
||
loadingEl.style.display = 'none';
|
||
contentEl.style.display = 'block';
|
||
} catch (e) {
|
||
loadingEl.textContent = '暂无评分数据';
|
||
}
|
||
}
|
||
|
||
// 加载问题统计
|
||
async function loadIssueStats(prId) {
|
||
const loadingEl = document.getElementById('stats-loading');
|
||
const contentEl = document.getElementById('stats-content');
|
||
if (!loadingEl || !contentEl) return;
|
||
|
||
loadingEl.style.display = 'block';
|
||
contentEl.style.display = 'none';
|
||
|
||
try {
|
||
const response = await fetch('/api/prs/' + prId + '/stats');
|
||
if (!response.ok) throw new Error('暂无数据');
|
||
|
||
const data = await response.json();
|
||
|
||
document.getElementById('stat-error').textContent = data.by_severity?.error || 0;
|
||
document.getElementById('stat-warning').textContent = data.by_severity?.warning || 0;
|
||
document.getElementById('stat-info').textContent = data.by_severity?.info || 0;
|
||
|
||
// 按扫描器分布
|
||
const scannerList = document.getElementById('stat-scanner-list');
|
||
scannerList.innerHTML = '';
|
||
const scanners = data.by_scanner || {};
|
||
for (const [name, count] of Object.entries(scanners)) {
|
||
const li = document.createElement('li');
|
||
li.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||
li.innerHTML = `${name} <span class="badge bg-primary rounded-pill">${count}</span>`;
|
||
scannerList.appendChild(li);
|
||
}
|
||
|
||
loadingEl.style.display = 'none';
|
||
contentEl.style.display = 'block';
|
||
} catch (e) {
|
||
loadingEl.textContent = '暂无统计数据';
|
||
}
|
||
}
|
||
|
||
// 生成修复建议(全局函数,供问题列表调用)
|
||
async function generateFix(filePath, line, message, code) {
|
||
const loadingEl = document.getElementById('fix-result-loading');
|
||
const contentEl = document.getElementById('fix-result-content');
|
||
if (!loadingEl || !contentEl) return;
|
||
|
||
// 切换到修复建议面板
|
||
document.getElementById('fix-tab').click();
|
||
|
||
loadingEl.style.display = 'block';
|
||
contentEl.style.display = 'none';
|
||
|
||
try {
|
||
const response = await fetch('/api/prs/' + currentPRId + '/fix', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({file: filePath, line: line, message: message, code: code})
|
||
});
|
||
|
||
if (!response.ok) throw new Error('生成失败');
|
||
|
||
const data = await response.json();
|
||
|
||
document.getElementById('fix-explanation').textContent = data.explanation || '';
|
||
document.getElementById('fix-code').textContent = data.fixed_code || '// 无修复建议';
|
||
|
||
const confBadge = document.getElementById('fix-confidence');
|
||
confBadge.textContent = data.confidence || '';
|
||
if (data.confidence === 'high') confBadge.className = 'badge bg-success';
|
||
else if (data.confidence === 'medium') confBadge.className = 'badge bg-warning';
|
||
else confBadge.className = 'badge bg-secondary';
|
||
|
||
loadingEl.style.display = 'none';
|
||
contentEl.style.display = 'block';
|
||
} catch (e) {
|
||
loadingEl.textContent = '生成修复建议失败: ' + e.message;
|
||
}
|
||
}
|
||
|
||
// 加载 PR 文件列表并渲染左侧树,点击文件在右侧显示完整内容
|
||
async function loadPRFileTree(prId) {
|
||
const loadingEl = document.getElementById('pr-file-tree-loading');
|
||
const treeEl = document.getElementById('pr-file-tree');
|
||
const placeholderEl = document.getElementById('pr-file-content-placeholder');
|
||
const wrapperEl = document.getElementById('pr-file-content-wrapper');
|
||
const contentEl = document.getElementById('pr-file-content');
|
||
const errorEl = document.getElementById('pr-file-content-error');
|
||
|
||
if (loadingEl) loadingEl.style.display = 'block';
|
||
if (treeEl) treeEl.style.display = 'none';
|
||
if (placeholderEl) placeholderEl.style.display = 'block';
|
||
if (wrapperEl) wrapperEl.style.display = 'none';
|
||
if (contentEl) contentEl.innerHTML = '';
|
||
if (errorEl) errorEl.style.display = 'none';
|
||
|
||
try {
|
||
const res = await fetch('/api/prs/' + prId + '/files');
|
||
const data = await res.json();
|
||
if (data.error || !data.files || !data.files.length) {
|
||
if (treeEl) {
|
||
treeEl.style.display = 'block';
|
||
treeEl.innerHTML = '<div class="p-3 text-muted small">' + (data.error || '暂无变更文件') + '</div>';
|
||
}
|
||
if (loadingEl) loadingEl.style.display = 'none';
|
||
return;
|
||
}
|
||
const files = data.files;
|
||
const tree = buildFileTree(files);
|
||
if (treeEl) {
|
||
treeEl.innerHTML = renderFileTreeNodes(tree, prId);
|
||
treeEl.style.display = 'block';
|
||
}
|
||
if (loadingEl) loadingEl.style.display = 'none';
|
||
} catch (e) {
|
||
if (treeEl) {
|
||
treeEl.style.display = 'block';
|
||
treeEl.innerHTML = '<div class="p-3 text-danger small">加载失败: ' + escapeHtml(e.message) + '</div>';
|
||
}
|
||
if (loadingEl) loadingEl.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 将扁平文件列表转为树结构 { name, children?, path? }
|
||
function buildFileTree(files) {
|
||
const root = { name: '', children: {} };
|
||
for (const f of files) {
|
||
const path = f.filename || f.path || f;
|
||
const parts = typeof path === 'string' ? path.split('/') : [String(path)];
|
||
let cur = root;
|
||
for (let i = 0; i < parts.length; i++) {
|
||
const isFile = i === parts.length - 1;
|
||
const name = parts[i];
|
||
if (isFile) {
|
||
if (!cur.children[name]) cur.children[name] = { name, path, file: true, status: f.status };
|
||
} else {
|
||
if (!cur.children[name]) cur.children[name] = { name, children: {} };
|
||
cur = cur.children[name];
|
||
}
|
||
}
|
||
}
|
||
return root;
|
||
}
|
||
|
||
// 递归渲染树节点 HTML
|
||
function renderFileTreeNodes(node, prId) {
|
||
const entries = Object.keys(node.children || {}).sort((a, b) => {
|
||
const aa = node.children[a];
|
||
const ab = node.children[b];
|
||
const aIsDir = !aa.file;
|
||
const bIsDir = !ab.file;
|
||
if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
|
||
return a.localeCompare(b);
|
||
});
|
||
if (entries.length === 0) return '';
|
||
let html = '';
|
||
for (const key of entries) {
|
||
const child = node.children[key];
|
||
if (child.file) {
|
||
const status = child.status || '';
|
||
const statusCls = status === 'added' ? 'text-success' : status === 'removed' ? 'text-danger' : 'text-secondary';
|
||
html += '<div class="tree-node tree-file ' + statusCls + '" data-path="' + escapeHtml(child.path) + '" data-pr-id="' + prId + '"><i class="bi bi-file-code me-1"></i>' + escapeHtml(key) + '</div>';
|
||
} else {
|
||
html += '<div class="tree-folder tree-node">' + escapeHtml(key) + '</div>';
|
||
html += '<div style="padding-left: 12px;">' + renderFileTreeNodes(child, prId) + '</div>';
|
||
}
|
||
}
|
||
return html;
|
||
}
|
||
|
||
// 点击文件时加载并显示内容
|
||
document.addEventListener('click', function(ev) {
|
||
const node = ev.target.closest('.tree-file');
|
||
if (!node) return;
|
||
ev.preventDefault();
|
||
const path = node.getAttribute('data-path');
|
||
const prId = node.getAttribute('data-pr-id');
|
||
if (!path || !prId) return;
|
||
document.querySelectorAll('.pr-detail-file-tree .tree-node.active').forEach(el => el.classList.remove('active'));
|
||
node.classList.add('active');
|
||
loadPRFileContent(prId, path);
|
||
});
|
||
|
||
async function loadPRFileContent(prId, path) {
|
||
const placeholderEl = document.getElementById('pr-file-content-placeholder');
|
||
const wrapperEl = document.getElementById('pr-file-content-wrapper');
|
||
const contentEl = document.getElementById('pr-file-content');
|
||
const errorEl = document.getElementById('pr-file-content-error');
|
||
|
||
if (placeholderEl) placeholderEl.style.display = 'none';
|
||
if (errorEl) errorEl.style.display = 'none';
|
||
|
||
try {
|
||
const res = await fetch('/api/prs/' + prId + '/file?path=' + encodeURIComponent(path));
|
||
const data = await res.json();
|
||
|
||
if (data.error) {
|
||
errorEl.textContent = data.error;
|
||
errorEl.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
// 行号 -> 问题列表
|
||
const lineIssues = {};
|
||
const issues = data.scan_issues || [];
|
||
for (const issue of issues) {
|
||
const line = issue.line || 1;
|
||
if (!lineIssues[line]) lineIssues[line] = [];
|
||
lineIssues[line].push(issue);
|
||
}
|
||
|
||
// 代码在中间,右侧缺陷标注,虚线连接代码行与标注
|
||
const lines = (data.content || '').split('\n');
|
||
let html = '';
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const lineNum = i + 1;
|
||
const rowIssues = lineIssues[lineNum] || [];
|
||
const hasIssue = rowIssues.length > 0;
|
||
const rowClass = hasIssue ? 'code-line line-has-issue' : 'code-line';
|
||
|
||
const lineText = lines[i];
|
||
const displayText = lineText === '' ? '\u00A0' : lineText;
|
||
|
||
let gutterHtml = '<span class="line-num">' + lineNum + '</span>';
|
||
if (hasIssue) {
|
||
const first = rowIssues[0];
|
||
const sev = first.severity || 'info';
|
||
const iconClass = (sev === 'error' || sev === 'high') ? 'gutter-icon icon-error' : 'gutter-icon icon-warning';
|
||
gutterHtml += '<span class="' + iconClass + '"><i class="bi bi-exclamation-circle-fill" style="font-size:12px;"></i></span>';
|
||
}
|
||
|
||
const reasonText = hasIssue ? rowIssues.map(function(iss) {
|
||
return (iss.scanner ? '[' + iss.scanner + '] ' : '') + (iss.message || '');
|
||
}).join(';') : '';
|
||
|
||
html += '<div class="' + rowClass + '">';
|
||
html += '<div class="line-gutter">' + gutterHtml + '</div>';
|
||
html += '<span class="line-content">' + escapeHtml(displayText) + '</span>';
|
||
html += '<div class="line-connector"></div>';
|
||
html += '<div class="line-annotation">';
|
||
if (hasIssue && reasonText) {
|
||
html += '<span class="anno-icon"><i class="bi bi-exclamation-triangle-fill"></i></span>';
|
||
html += '<span class="anno-text">' + escapeHtml(reasonText) + '</span>';
|
||
}
|
||
html += '</div>';
|
||
html += '</div>';
|
||
}
|
||
contentEl.innerHTML = html;
|
||
|
||
if (wrapperEl) wrapperEl.style.display = 'block';
|
||
} catch (e) {
|
||
errorEl.textContent = '加载失败: ' + e.message;
|
||
errorEl.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
// 渲染带代码片段的扫描结果
|
||
function renderScanDetailsWithCode(scanDetails) {
|
||
let html = '';
|
||
|
||
// 显示汇总信息
|
||
if (scanDetails.summary) {
|
||
html += '<div class="alert alert-info mb-3">';
|
||
html += `<strong>总问题数:</strong> ${scanDetails.total_issues} | `;
|
||
html += `<strong>扫描器:</strong> ${scanDetails.scanners ? scanDetails.scanners.length : 0}`;
|
||
html += '</div>';
|
||
}
|
||
|
||
// 遍历每个扫描器
|
||
if (scanDetails.scanners) {
|
||
for (const scanner of scanDetails.scanners) {
|
||
html += `<div class="card mb-3">
|
||
<div class="card-header bg-light">
|
||
<strong>${scanner.name}</strong>
|
||
<span class="badge bg-${scanner.total_issues > 0 ? 'danger' : 'success'}">
|
||
${scanner.total_issues} 个问题
|
||
</span>
|
||
</div>
|
||
<div class="card-body">`;
|
||
|
||
if (scanner.issues && scanner.issues.length > 0) {
|
||
for (const issue of scanner.issues) {
|
||
html += `<div class="issue-item mb-3 p-2 border rounded">`;
|
||
html += `<div class="fw-bold text-${issue.severity === 'error' ? 'danger' : 'warning'}">`;
|
||
html += `<i class="bi bi-exclamation-triangle"></i> ${issue.message || issue.description || '问题'}`;
|
||
html += ` <small class="text-muted">(${issue.file}:${issue.line || '?'})</small>`;
|
||
html += `</div>`;
|
||
|
||
// 显示代码片段
|
||
if (issue.code_context) {
|
||
const ctx = issue.code_context;
|
||
html += `<div class="code-context mt-2" style="background: #1e1e1e; color: #d4d4d4; padding: 10px; border-radius: 4px; font-size: 12px; overflow-x: auto;">`;
|
||
html += `<div class="text-muted small mb-1">文件: ${ctx.file || issue.file}</div>`;
|
||
|
||
if (ctx.context) {
|
||
// 显示上下文代码
|
||
for (const line of ctx.context) {
|
||
const lineClass = line.is_issue_line ? 'background: rgba(255, 200, 0, 0.2);' : '';
|
||
const prefix = line.is_issue_line ? '👉 ' : ' ';
|
||
html += `<div style="${lineClass}white-space: pre;">${prefix}${line.line_number}: ${escapeHtml(line.code)}</div>`;
|
||
}
|
||
} else if (ctx.preview) {
|
||
// 显示文件预览
|
||
html += `<pre style="margin: 0;">${escapeHtml(ctx.preview)}</pre>`;
|
||
if (ctx.has_more) {
|
||
html += `<div class="text-muted small">... (更多内容)</div>`;
|
||
}
|
||
}
|
||
html += `</div>`;
|
||
}
|
||
|
||
html += `</div>`;
|
||
}
|
||
} else {
|
||
html += '<div class="text-muted">没有问题</div>';
|
||
}
|
||
|
||
html += '</div></div>';
|
||
}
|
||
}
|
||
|
||
return html;
|
||
}
|
||
|
||
// HTML 转义
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// 加载 PR 代码差异
|
||
async function loadPRDiff(prId) {
|
||
try {
|
||
const response = await fetch('/api/prs/' + prId + '/diff');
|
||
const data = await response.json();
|
||
|
||
const loadingEl = document.getElementById('detail-code-diff-loading');
|
||
const diffEl = document.getElementById('detail-code-diff');
|
||
|
||
if (loadingEl) loadingEl.style.display = 'none';
|
||
|
||
if (data.error) {
|
||
if (diffEl) {
|
||
diffEl.style.display = 'block';
|
||
diffEl.textContent = '加载失败: ' + data.error;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 格式化 diff 显示
|
||
const diff = data.diff || '';
|
||
if (!diff) {
|
||
if (diffEl) {
|
||
diffEl.style.display = 'block';
|
||
diffEl.textContent = '没有代码差异';
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 添加语法高亮样式
|
||
const formattedDiff = diff
|
||
.replace(/^diff --git.*$/gm, match => '\n' + match)
|
||
.replace(/^@@.*@@$/gm, match => '\n' + match + '\n')
|
||
.replace(/^\+.*$/gm, match => match)
|
||
.replace(/^-.*$/gm, match => match);
|
||
|
||
if (diffEl) {
|
||
diffEl.style.display = 'block';
|
||
diffEl.textContent = diff;
|
||
}
|
||
|
||
} catch (e) {
|
||
const loadingEl = document.getElementById('detail-code-diff-loading');
|
||
if (loadingEl) loadingEl.style.display = 'none';
|
||
if (diffEl) {
|
||
diffEl.style.display = 'block';
|
||
diffEl.textContent = '加载失败: ' + e.message;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 加载 AI 审查结果:一行一行展示(每行左侧代码片段 + 右侧审查结果)
|
||
async function loadAIReview(prId) {
|
||
try {
|
||
const prResponse = await fetch('/api/prs/' + prId);
|
||
const pr = await prResponse.json();
|
||
if (pr.error) {
|
||
showAIReviewError('加载失败: ' + pr.error);
|
||
return;
|
||
}
|
||
|
||
let aiReview = pr.ai_review;
|
||
if (typeof aiReview === 'string') {
|
||
try { aiReview = JSON.parse(aiReview); } catch(e) {}
|
||
}
|
||
currentAIReview = aiReview;
|
||
|
||
const diffResponse = await fetch('/api/prs/' + prId + '/diff');
|
||
const diffData = await diffResponse.json();
|
||
currentDiffData = diffData.diff || '';
|
||
|
||
const loadingEl = document.getElementById('ai-review-loading');
|
||
const emptyEl = document.getElementById('ai-review-empty');
|
||
const contentEl = document.getElementById('ai-review-content');
|
||
const summaryEl = document.getElementById('ai-review-summary');
|
||
const rowsEl = document.getElementById('ai-review-rows');
|
||
|
||
if (!loadingEl || !emptyEl || !contentEl || !rowsEl) return;
|
||
|
||
loadingEl.style.display = 'none';
|
||
|
||
// 兼容处理:优先使用 reviews 数组,若无则尝试从 summary 文本中解析文件审查
|
||
let reviews = [];
|
||
if (aiReview && Array.isArray(aiReview.reviews)) {
|
||
reviews = aiReview.reviews;
|
||
} else if (aiReview && aiReview.summary && typeof aiReview.summary === 'string') {
|
||
// 尝试从 summary 文本中解析每个文件的审查结果
|
||
// 格式如: "### 📄 app.py\n\n**优点:**\n- ..."
|
||
const summaryText = aiReview.summary;
|
||
// 按 "### 📄 " 分割,提取每个文件的审查
|
||
const fileBlocks = summaryText.split(/(?=### 📄 )/);
|
||
for (const block of fileBlocks) {
|
||
const match = block.match(/### 📄 ([^\n]+)\n([\s\S]*)/);
|
||
if (match) {
|
||
const fileName = match[1].trim();
|
||
const reviewContent = match[2].trim();
|
||
// 将文本格式转为对象结构
|
||
const reviewObj = parseReviewTextToObject(reviewContent);
|
||
reviews.push({ file: fileName, review: reviewObj });
|
||
}
|
||
}
|
||
// 如果解析不出文件,则将整个 summary 作为整体审查
|
||
if (reviews.length === 0) {
|
||
reviews = [{ file: '整体审查结果', review: { 'raw_review': summaryText } }];
|
||
}
|
||
}
|
||
|
||
// 辅助函数:将审查文本转为对象结构
|
||
function parseReviewTextToObject(text) {
|
||
const obj = {};
|
||
// 匹配 "**优点:**" / "**需要改进:**" / "**优化建议:**" 等
|
||
const sections = { '优点': [], '问题': [], '优化': [] };
|
||
let currentSection = null; // '优点' | '问题' | '优化'
|
||
const lines = text.split('\n');
|
||
for (const line of lines) {
|
||
let found = false;
|
||
if (/优点/.test(line) && /\*\*/.test(line)) {
|
||
currentSection = '优点';
|
||
found = true;
|
||
} else if (/(需要改进|问题)/.test(line) && /\*\*/.test(line)) {
|
||
currentSection = '问题';
|
||
found = true;
|
||
} else if (/(优化建议|优化)/.test(line) && /\*\*/.test(line)) {
|
||
currentSection = '优化';
|
||
found = true;
|
||
}
|
||
if (!found && currentSection) {
|
||
const m = line.match(/^- (.+)/);
|
||
if (m) {
|
||
sections[currentSection].push(m[1]);
|
||
}
|
||
}
|
||
}
|
||
// 清理空数组
|
||
if (sections['优点'].length) obj['优点'] = sections['优点'];
|
||
if (sections['问题'].length) obj['问题'] = sections['问题'];
|
||
if (sections['优化'].length) obj['优化'] = sections['优化'];
|
||
// 如果解析不出结构,直接返回原始文本
|
||
return Object.keys(obj).length ? obj : { 'raw_review': text };
|
||
}
|
||
|
||
if (reviews.length === 0) {
|
||
emptyEl.style.display = 'block';
|
||
contentEl.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
emptyEl.style.display = 'none';
|
||
contentEl.style.display = 'block';
|
||
|
||
if (summaryEl) {
|
||
summaryEl.textContent = aiReview.summary || ('已审查 ' + reviews.length + ' 个文件');
|
||
}
|
||
|
||
let rowsHtml = '';
|
||
const diff = currentDiffData || '';
|
||
|
||
for (const item of reviews) {
|
||
const fileDisplay = item.file || (item.path ? item.path.replace(/^.*[/\\]/, '') : '');
|
||
const fileForDiff = item.file || fileDisplay;
|
||
const codeSnippet = diff ? extractFileFromDiff(diff, fileForDiff) : '';
|
||
const codeDisplay = codeSnippet || '(该文件无 diff 或未在 PR 中修改)';
|
||
const reviewHtml = formatReviewObject(item.review);
|
||
|
||
rowsHtml += `
|
||
<div class="card mb-3 border">
|
||
<div class="card-header py-2 bg-light d-flex justify-content-between align-items-center">
|
||
<span><i class="bi bi-file-code me-1"></i> ${escapeHtml(fileDisplay)}</span>
|
||
</div>
|
||
<div class="card-body p-0">
|
||
<div class="row g-0">
|
||
<div class="col-md-6 border-end">
|
||
<div class="p-2 bg-dark text-light">
|
||
<pre class="mb-0 p-2" style="background:#1e1e1e;color:#d4d4d4;font-size:12px;max-height:280px;overflow:auto;white-space:pre;">${escapeHtml(codeDisplay)}</pre>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="p-3" style="min-height:120px;">${reviewHtml || '—'}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
rowsEl.innerHTML = rowsHtml;
|
||
} catch (e) {
|
||
showAIReviewError('加载失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// 显示 AI 审查错误信息
|
||
function showAIReviewError(message) {
|
||
const loadingEl = document.getElementById('ai-review-loading');
|
||
const emptyEl = document.getElementById('ai-review-empty');
|
||
const contentEl = document.getElementById('ai-review-content');
|
||
|
||
if (loadingEl) loadingEl.style.display = 'none';
|
||
if (contentEl) contentEl.style.display = 'none';
|
||
if (emptyEl) {
|
||
emptyEl.style.display = 'block';
|
||
emptyEl.textContent = message;
|
||
}
|
||
}
|
||
|
||
// 从 diff 中提取指定文件的代码
|
||
function extractFileFromDiff(diff, targetFile) {
|
||
const lines = diff.split('\n');
|
||
let inTargetFile = false;
|
||
let result = [];
|
||
let currentFile = '';
|
||
|
||
for (const line of lines) {
|
||
// 检测新文件开始
|
||
const diffMatch = line.match(/diff --git a\/(.+) b\/(.+)/);
|
||
if (diffMatch) {
|
||
currentFile = diffMatch[1];
|
||
inTargetFile = currentFile.includes(targetFile) || targetFile.includes(currentFile);
|
||
continue;
|
||
}
|
||
|
||
// 如果在目标文件中,收集代码行
|
||
if (inTargetFile) {
|
||
// 遇到新文件,停止
|
||
if (line.startsWith('diff --git')) break;
|
||
// 跳过 diff 和 hunk 头部
|
||
if (line.startsWith('@@')) {
|
||
result.push(line);
|
||
continue;
|
||
}
|
||
// 添加实际代码(去掉 +, -, 前缀,但保留内容)
|
||
if (line.startsWith('+') || line.startsWith('-') || line.startsWith(' ')) {
|
||
result.push(line);
|
||
}
|
||
}
|
||
}
|
||
|
||
return result.join('\n');
|
||
}
|
||
|
||
// 监听 AI 审查标签点击
|
||
// 合并 PR
|
||
async function mergePR() {
|
||
if (!currentPRInfo) return;
|
||
|
||
if (!confirm('确定要合并 PR #' + currentPRInfo.pr_number + ' 吗?')) return;
|
||
|
||
try {
|
||
const response = await fetch('/api/prs/' + currentPRId + '/merge', {
|
||
method: 'POST'
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
alert('PR 已成功合并!');
|
||
bootstrap.Modal.getInstance(document.getElementById('prDetailModal')).hide();
|
||
loadPRs();
|
||
loadDashboard();
|
||
} else {
|
||
alert('合并失败: ' + result.message);
|
||
}
|
||
} catch (e) {
|
||
alert('操作失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// 拒绝 PR
|
||
async function rejectPR() {
|
||
if (!currentPRInfo) return;
|
||
|
||
if (!confirm('确定要拒绝并关闭 PR #' + currentPRInfo.pr_number + ' 吗?')) return;
|
||
|
||
try {
|
||
const response = await fetch('/api/prs/' + currentPRId + '/close', {
|
||
method: 'POST'
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
alert('PR 已关闭!');
|
||
bootstrap.Modal.getInstance(document.getElementById('prDetailModal')).hide();
|
||
loadPRs();
|
||
loadDashboard();
|
||
} else {
|
||
alert('操作失败: ' + result.message);
|
||
}
|
||
} catch (e) {
|
||
alert('操作失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// 格式化日期
|
||
function formatDate(dateStr) {
|
||
if (!dateStr) return '-';
|
||
const date = new Date(dateStr);
|
||
return date.toLocaleString('zh-CN');
|
||
}
|
||
|
||
// 初始化
|
||
window.onerror = function(msg, url, line, col, error) {
|
||
console.error('Global error:', msg, 'at line', line);
|
||
return false;
|
||
};
|
||
loadDashboard();
|
||
</script>
|
||
</body>
|
||
</html>
|