i18n 国际化全量接入 + 修复与 UI 增强

- 引入 i18n 系统,所有用户可见字符串替换为 t() 调用,支持中/EN/日三语
- 插件激活时读取 ai.outputLanguage 配置初始化语言,onDidChangeConfiguration 监听变更自动切换
- ReviewPanel 与 SetupViewProvider 注册 onLanguageChange 回调,语言切换时全量重渲染
- package.json description:"AI 输出语言" -> "插件语言"
- 修复 repairJsonEscapes 无差别解引号导致 JSON 解析失败
- 设置页规则名称空值时输入框红框 + 错误提示
- 规则导入流程添加 withProgress 加载提示
This commit is contained in:
范智鹏
2026-07-26 15:44:26 +08:00
parent d96e4dd866
commit ff32ecf5c2
12 changed files with 220 additions and 140 deletions
+24 -23
View File
@@ -7,6 +7,7 @@ import { mergeResults, MergedReport } from '../merger/merger';
import { reportToMarkdown } from '../utils/report';
import { getApiKey } from '../config';
import { ReviewPanel } from '../panel/webview';
import { t } from '../i18n/messages';
let currentReport: MergedReport | null = null;
@@ -19,7 +20,7 @@ export function registerCommands(
vscode.commands.registerCommand('codeReviewer.review', async () => {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showWarningMessage('请先打开一个文件');
vscode.window.showWarningMessage(t('review.noEditor'));
return;
}
@@ -29,15 +30,15 @@ export function registerCommands(
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: '正在审查...',
title: t('review.running'),
cancellable: false,
}, async (progress) => {
progress.report({ message: '运行静态分析...' });
progress.report({ message: t('review.staticAnalysis') });
const startTime = Date.now();
const staticResult = await orchestrator.runStaticAnalysis(document, workingDir);
progress.report({ message: '运行 AI 审查...' });
progress.report({ message: t('review.aiReview') });
const allRules = loadActiveRules(workspaceRoot);
const filterResult = filterAndSummarize(allRules, document);
@@ -76,14 +77,14 @@ export function registerCommands(
const selection = editor.selection;
if (selection.isEmpty) {
vscode.window.showWarningMessage('请先选中要审查的代码');
vscode.window.showWarningMessage(t('review.noSelection'));
return;
}
const code = editor.document.getText(selection);
const apiKey = await getApiKey(context);
if (!apiKey) {
vscode.window.showWarningMessage('请先在设置面板中配置 API Key');
vscode.window.showWarningMessage(t('review.needApiKey'));
return;
}
@@ -92,12 +93,12 @@ export function registerCommands(
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: '审查选中代码...',
title: t('review.reviewingSelection'),
cancellable: false,
}, async () => {
const aiResult = await runAIReview(context, code, [], customRules);
vscode.window.showInformationMessage(
`选中代码审查完成: ${aiResult.customRuleResults.length + aiResult.findings.length} 个问题`
t('review.selectionComplete', { 0: String(aiResult.customRuleResults.length + aiResult.findings.length) })
);
});
})
@@ -116,18 +117,18 @@ export function registerCommands(
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.exportReport', async () => {
if (!currentReport) {
vscode.window.showWarningMessage('请先运行完整审查生成报告');
vscode.window.showWarningMessage(t('export.needRunFirst'));
return;
}
const markdown = reportToMarkdown(currentReport);
const pick = await vscode.window.showQuickPick([
{ label: '📋 复制到剪贴板', description: '将报告内容以 Markdown 格式复制到剪贴板' },
{ label: '📄 下载 Markdown 文件', description: '将报告保存为 .md 文件' },
], { placeHolder: '选择导出方式' });
{ label: t('export.copyToClipboard'), description: t('export.copyDescription') },
{ label: t('export.downloadMarkdown'), description: t('export.saveDescription') },
], { placeHolder: t('export.selectMethod') });
if (!pick) { return; }
if (pick.label.startsWith('📋')) {
if (pick.label === t('export.copyToClipboard')) {
await vscode.env.clipboard.writeText(markdown);
vscode.window.showInformationMessage('报告已复制到剪贴板');
vscode.window.showInformationMessage(t('export.copied'));
} else {
const fileName = currentReport.filePath.split(/[/\\]/).pop()?.replace(/\.[^.]+$/, '') ?? 'review-report';
const defaultUri = vscode.workspace.workspaceFolders?.[0]
@@ -136,11 +137,11 @@ export function registerCommands(
const uri = await vscode.window.showSaveDialog({
defaultUri,
filters: { 'Markdown': ['md'] },
title: '保存审查报告',
title: t('export.saveDialogTitle'),
});
if (!uri) { return; }
await vscode.workspace.fs.writeFile(uri, Buffer.from(markdown, 'utf-8'));
vscode.window.showInformationMessage(`报告已保存到 ${uri.fsPath}`);
vscode.window.showInformationMessage(t('export.saved', { 0: uri.fsPath }));
}
})
);
@@ -149,22 +150,22 @@ export function registerCommands(
vscode.commands.registerCommand('codeReviewer.addCustomRule', () => {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) {
vscode.window.showWarningMessage('请先打开工作区');
vscode.window.showWarningMessage(t('setup.noWorkspace'));
return;
}
vscode.window.showInformationMessage('请在设置面板中管理自定义规则');
vscode.window.showInformationMessage(t('setup.manageRulesHint'));
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.fixIssue', () => {
vscode.window.showInformationMessage('单条修复功能开发中');
vscode.window.showInformationMessage(t('review.fixNotAvailable'));
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.fixAll', () => {
vscode.window.showInformationMessage('批量修复功能开发中');
vscode.window.showInformationMessage(t('review.fixAllNotAvailable'));
})
);
@@ -174,10 +175,10 @@ export function registerCommands(
await vscode.commands.executeCommand('workbench.view.extension.code-reviewer');
} catch {
const action = await vscode.window.showErrorMessage(
'无法打开设置面板',
'打开设置 (JSON)'
t('setup.openSetupFail'),
t('setup.openSettingsJson')
);
if (action === '打开设置 (JSON)') {
if (action === t('setup.openSettingsJson')) {
await vscode.commands.executeCommand('workbench.action.openSettingsJson');
}
}
+2 -1
View File
@@ -4,6 +4,7 @@ import { existsSync } from 'fs';
import { execSync, spawn } from 'child_process';
import type { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
import { getPMDRulesetPath } from '../config';
import { t } from '../i18n/messages';
export class PmdAdapter implements LinterAdapter {
id = 'pmd';
@@ -57,7 +58,7 @@ export class PmdAdapter implements LinterAdapter {
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('ENOENT') || message.includes('java not found') || message.includes('Cannot find')) {
return { diagnostics: [], status: 'tool-unavailable', errorMessage: 'Java 11+ 未安装或不在 PATH 中' };
return { diagnostics: [], status: 'tool-unavailable', errorMessage: t('adapter.javaNotInstalled') };
}
return { diagnostics: [], status: 'execution-failed', errorMessage: message };
}
+2 -1
View File
@@ -1,6 +1,7 @@
import * as vscode from 'vscode';
import { spawn } from 'child_process';
import type { LinterAdapter, AdapterResult, LinterDiagnostic } from './adapter';
import { t } from '../i18n/messages';
const DIALECT_MAP: Record<string, string> = {
sql: 'ansi',
@@ -95,7 +96,7 @@ export class SqlLintAdapter implements LinterAdapter {
return {
diagnostics: [],
status: 'tool-unavailable',
errorMessage: 'sqlfluff 未安装,请执行 pip install sqlfluff',
errorMessage: t('adapter.sqlfluffNotInstalled'),
};
}
return {
+12 -12
View File
@@ -9,6 +9,7 @@ import type {
TranslatedDiagnostic,
AIFinding,
} from './schema';
import { t } from '../i18n/messages';
function buildCustomRulePrompt(rules: CustomRule[]): string {
return rules.map(r =>
@@ -27,22 +28,21 @@ function addLineNumbers(code: string): string {
}
function repairJsonEscapes(str: string): string {
let s = str.replace(/\\"\\n/g, '\\n').replace(/\\"/g, '"');
let inString = false;
let out = '';
for (let i = 0; i < s.length; i++) {
const ch = s[i];
for (let i = 0; i < str.length; i++) {
const ch = str[i];
if (ch === '\\') {
out += ch;
if (i + 1 < s.length) { out += s[++i]; }
if (i + 1 < str.length) { out += str[++i]; }
} else if (ch === '"') {
if (!inString) {
inString = true;
out += ch;
} else {
let j = i + 1;
while (j < s.length && s[j] === ' ') { j++; }
if (j < s.length && ':,\]}'.includes(s[j])) {
while (j < str.length && str[j] === ' ') { j++; }
if (j < str.length && ':,\]}'.includes(str[j])) {
inString = false;
out += ch;
} else {
@@ -109,7 +109,7 @@ export async function runAIReview(
translatedDiagnostics: [],
findings: [],
degraded: true,
error: '未配置 API Key',
error: t('adapter.noApiKey'),
};
}
@@ -125,7 +125,7 @@ export async function runAIReview(
translatedDiagnostics: [],
findings: [],
degraded: true,
error: `创建 Provider 失败: ${err instanceof Error ? err.message : String(err)}`,
error: t('adapter.createProviderFail', { 0: err instanceof Error ? err.message : String(err) }),
};
}
@@ -166,10 +166,10 @@ export async function runAIReview(
ruleId: `custom:${r.ruleId}`,
}));
} catch (e) {
errors.push(`自定义规则响应解析失败: ${e instanceof Error ? e.message : String(e)}`);
errors.push(t('adapter.customRuleParseFail', { 0: e instanceof Error ? e.message : String(e) }));
}
} else {
errors.push(`自定义规则请求失败: ${resultA.reason}`);
errors.push(t('adapter.customRuleRequestFail', { 0: resultA.reason }));
}
let translatedDiagnostics: TranslatedDiagnostic[] = [];
@@ -183,10 +183,10 @@ export async function runAIReview(
translatedDiagnostics = parsed.translatedDiagnostics ?? [];
findings = parsed.findings ?? [];
} catch (e) {
errors.push(`AI 审查响应解析失败: ${e instanceof Error ? e.message : String(e)}`);
errors.push(t('adapter.aiReviewParseFail', { 0: e instanceof Error ? e.message : String(e) }));
}
} else {
errors.push(`AI 审查请求失败: ${resultB.reason}`);
errors.push(t('adapter.aiReviewRequestFail', { 0: resultB.reason }));
}
const degraded = errors.length > 0;
+2 -1
View File
@@ -1,4 +1,5 @@
import { AIProvider, ChatOptions } from './base';
import { t } from '../../i18n/messages';
export class OpenAICompatibleProvider extends AIProvider {
id: string;
@@ -40,7 +41,7 @@ export class OpenAICompatibleProvider extends AIProvider {
if (!response.ok) {
const errorText = await response.text();
if (response.status === 401) {
throw new Error('API Key 无效,请重新设置');
throw new Error(t('adapter.invalidApiKey'));
}
throw new Error(`API 请求失败 (${response.status}): ${errorText}`);
}
+14 -1
View File
@@ -2,11 +2,15 @@ import * as vscode from 'vscode';
import { Orchestrator } from './orchestrator/orchestrator';
import { registerCommands } from './activation/commands';
import { SetupViewProvider } from './views/setupView';
import { setLanguage, t, type Language } from './i18n/messages';
import { getAIOutputLanguage } from './config';
let orchestrator: Orchestrator;
export function activate(context: vscode.ExtensionContext) {
console.log('净码特工 · Code Purifier 已激活');
const lang = getAIOutputLanguage() as Language;
setLanguage(lang);
console.log(t('extension.activated'));
orchestrator = new Orchestrator();
@@ -35,6 +39,15 @@ export function activate(context: vscode.ExtensionContext) {
debounceTimers.set(key, timer);
})
);
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration(e => {
if (e.affectsConfiguration('vscode-code-reviewer.ai.outputLanguage')) {
const newLang = getAIOutputLanguage() as Language;
setLanguage(newLang);
}
})
);
}
export function deactivate() {
+47 -29
View File
@@ -1,5 +1,6 @@
import * as vscode from 'vscode';
import { MergedReport } from '../merger/merger';
import { t, onLanguageChange, getLanguage } from '../i18n/messages';
interface PanelMessage {
type: 'navigate' | 'rerun' | 'export' | 'fix' | 'fixAll';
@@ -19,10 +20,16 @@ function svgIcon(): string {
const SVG_HEADER_ICON = svgIcon();
const BADGE_CLASS: Record<string, string> = { linter: 'badge-linter', custom: 'badge-custom', ai: 'badge-ai' };
const BADGE_LABEL: Record<string, string> = { linter: 'Linter', custom: '自定义', ai: 'AI' };
function badgeHtml(source: string): string {
return `<span class="item-badge ${BADGE_CLASS[source] || 'badge-linter'}">${BADGE_LABEL[source] || source}</span>`;
let label: string;
switch (source) {
case 'linter': label = t('report.sourceLinter'); break;
case 'custom': label = t('report.sourceCustom'); break;
case 'ai': label = t('report.sourceAI'); break;
default: label = source;
}
return `<span class="item-badge ${BADGE_CLASS[source] || 'badge-linter'}">${label}</span>`;
}
function severityClass(severity: string): string {
@@ -38,6 +45,7 @@ export class ReviewPanel {
public static currentPanel: ReviewPanel | undefined;
private readonly panel: vscode.WebviewPanel;
private disposables: vscode.Disposable[] = [];
private currentReport: MergedReport | null = null;
private constructor(
private readonly extensionUri: vscode.Uri,
@@ -45,7 +53,7 @@ export class ReviewPanel {
) {
this.panel = vscode.window.createWebviewPanel(
'codeReviewer.reviewPanel',
'净码特工 · 代码审查报告',
t('report.panelTitle'),
column,
{
enableScripts: true,
@@ -61,6 +69,15 @@ export class ReviewPanel {
null,
this.disposables
);
this.disposables.push(
onLanguageChange(() => {
this.panel.title = t('report.panelTitle');
if (this.currentReport) {
this.panel.webview.html = this.buildHtml(this.currentReport);
}
})
);
}
static createOrShow(extensionUri: vscode.Uri, column?: vscode.ViewColumn): ReviewPanel {
@@ -74,6 +91,7 @@ export class ReviewPanel {
}
update(report: MergedReport): void {
this.currentReport = report;
this.panel.webview.html = this.buildHtml(report);
}
@@ -96,13 +114,13 @@ export class ReviewPanel {
const totalInfos = linterInfos + customInfos + aiInfos;
const errorBox = report.errors.length > 0
? `<div class="errors-box"><div class="errors-box-title">✖ 执行错误</div>${report.errors.map(e => `<div class="errors-box-item">${esc(e)}</div>`).join('')}</div>`
? `<div class="errors-box"><div class="errors-box-title">✖ ${t('report.executionErrors')}</div>${report.errors.map(e => `<div class="errors-box-item">${esc(e)}</div>`).join('')}</div>`
: '';
const banner = report.errors.length > 0
? '<div class="banner banner-error">⚠ AI 审查未完成,报告仅包含部分结果</div>'
? `<div class="banner banner-error">⚠ ${t('report.degradedBanner')}</div>`
: report.degraded
? '<div class="banner banner-warning">⚠ 部分 AI 功能不可用,报告已降级</div>'
? `<div class="banner banner-warning">⚠ ${t('report.degradedBanner')}</div>`
: '';
const fixableLinterSet = new Set(report.fixableLinterIndices);
@@ -116,11 +134,11 @@ export class ReviewPanel {
return pts.join(' ');
};
const linterToolName = report.adapterNames.length > 0 ? report.adapterNames.join(' + ') : '静态分析';
const linterToolName = report.adapterNames.length > 0 ? report.adapterNames.join(' + ') : t('report.sourceLinter');
const customFilterInfo = report.customRuleFilterInfo;
const customFilterLabel = customFilterInfo
? `(注入 ${customFilterInfo.injected}/${customFilterInfo.totalActive} 条)`
? t('report.injectedCount', { 0: customFilterInfo.injected, 1: customFilterInfo.totalActive })
: '';
return `<!DOCTYPE html>
@@ -128,7 +146,7 @@ export class ReviewPanel {
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>代码审查报告</title>
<title>${t('report.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); }
@@ -223,7 +241,7 @@ export class ReviewPanel {
<div class="header">
<div>
<h2>${SVG_HEADER_ICON} 净码特工 · 代码审查报告</h2>
<h2>${SVG_HEADER_ICON} ${t('report.panelTitle')}</h2>
<div class="meta">${esc(fileName)} · ${esc(report.language)} · ${(report.duration / 1000).toFixed(1)}s</div>
</div>
</div>
@@ -232,16 +250,16 @@ ${banner}
${errorBox}
<div class="summary">
<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">${totalErrors}</div><div class="label">错误</div></div>
<div class="stat-card stat-warning"><div class="num">${totalWarnings}</div><div class="label">警告</div></div>
<div class="stat-card stat-info"><div class="num">${totalInfos}</div><div class="label">建议</div></div>
<div class="stat-card stat-total"><div class="num">${total}</div><div class="label">${t('report.totalIssues')}</div></div>
<div class="stat-card stat-error"><div class="num">${totalErrors}</div><div class="label">${t('report.errors')}</div></div>
<div class="stat-card stat-warning"><div class="num">${totalWarnings}</div><div class="label">${t('report.warnings')}</div></div>
<div class="stat-card stat-info"><div class="num">${totalInfos}</div><div class="label">${t('report.info')}</div></div>
</div>
<div class="tab-bar">
<button class="tab active" data-tab="linter" onclick="switchTab('linter')">🔧 ${report.adapterNames.length > 0 ? report.adapterNames.join(' + ') : '静态分析'} ${tabCount(linterErrors, linterWarnings, linterInfos)}</button>
<button class="tab" data-tab="custom" onclick="switchTab('custom')">📋 自定义规则 ${tabCount(customErrors, customWarnings, customInfos)} <span style="font-size:11px;color:var(--vscode-descriptionForeground);">${customFilterLabel}</span></button>
<button class="tab" data-tab="ai" onclick="switchTab('ai')">🤖 AI 审查 ${tabCount(aiErrors, aiWarnings, aiInfos)}</button>
<button class="tab active" data-tab="linter" onclick="switchTab('linter')">🔧 ${report.adapterNames.length > 0 ? report.adapterNames.join(' + ') : t('report.sourceLinter')} ${tabCount(linterErrors, linterWarnings, linterInfos)}</button>
<button class="tab" data-tab="custom" onclick="switchTab('custom')">📋 ${t('report.sourceCustom')} ${tabCount(customErrors, customWarnings, customInfos)} <span style="font-size:11px;color:var(--vscode-descriptionForeground);">${customFilterLabel}</span></button>
<button class="tab" data-tab="ai" onclick="switchTab('ai')">🤖 ${t('report.sourceAI')} ${tabCount(aiErrors, aiWarnings, aiInfos)}</button>
</div>
<div class="tab-content active" id="tab-linter">
@@ -255,8 +273,8 @@ ${errorBox}
</div>
<div class="actions">
<button class="btn btn-primary" onclick="send('rerun')">🔄 重新审查</button>
<button class="btn" onclick="send('export')">📄 导出报告</button>
<button class="btn btn-primary" onclick="send('rerun')">🔄 ${t('report.rerun')}</button>
<button class="btn" onclick="send('export')">📄 ${t('report.export')}</button>
</div>
</div>
@@ -283,35 +301,35 @@ ${errorBox}
private buildLinterList(report: MergedReport, fixableSet: Set<number>): string {
if (report.linterDiagnostics.length === 0) {
return '<div class="empty">未发现任何问题</div>';
return `<div class="empty">${t('report.noIssues')}</div>`;
}
const toolName = report.adapterNames.length > 0 ? report.adapterNames.join(' + ') : '静态分析';
const toolName = report.adapterNames.length > 0 ? report.adapterNames.join(' + ') : t('report.sourceLinter');
const hasFixable = fixableSet.size > 0;
return `<div class="section-header"><span class="section-header-title">${esc(toolName)} · ${report.linterCount} 个问题</span>${hasFixable ? '<button class="btn" onclick="send(\'fixAll\')">全部修复</button>' : ''}</div>`
return `<div class="section-header"><span class="section-header-title">${esc(toolName)} · ${t('report.issuesCount', { 0: report.linterCount })}</span>${hasFixable ? `<button class="btn" onclick="send('fixAll')">${t('report.fixAll')}</button>` : ''}</div>`
+ report.linterDiagnostics.map((d, i) => this.buildIssueItem(d.severity, d.ruleId, d.message, d.range.start.line, 'linter', d.suggestion, fixableSet.has(i), undefined, false)).join('');
}
private buildCustomList(report: MergedReport, fixableSet: Set<number>): string {
const filterInfo = report.customRuleFilterInfo;
if (filterInfo?.skippedRequestA) {
return '<div class="empty">当前文件语言无匹配的自定义规则,已跳过规则评估</div>';
return `<div class="empty">${t('report.skipCustomRules')}</div>`;
}
if (report.customRuleDiagnostics.length === 0) {
return '<div class="empty">未发现规则违规</div>';
return `<div class="empty">${t('report.noRuleViolations')}</div>`;
}
const hasFixable = fixableSet.size > 0;
const filterLabel = filterInfo
? `(注入 ${filterInfo.injected}/${filterInfo.totalActive} 条规则)`
? t('report.injectedRules', { 0: filterInfo.injected, 1: filterInfo.totalActive })
: '';
return `<div class="section-header"><span class="section-header-title">自定义规则 · ${report.customRuleCount} 个问题${filterLabel}</span>${hasFixable ? '<button class="btn" onclick="send(\'fixAll\')">全部修复</button>' : ''}</div>`
return `<div class="section-header"><span class="section-header-title">${t('report.sourceCustom')} · ${t('report.issuesCount', { 0: report.customRuleCount })}${filterLabel}</span>${hasFixable ? `<button class="btn" onclick="send('fixAll')">${t('report.fixAll')}</button>` : ''}</div>`
+ report.customRuleDiagnostics.map((d, i) => this.buildIssueItem(d.severity, d.ruleId, d.message, d.range.start.line, 'custom', d.suggestion, fixableSet.has(i), undefined, false)).join('');
}
private buildAIList(report: MergedReport): string {
if (report.aiFindings.length === 0) {
return '<div class="empty">无 AI 审查建议</div>';
return `<div class="empty">${t('report.noAIFindings')}</div>`;
}
const parts: string[] = ['<div class="section-header"><span class="section-header-title">AI 审查建议 · ' + report.aiCount + ' 条</span><button class="btn" onclick="send(\'fixAll\')">全部修复</button></div>'];
const parts: string[] = [`<div class="section-header"><span class="section-header-title">${t('report.sourceAI')} · ${t('report.itemsCount', { 0: report.aiCount })}</span><button class="btn" onclick="send('fixAll')">${t('report.fixAll')}</button></div>`];
for (const f of report.aiFindings) {
const details: string[] = [];
details.push(`<div class="detail-text">${esc(f.description)}</div>`);
@@ -351,7 +369,7 @@ ${errorBox}
parts.push(`<span class="item-message">${esc(message)}</span>`);
parts.push(`<span class="item-line" onclick="event.stopPropagation();send('navigate', ${line}, '${esc(ruleId)}', '${source}')">L${lineNum}</span>`);
if (fixable) {
parts.push(`<button class="item-fix" onclick="event.stopPropagation(); this.disabled=true; this.textContent='⏳...';send('fix', ${line}, '${esc(ruleId)}', '${source}')">🔧 修复</button>`);
parts.push(`<button class="item-fix" onclick="event.stopPropagation(); this.disabled=true; this.textContent='⏳...';send('fix', ${line}, '${esc(ruleId)}', '${source}')">🔧 ${t('report.fixAll')}</button>`);
}
parts.push('</div>');
+15 -14
View File
@@ -1,4 +1,5 @@
import { MergedReport } from '../merger/merger';
import { t } from '../i18n/messages';
function severityEmoji(severity: string): string {
switch (severity) {
@@ -16,21 +17,21 @@ function formatLine(line: number): string {
export function reportToMarkdown(report: MergedReport): string {
const lines: string[] = [];
lines.push('# 代码审查报告');
lines.push(`# ${t('report.title')}`);
lines.push('');
lines.push(`**文件:** \`${report.filePath}\``);
lines.push(`**语言:** ${report.language}`);
lines.push(`**耗时:** ${(report.duration / 1000).toFixed(1)}s`);
lines.push(`**${t('report.file')}:** \`${report.filePath}\``);
lines.push(`**${t('report.language')}:** ${report.language}`);
lines.push(`**${t('report.duration')}:** ${(report.duration / 1000).toFixed(1)}s`);
if (report.adapterNames.length > 0) {
lines.push(`**分析工具:** ${report.adapterNames.join(', ')}`);
lines.push(`**${t('report.tools')}:** ${report.adapterNames.join(', ')}`);
}
if (report.degraded) {
lines.push('');
lines.push('> ⚠️ 部分 AI 功能不可用,报告已降级');
lines.push(`> ⚠️ ${t('report.degradedBanner')}`);
}
if (report.errors.length > 0) {
lines.push('');
lines.push('## 错误');
lines.push(`## ${t('report.errors')}`);
for (const err of report.errors) {
lines.push(`- ${err}`);
}
@@ -49,24 +50,24 @@ export function reportToMarkdown(report: MergedReport): string {
+ report.aiFindings.filter(f => f.severity === 'warning').length;
const infos = total - errors - warnings;
lines.push(`**总计:** ${total} | **错误:** ${errors} | **警告:** ${warnings} | **建议:** ${infos}`);
lines.push(t('report.totalSummary', { 0: String(total), 1: String(errors), 2: String(warnings), 3: String(infos) }));
lines.push('');
if (report.linterDiagnostics.length > 0) {
lines.push(`## 🔧 静态分析 · ${report.linterCount} 个问题`);
lines.push(t('report.staticSection', { 0: String(report.linterCount) }));
lines.push('');
for (const diag of report.linterDiagnostics) {
lines.push(`- ${severityEmoji(diag.severity)} \`${diag.ruleId}\` ${formatLine(diag.range.start.line)}`);
lines.push(` ${diag.message}`);
if (diag.suggestion) {
lines.push(` 建议: ${diag.suggestion}`);
lines.push(` ${t('report.suggestion')}: ${diag.suggestion}`);
}
}
lines.push('');
}
if (report.customRuleDiagnostics.length > 0) {
lines.push(`## 📋 自定义规则 · ${report.customRuleCount} 个问题`);
lines.push(t('report.customSection', { 0: String(report.customRuleCount) }));
lines.push('');
for (const diag of report.customRuleDiagnostics) {
lines.push(`- ${severityEmoji(diag.severity)} \`${diag.ruleId}\` ${formatLine(diag.range.start.line)}`);
@@ -76,14 +77,14 @@ export function reportToMarkdown(report: MergedReport): string {
}
if (report.aiFindings.length > 0) {
lines.push(`## 🤖 AI 审查 · ${report.aiCount} 条建议`);
lines.push(t('report.aiSection', { 0: String(report.aiCount) }));
lines.push('');
for (const finding of report.aiFindings) {
lines.push(`- ${severityEmoji(finding.severity)} [AI] [${finding.category}] \`${finding.ruleId}\` ${formatLine(finding.line)}`);
lines.push(` **${finding.title}**`);
lines.push(` ${finding.description}`);
if (finding.suggestion) {
lines.push(` 建议: ${finding.suggestion}`);
lines.push(` ${t('report.suggestion')}: ${finding.suggestion}`);
}
if (finding.codeDiff) {
lines.push(' ```diff');
@@ -95,7 +96,7 @@ export function reportToMarkdown(report: MergedReport): string {
}
if (total === 0) {
lines.push('✅ 未发现问题');
lines.push(`${t('report.noProblems')}`);
lines.push('');
}
+15 -1
View File
@@ -8,14 +8,28 @@
vscode.postMessage({ type: type, value: value });
}
function showRuleNameError(show) {
var input = document.getElementById('newRuleInput');
var error = document.getElementById('ruleNameError');
if (!input || !error) { return; }
input.classList.toggle('input-error', show);
error.classList.toggle('show', show);
}
function addRule() {
var input = document.getElementById('newRuleInput');
var name = input.value.trim();
if (!name) { return; }
if (!name) { showRuleNameError(true); return; }
showRuleNameError(false);
vscode.postMessage({ type: 'addRule', name: name });
input.value = '';
}
var ruleInput = document.getElementById('newRuleInput');
if (ruleInput) {
ruleInput.addEventListener('input', function () { showRuleNameError(false); });
}
function deleteFile(fileName) {
vscode.postMessage({ type: 'deleteFile', fileName: fileName });
}
+79 -55
View File
@@ -13,18 +13,25 @@ import { TxtConverter } from '../rules/converters/txt-converter';
import { ExcelConverter } from '../rules/converters/excel-converter';
import { DocxConverter } from '../rules/converters/docx-converter';
import { PptxConverter } from '../rules/converters/pptx-converter';
import { t, onLanguageChange, Language } from '../i18n/messages';
const languageLabels: Record<string, string> = {
'zh-CN': '中文(简体)',
'en': 'English',
'ja': '日本語',
};
function getLanguageLabel(lang: string): string {
switch (lang) {
case 'zh-CN': return t('lang.zhCN');
case 'en': return t('lang.en');
case 'ja': return t('lang.ja');
default: return lang;
}
}
export class SetupViewProvider implements vscode.WebviewViewProvider {
private _view?: vscode.WebviewView;
public connectionTested = false;
public connectionSuccess = false;
private importService = new ImportService();
private _providers: Record<string, { name: string; models: string[] }> = {};
private _config: { provider: string; model: string; outputLanguage: string; baseUrl: string } = { provider: '', model: '', outputLanguage: 'zh-CN', baseUrl: '' };
private _scriptUri: vscode.Uri | null = null;
constructor(private context: vscode.ExtensionContext) {
this.importService.registerConverter(new YamlConverter());
@@ -53,8 +60,19 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
vscode.Uri.joinPath(this.context.extensionUri, 'out', 'webview', 'setupView.js')
);
const aiConfig = getAIConfig();
this._providers = providers;
this._config = aiConfig;
this._scriptUri = scriptUri;
webviewView.webview.html = this.getHtml(providers, aiConfig, scriptUri);
const langDisposable = onLanguageChange(() => {
const newConfig = getAIConfig();
this._config = newConfig;
if (this._view && this._scriptUri) {
this._view.webview.html = this.getHtml(this._providers, newConfig, this._scriptUri);
}
});
webviewView.webview.onDidReceiveMessage(async (msg) => {
switch (msg.type) {
case 'ready':
@@ -128,7 +146,7 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
baseUrl: baseUrlConfigured ? config.baseUrl : '',
baseUrlConfigured,
language: config.outputLanguage,
languageLabel: languageLabels[config.outputLanguage] || config.outputLanguage,
languageLabel: getLanguageLabel(config.outputLanguage),
apiKeyConfigured,
},
providers: getAllProviderMeta(),
@@ -141,12 +159,12 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
private async testConnection(): Promise<void> {
const apiKey = await getApiKey(this.context);
if (!apiKey) {
this._view?.webview.postMessage({ type: 'testResult', success: false, message: '请先设置 API Key' });
this._view?.webview.postMessage({ type: 'testResult', success: false, message: t('setup.setApiKeyFirst') });
return;
}
if (!isBaseUrlConfigured()) {
this._view?.webview.postMessage({ type: 'testResult', success: false, message: '请先设置 Base URL' });
this._view?.webview.postMessage({ type: 'testResult', success: false, message: t('setup.setBaseUrlFirst') });
return;
}
@@ -162,12 +180,12 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
});
this.connectionTested = true;
this.connectionSuccess = true;
this._view?.webview.postMessage({ type: 'testResult', success: true, message: '✓ 连接成功' });
this._view?.webview.postMessage({ type: 'testResult', success: true, message: t('setup.testSuccess') });
} catch (err) {
this.connectionTested = true;
this.connectionSuccess = false;
const message = err instanceof Error ? err.message : String(err);
this._view?.webview.postMessage({ type: 'testResult', success: false, message: `✗ 连接失败: ${message}` });
this._view?.webview.postMessage({ type: 'testResult', success: false, message: t('setup.testFail', { 0: message }) });
}
await this.pushConfig();
@@ -191,7 +209,7 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
const result = await vscode.window.showOpenDialog({
canSelectMany: false,
openLabel: '选择规则文件',
openLabel: t('setup.selectRuleFile'),
filters: { '规则文件': ['yaml', 'yml', 'md', 'txt', 'xlsx', 'xls', 'docx', 'pptx'] },
});
if (!result || result.length === 0) { return; }
@@ -208,32 +226,33 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
const yamlPath = path.join(rulesDir, yamlFileName);
if (fs.existsSync(yamlPath)) {
vscode.window.showErrorMessage(`文件 ${yamlFileName} 已存在`);
vscode.window.showErrorMessage(t('setup.fileExists', { 0: yamlFileName }));
return;
}
if (ext === '.yaml' || ext === '.yml') {
fs.copyFileSync(srcPath, yamlPath);
vscode.window.showInformationMessage(`规则文件已导入: ${yamlFileName}`);
vscode.window.showInformationMessage(t('setup.importSuccess', { 0: yamlFileName }));
} else {
try {
const conversion = await this.importService.convert(srcPath, this.context);
const conversion = await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: t('setup.importing'),
}, () => this.importService.convert(srcPath, this.context));
const decision = await showImportPreview(conversion);
if (!decision || !decision.confirmed) {
vscode.window.showInformationMessage('导入已取消');
vscode.window.showInformationMessage(t('setup.importCancelled'));
return;
}
this.importService.applyConversion(conversion, decision, yamlPath);
vscode.window.showInformationMessage(
`规则已导入: ${yamlFileName}${conversion.rules.length} 条,` +
`${conversion.exactCount} 条完全重复已注释,` +
`${conversion.overlapCount} 条部分重叠已标注)`
t('setup.importDedupResult', { 0: yamlFileName, 1: String(conversion.rules.length), 2: String(conversion.exactCount), 3: String(conversion.overlapCount) })
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
vscode.window.showErrorMessage(`规则导入失败: ${msg}`);
vscode.window.showErrorMessage(t('setup.importFail', { 0: msg }));
}
}
}
@@ -373,6 +392,10 @@ select {
}
input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vscode-descriptionForeground)); }
.field-hint { font-size: 11px; color: var(--vscode-descriptionForeground); margin-top: 4px; }
.input-error { border-color: #f48771 !important; }
.input-error:focus { border-color: #f48771 !important; }
.error-hint { font-size: 11px; color: #f48771; margin-top: 4px; display: none; }
.error-hint.show { display: block; }
/* Input group */
.input-group { display: flex; gap: 4px; }
@@ -469,31 +492,31 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
净码特工 · 代码审查 · 设置
${t('setup.header')}
</div>
<!-- 1. 快速开始 -->
<div class="section">
<div class="section-title">快速开始</div>
<div class="section-title">${t('setup.quickStart')}</div>
<div class="getting-started">
<div class="gs-title">
<span class="gs-title-dot"></span>
三步启用代码审核
${t('setup.gettingStarted')}
</div>
<div class="gs-steps">
<div class="gs-step" data-step="1">
<span class="gs-step-num">1</span>
<span>安装插件后,<b>配置 AI 模型</b>及 API Key,激活智能审核能力</span>
<span>${t('setup.step1')}</span>
</div>
<div class="gs-step" data-step="2">
<span class="gs-step-num">2</span>
<span>启用 <b>自定义规则</b>,补充团队特有的编码规范</span>
<span>${t('setup.step2')}</span>
</div>
<div class="gs-step" data-step="3">
<span class="gs-step-num">3</span>
<div class="gs-step-body">
<span><b>保存并测试连接</b>,验证配置无误后即可触发审核</span>
<span class="gs-step-hint">按 <b>Ctrl + Shift + R</b> 快捷键触发审核,结果实时显示在 <b>审核结果报告</b>页面中</span>
<span>${t('setup.step3')}</span>
<span class="gs-step-hint">${t('setup.step3Hint')}</span>
</div>
</div>
</div>
@@ -502,27 +525,27 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
<!-- 2. 审核引擎 -->
<div class="section">
<div class="section-title">审核引擎</div>
<div class="section-title">${t('setup.engineSection')}</div>
<div class="engines">
<div class="engine-tab">
<span class="engine-dot dot-purple"></span>
<div>
<div class="engine-label">共通规则</div>
<div class="engine-desc">Linter 静态分析</div>
<div class="engine-label">${t('setup.commonRules')}</div>
<div class="engine-desc">${t('setup.linterStatic')}</div>
</div>
</div>
<div class="engine-tab">
<span class="engine-dot dot-amber"></span>
<div>
<div class="engine-label">自定义规则</div>
<div class="engine-desc">团队编码规范</div>
<div class="engine-label">${t('setup.customRules')}</div>
<div class="engine-desc">${t('setup.teamCoding')}</div>
</div>
</div>
<div class="engine-tab">
<span class="engine-dot dot-green"></span>
<div>
<div class="engine-label">AI 审核</div>
<div class="engine-desc">深度代码审查</div>
<div class="engine-label">${t('setup.aiReview')}</div>
<div class="engine-desc">${t('setup.deepReview')}</div>
</div>
</div>
</div>
@@ -530,11 +553,11 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
<!-- 3. AI 模型配置 -->
<div class="section">
<div class="section-title">AI 模型配置</div>
<div class="section-title">${t('setup.aiConfig')}</div>
<div class="card">
<div class="card-row">
<span class="card-label">模型提供商</span>
<span class="badge" id="providerBadge">未配置</span>
<span class="card-label">${t('setup.provider')}</span>
<span class="badge" id="providerBadge">${t('setup.notConfigured')}</span>
</div>
<div class="field">
<select id="providerSelect">${Object.entries(providers).map(([id, meta]) =>
@@ -542,12 +565,12 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
).join('\n ')}</select>
</div>
<div class="field">
<label class="field-label">模型名称</label>
<label class="field-label">${t('setup.model')}</label>
<select id="modelSelect">${(providers[config.provider]?.models ?? []).map(m =>
`<option value="${m}"${config.model === m ? ' selected' : ''}>${m}</option>`
).join('\n ')}</select>
</div>
<div class="field-hint">建议使用支持结构化输出的模型。</div>
<div class="field-hint">${t('setup.modelHint')}</div>
</div>
</div>
@@ -556,56 +579,57 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
<div class="section-title">API Key</div>
<div class="card">
<div class="card-row">
<span class="card-label">API Key</span>
<span class="badge" id="apiKeyBadge">未配置</span>
<span class="card-label">${t('setup.apiKey')}</span>
<span class="badge" id="apiKeyBadge">${t('setup.notConfigured')}</span>
</div>
<div class="field">
<input type="password" id="apiKeyInput" placeholder="sk-..." onchange="postMsg('setApiKey', this.value)">
</div>
<div class="field">
<label class="field-label">Base URL</label>
<label class="field-label">${t('setup.baseUrl')}</label>
<input type="text" id="baseUrlInput" placeholder="https://api.deepseek.com/v1" onchange="postMsg('setBaseUrl', this.value)">
</div>
<div class="field-hint">Key 仅存储在本地 VS Code 安全存储中。</div>
<div class="field-hint">${t('setup.keyStorageHint')}</div>
</div>
</div>
<!-- 5. 输出语言 -->
<div class="section">
<div class="section-title">输出语言</div>
<div class="section-title">${t('setup.outputLang')}</div>
<div class="field">
<label class="field-label">AI 审查结果输出语言</label>
<label class="field-label">${t('setup.outputLangHint')}</label>
<select id="languageSelect" onchange="postMsg('setLanguage', this.value)">
<option value="zh-CN"${config.outputLanguage === 'zh-CN' ? ' selected' : ''}>中文(简体)</option>
<option value="en"${config.outputLanguage === 'en' ? ' selected' : ''}>English</option>
<option value="ja"${config.outputLanguage === 'ja' ? ' selected' : ''}>日本語</option>
<option value="zh-CN"${config.outputLanguage === 'zh-CN' ? ' selected' : ''}>${t('lang.zhCN')}</option>
<option value="en"${config.outputLanguage === 'en' ? ' selected' : ''}>${t('lang.en')}</option>
<option value="ja"${config.outputLanguage === 'ja' ? ' selected' : ''}>${t('lang.ja')}</option>
</select>
</div>
</div>
<!-- 6. 自定义规则 -->
<div class="section">
<div class="section-title">自定义规则</div>
<div class="section-title">${t('setup.customRulesSection')}</div>
<div class="card">
<div class="card-row">
<span class="card-label">规则列表</span>
<span class="badge" style="background:rgba(139,148,158,0.12);color:#8b949e;" id="ruleCountBadge">0 条</span>
<span class="card-label">${t('setup.ruleList')}</span>
<span class="badge" style="background:rgba(139,148,158,0.12);color:#8b949e;" id="ruleCountBadge">${t('setup.ruleCount', { 0: '0' })}</span>
</div>
<div id="ruleList"></div>
<div class="field" style="margin-top:8px;">
<div class="input-group">
<input type="text" id="newRuleInput" placeholder="输入规则名称...">
<button class="btn btn-sm" style="background:#7c3aed;color:#fff;border-color:#7c3aed;" onclick="addRule()">+ 添加</button>
<input type="text" id="newRuleInput" placeholder="${t('setup.ruleNamePlaceholder')}">
<button class="btn btn-sm" style="background:#7c3aed;color:#fff;border-color:#7c3aed;" onclick="addRule()">${t('setup.add')}</button>
</div>
<div class="field-hint">建议使用英文名称,无需输入 .yaml 后缀(如 security-rules</div>
<div class="error-hint" id="ruleNameError">${t('setup.ruleNameRequired')}</div>
<div class="field-hint">${t('setup.ruleNameHint')}</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="actions">
<button class="btn" onclick="postMsg('reset')">重置</button>
<button class="btn btn-primary" id="btnTest" onclick="postMsg('saveAndTest')">保存并测试连接</button>
<button class="btn" onclick="postMsg('reset')">${t('setup.reset')}</button>
<button class="btn btn-primary" id="btnTest" onclick="postMsg('saveAndTest')">${t('setup.saveAndTest')}</button>
</div>
<!-- Toast -->