14 KiB
14 KiB
Step 17 — Phase 5.3: 审查面板(Webview)
依赖: Step 12, 15
参考设计: §7, §14
目标
实现 Webview 审查报告面板:三 Tab 切换、统计卡片、问题列表、postMessage 通信、降级提示。
新建文件
| # | 文件 | 说明 |
|---|---|---|
| 1 | src/panel/webview.ts |
ReviewPanel 类(Webview 管理 + HTML 生成) |
面板参考
UI 预览文件: docs/superpowers/specs/review-panel-preview.html
src/panel/webview.ts
import * as vscode from 'vscode';
import { MergedReport } from '../merger/merger';
interface PanelMessage {
type: 'navigate' | 'rerun' | 'export' | 'settings' | 'fix' | 'fixAll';
line?: number;
ruleId?: string;
source?: 'linter' | 'custom' | 'ai';
}
export class ReviewPanel {
public static currentPanel: ReviewPanel | undefined;
private readonly panel: vscode.WebviewPanel;
private disposables: vscode.Disposable[] = [];
private constructor(
private readonly extensionUri: vscode.Uri,
column: vscode.ViewColumn
) {
this.panel = vscode.window.createWebviewPanel(
'codeReviewer.reviewPanel',
'代码审查报告',
column,
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [],
}
);
this.panel.onDidDispose(() => this.dispose(), null, this.disposables);
this.panel.webview.onDidReceiveMessage(
(message: PanelMessage) => this.handleMessage(message),
null,
this.disposables
);
}
static createOrShow(extensionUri: vscode.Uri, column?: vscode.ViewColumn): ReviewPanel {
if (ReviewPanel.currentPanel) {
ReviewPanel.currentPanel.panel.reveal(column);
return ReviewPanel.currentPanel;
}
ReviewPanel.currentPanel = new ReviewPanel(extensionUri, column ?? vscode.ViewColumn.Two);
return ReviewPanel.currentPanel;
}
update(report: MergedReport): void {
this.panel.webview.html = this.buildHtml(report);
}
private buildHtml(report: MergedReport): string {
const total = report.linterCount + report.customRuleCount + report.aiCount;
const errorCount = report.linterDiagnostics.filter(d => d.severity === 'error').length
+ report.customRuleDiagnostics.filter(d => d.severity === 'error').length
+ report.aiFindings.filter(f => f.severity === 'error').length;
const warnCount = report.linterDiagnostics.filter(d => d.severity === 'warning').length
+ report.customRuleDiagnostics.filter(d => d.severity === 'warning').length
+ report.aiFindings.filter(f => f.severity === 'warning').length;
const infoCount = total - errorCount - warnCount;
const fileName = report.filePath.split(/[/\\]/).pop() ?? '';
const degradedBanner = report.degraded
? `<div class="banner ${report.errors.length > 0 ? 'banner-error' : 'banner-warn'}">
${report.errors.length > 0 ? '⚠ AI 审查失败' : '⚠ 部分 AI 功能不可用'}
${report.errors.join('; ')}
</div>`
: '';
return `<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>代码审查报告</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: var(--vscode-font-family); font-size: var(--vscode-font-size); color: var(--vscode-foreground); background: var(--vscode-editor-background); padding: 16px; }
.header { margin-bottom: 16px; }
.header h1 { font-size: 18px; margin-bottom: 4px; }
.header .meta { font-size: 12px; color: var(--vscode-descriptionForeground); }
.banner { padding: 8px 12px; border-radius: 4px; margin-bottom: 12px; font-size: 13px; }
.banner-warn { background: #332B00; border: 1px solid #665C00; color: #FFF3B0; }
.banner-error { background: #330000; border: 1px solid #660000; color: #FFB0B0; }
.stats { display: flex; gap: 12px; margin-bottom: 16px; }
.stat-card { flex: 1; padding: 12px; border-radius: 6px; text-align: center; background: var(--vscode-sideBar-background); }
.stat-card .num { font-size: 24px; font-weight: 600; }
.stat-card .label { font-size: 12px; color: var(--vscode-descriptionForeground); margin-top: 2px; }
.stat-total .num { color: var(--vscode-foreground); }
.stat-error .num { color: #E06C75; }
.stat-warn .num { color: #D19A66; }
.stat-info .num { color: #61AFEF; }
.tabs { display: flex; gap: 0; margin-bottom: 12px; border-bottom: 1px solid var(--vscode-panel-border); }
.tab { padding: 8px 16px; cursor: pointer; border: none; background: none; color: var(--vscode-descriptionForeground); font-family: var(--vscode-font-family); font-size: 13px; border-bottom: 2px solid transparent; }
.tab.active { color: var(--vscode-foreground); border-bottom-color: #7C3AED; }
.tab .count { margin-left: 6px; font-size: 11px; opacity: 0.7; }
.issue-list { display: none; }
.issue-list.active { display: block; }
.issue { padding: 8px 12px; border-radius: 4px; margin-bottom: 6px; background: var(--vscode-sideBar-background); cursor: pointer; display: flex; justify-content: space-between; align-items: flex-start; }
.issue:hover { background: var(--vscode-list-hoverBackground); }
.issue-left { flex: 1; }
.issue-title { font-size: 13px; margin-bottom: 2px; }
.issue-detail { font-size: 12px; color: var(--vscode-descriptionForeground); }
.issue-actions { display: flex; gap: 4px; flex-shrink: 0; }
.btn { padding: 2px 8px; border-radius: 3px; border: 1px solid var(--vscode-button-border); background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); cursor: pointer; font-size: 11px; }
.btn:hover { background: var(--vscode-button-secondaryHoverBackground); }
.btn-primary { background: #7C3AED; border-color: #7C3AED; color: #fff; }
.btn-primary:hover { background: #6D28D9; }
.severity { display: inline-block; width: 16px; text-align: center; }
.actions { display: flex; gap: 8px; margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--vscode-panel-border); }
.empty { text-align: center; padding: 24px; color: var(--vscode-descriptionForeground); font-size: 13px; }
</style>
</head>
<body>
<div class="header">
<h1>📋 代码审查报告</h1>
<div class="meta">${fileName} · ${report.language} · ${(report.duration / 1000).toFixed(1)}s</div>
</div>
${degradedBanner}
<div class="stats">
<div class="stat-card stat-total"><div class="num">${total}</div><div class="label">总计</div></div>
<div class="stat-card stat-error"><div class="num">${errorCount}</div><div class="label">错误</div></div>
<div class="stat-card stat-warn"><div class="num">${warnCount}</div><div class="label">警告</div></div>
<div class="stat-card stat-info"><div class="num">${infoCount}</div><div class="label">建议</div></div>
</div>
<div class="tabs">
<button class="tab active" onclick="switchTab('linter')">🔧 静态分析 <span class="count">${report.linterCount}</span></button>
<button class="tab" onclick="switchTab('custom')">📋 自定义规则 <span class="count">${report.customRuleCount}</span></button>
<button class="tab" onclick="switchTab('ai')">🤖 AI 审查 <span class="count">${report.aiCount}</span></button>
</div>
<div id="tab-linter" class="issue-list active">
${this.buildLinterList(report)}
</div>
<div id="tab-custom" class="issue-list">
${this.buildCustomList(report)}
</div>
<div id="tab-ai" class="issue-list">
${this.buildAIList(report)}
</div>
<div class="actions">
<button class="btn btn-primary" onclick="send('rerun')">🔄 重新审查</button>
<button class="btn" onclick="send('export')">📄 导出</button>
<button class="btn" onclick="send('settings')">⚙️ 设置</button>
<button class="btn" onclick="send('fixAll')">🔧 批量修复</button>
</div>
<script>
const vscode = acquireVsCodeApi();
function send(type, line, ruleId, source) {
vscode.postMessage({ type, line, ruleId, source });
}
function switchTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.issue-list').forEach(l => l.classList.remove('active'));
event.target.classList.add('active');
document.getElementById('tab-' + name).classList.add('active');
}
</script>
</body>
</html>`;
}
private buildLinterList(report: MergedReport): string {
if (report.linterDiagnostics.length === 0) {
return '<div class="empty">✅ 静态分析未发现问题</div>';
}
return report.linterDiagnostics.map((d, i) => `
<div class="issue" onclick="send('navigate', ${d.range.start.line}, '${this.escape(d.ruleId)}', 'linter')">
<div class="issue-left">
<div class="issue-title"><span class="severity">${this.sevIcon(d.severity)}</span> <code>${this.escape(d.ruleId)}</code> L${d.range.start.line + 1}</div>
<div class="issue-detail">${this.escape(d.message)}</div>
</div>
<div class="issue-actions">
<button class="btn" onclick="event.stopPropagation();send('fix', ${d.range.start.line}, '${this.escape(d.ruleId)}', 'linter')">修复</button>
</div>
</div>`).join('');
}
private buildCustomList(report: MergedReport): string {
if (report.customRuleDiagnostics.length === 0) {
return '<div class="empty">✅ 自定义规则未发现问题</div>';
}
return report.customRuleDiagnostics.map((d, i) => `
<div class="issue" onclick="send('navigate', ${d.range.start.line}, '${this.escape(d.ruleId)}', 'custom')">
<div class="issue-left">
<div class="issue-title"><span class="severity">${this.sevIcon(d.severity)}</span> <code>${this.escape(d.ruleId)}</code> L${d.range.start.line + 1}</div>
<div class="issue-detail">${this.escape(d.message)}</div>
</div>
<div class="issue-actions">
<button class="btn" onclick="event.stopPropagation();send('fix', ${d.range.start.line}, '${this.escape(d.ruleId)}', 'custom')">修复</button>
</div>
</div>`).join('');
}
private buildAIList(report: MergedReport): string {
const total = report.translatedDiagnostics.length + report.aiFindings.length;
if (total === 0) {
return '<div class="empty">🤖 AI 审查未发现新问题</div>';
}
const parts: string[] = [];
for (const td of report.translatedDiagnostics) {
parts.push(`
<div class="issue">
<div class="issue-left">
<div class="issue-title"><span class="severity">🔵</span> <code>${this.escape(td.originalRuleId)}</code></div>
<div class="issue-detail">${this.escape(td.translatedMessage)}</div>
${td.translatedSuggestion ? `<div class="issue-detail">建议: ${this.escape(td.translatedSuggestion)}</div>` : ''}
</div>
</div>`);
}
for (const f of report.aiFindings) {
parts.push(`
<div class="issue" onclick="send('navigate', ${f.line}, '${this.escape(f.ruleId)}', 'ai')">
<div class="issue-left">
<div class="issue-title"><span class="severity">${this.sevIcon(f.severity)}</span> [${f.category}] <strong>${this.escape(f.title)}</strong></div>
<div class="issue-detail">${this.escape(f.description)}</div>
${f.suggestion ? `<div class="issue-detail">建议: ${this.escape(f.suggestion)}</div>` : ''}
</div>
<div class="issue-actions">
<button class="btn" onclick="event.stopPropagation();send('fix', ${f.line}, '${this.escape(f.ruleId)}', 'ai')">修复</button>
</div>
</div>`);
}
return parts.join('');
}
private sevIcon(severity: string): string {
switch (severity) {
case 'error': return '🔴';
case 'warning': return '🟡';
case 'info': return '🔵';
default: return '⚪';
}
}
private escape(str: string): string {
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
private handleMessage(message: PanelMessage): void {
switch (message.type) {
case 'navigate':
if (message.line !== undefined) {
const editor = vscode.window.activeTextEditor;
if (editor) {
const line = Math.max(0, message.line);
const range = new vscode.Range(line, 0, line, 0);
editor.selection = new vscode.Selection(range.start, range.end);
editor.revealRange(range, vscode.TextEditorRevealType.InCenter);
}
}
break;
case 'rerun':
vscode.commands.executeCommand('codeReviewer.review');
break;
case 'export':
vscode.commands.executeCommand('codeReviewer.exportReport');
break;
case 'settings':
vscode.commands.executeCommand('codeReviewer.openSetup');
break;
case 'fix':
vscode.commands.executeCommand('codeReviewer.fixIssue', message);
break;
case 'fixAll':
vscode.commands.executeCommand('codeReviewer.fixAll');
break;
}
}
dispose(): void {
ReviewPanel.currentPanel = undefined;
this.panel.dispose();
for (const d of this.disposables) { d.dispose(); }
this.disposables = [];
}
}
额外修改:commands.ts 中集成审查面板调用
import { ReviewPanel } from '../panel/webview';
// 在 'codeReviewer.review' 命令中,静态分析完成后:
const panel = ReviewPanel.createOrShow(context.extensionUri);
panel.update(report);
// 'codeReviewer.exportReport' 命令:
const markdown = reportToMarkdown(report);
const doc = await vscode.workspace.openTextDocument({ content: markdown, language: 'markdown' });
await vscode.window.showTextDocument(doc);
验收
- 审查面板可打开(Webview)
- 三 Tab 切换正常工作
- 统计卡片数值正确
- 问题列表可点击跳转到代码位置
- 降级提示条在 AI 失败时显示
- 修复/重新审查/导出按钮发送正确消息
npm run compile通过npm run lint通过