Files
2026Technology-Competition/docs/superpowers/specs/implementation-steps/17-phase5.3-review-panel.md
T

14 KiB
Raw Blame History

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  }

  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 通过