# 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
? `
${report.errors.length > 0 ? '⚠ AI 审查失败' : '⚠ 部分 AI 功能不可用'}
${report.errors.join('; ')}
`
: '';
return `
代码审查报告
${degradedBanner}
${this.buildLinterList(report)}
${this.buildCustomList(report)}
${this.buildAIList(report)}
`;
}
private buildLinterList(report: MergedReport): string {
if (report.linterDiagnostics.length === 0) {
return '✅ 静态分析未发现问题
';
}
return report.linterDiagnostics.map((d, i) => `
${this.sevIcon(d.severity)} ${this.escape(d.ruleId)} L${d.range.start.line + 1}
${this.escape(d.message)}
`).join('');
}
private buildCustomList(report: MergedReport): string {
if (report.customRuleDiagnostics.length === 0) {
return '✅ 自定义规则未发现问题
';
}
return report.customRuleDiagnostics.map((d, i) => `
${this.sevIcon(d.severity)} ${this.escape(d.ruleId)} L${d.range.start.line + 1}
${this.escape(d.message)}
`).join('');
}
private buildAIList(report: MergedReport): string {
const total = report.translatedDiagnostics.length + report.aiFindings.length;
if (total === 0) {
return '🤖 AI 审查未发现新问题
';
}
const parts: string[] = [];
for (const td of report.translatedDiagnostics) {
parts.push(`
🔵 ${this.escape(td.originalRuleId)}
${this.escape(td.translatedMessage)}
${td.translatedSuggestion ? `
建议: ${this.escape(td.translatedSuggestion)}
` : ''}
`);
}
for (const f of report.aiFindings) {
parts.push(`
${this.sevIcon(f.severity)} [${f.category}] ${this.escape(f.title)}
${this.escape(f.description)}
${f.suggestion ? `
建议: ${this.escape(f.suggestion)}
` : ''}
`);
}
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, '"');
}
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` 通过