338 lines
14 KiB
Markdown
338 lines
14 KiB
Markdown
# 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`
|
||
|
||
```typescript
|
||
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` 中集成审查面板调用
|
||
|
||
```typescript
|
||
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` 通过
|