ESLint: +29 条 P1/P2 规则 + 12 条 TS 专属规则(含 no-shadow/no-array-constructor 冲突处理) Stylelint: 集成 stylelint-config-recommended + 27 条额外规则 PMD: 排除 20 弃用 + 17 噪音规则,补启 Security/Multithreading,274+12 条精选 SQL-lint: 内置精选 57 条规则配置 + 按 tier 分级 severity + 无项目配置时自动注入临时配置 模板导出: export-service.ts 导出 2-sheet xlsx(复用 xlsx 零新依赖) 模板导入: template-converter.ts 固定列映射解析 + dedup-prompt.ts AI 语义去重 导入预览增强: 错误规则分组置顶只读、跳过行提示、空数据提示 i18n: 新增 18 条模板导入/导出相关翻译 WebView: 静态分析/自定义规则项默认可展开显示 suggestion
816 lines
33 KiB
Markdown
816 lines
33 KiB
Markdown
# 自定义规则 · 模板导出与导入功能设计书 v2.0(修正稿)
|
||
|
||
> 版本:v2.0(整合导出 + 模板导入)
|
||
> 日期:2026-07-30
|
||
> 修正:基于 Stage ② 需求澄清,修正 API 签名、错误处理、参数传递
|
||
|
||
---
|
||
|
||
## 一、方案概览
|
||
|
||
### 1.1 目标
|
||
|
||
形成「导出模板 → 填写 → 模板导入」闭环:
|
||
|
||
- **导出模板**: 侧边栏新增按钮,生成标准 `.xlsx` 模板(2 个 sheet)
|
||
- **模板导入**: 新增 checkbox「从模板导入」,程序解析 + AI 去重,预览确认
|
||
|
||
### 1.2 数据流
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────────────────┐
|
||
│ 导出模板 │
|
||
│ setupView 按钮 → exportService.ts → XLSX.writeFile → .xlsx 文件 │
|
||
└──────────────────────────────────────────────────────────────────┘
|
||
|
||
┌──────────────────────────────────────────────────────────────────────┐
|
||
│ 模板导入(勾选 checkbox) │
|
||
│ │
|
||
│ setupView checkbox + 填名 + 点「添加」 │
|
||
│ → showOpenDialog(仅 xlsx/xls) │
|
||
│ → ImportService.importTemplate(srcPath, name, context) │
|
||
│ ├─ TemplateConverter.parseTemplate(srcPath) ← 纯程序,无 AI │
|
||
│ │ ├─ 准入校验-1: 文件格式 │
|
||
│ │ ├─ 准入校验-2: 表头结构 │
|
||
│ │ ├─ 数据行解析 + 校验 → ImportableRule[](带 validationIssues) │
|
||
│ │ └─ 拆分 validRules / errorRules,生成 yamlContent │
|
||
│ ├─ AI 去重(仅 validRules) ← 新增 dedup-prompt │
|
||
│ │ ├─ 成功: parseImportableYaml() → 带 duplicateLevel │
|
||
│ │ └─ 失败: 降级,validRules 无去重标记 + warning 提示 │
|
||
│ └─ 返回 ConversionResult(含 errorRules + skippedCount) │
|
||
│ → showImportPreview(预览面板) │
|
||
│ ├─ 顶部: 跳过 N 行空数据(若 skippedCount > 0) │
|
||
│ ├─ 🚫 错误规则组(置顶,无 checkbox,只读) │
|
||
│ ├─ ⛔ 完全重复 / ⚠️ 部分重叠 / ✅ 新规则(仅有效规则,现有逻辑) │
|
||
│ └─ 确认 → ImportService.applyConversion → 写 YAML │
|
||
│ │
|
||
│ 不勾选 checkbox → 走原有 addRule AI 链路,零改动 │
|
||
└──────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 1.3 范围
|
||
|
||
| 类型 | 内容 |
|
||
|------|------|
|
||
| 含 | 导出模板;模板导入(程序解析 + 准入校验 + AI 去重 + 预览) |
|
||
| 不含 | 导出当前规则;YAML 直通链路校验补齐;AI 链路校验补齐 |
|
||
| 不触碰 | `ExcelConverter`、`prompt-builder.ts`、原 `addRule` AI 流程 |
|
||
|
||
### 1.4 Stage ② 修正项(相对初版设计书)
|
||
|
||
| 编号 | 修正点 | 内容 |
|
||
|------|--------|------|
|
||
| ① | `convertContentWithAI` 调用签名 | `convertContentWithAI(yamlContent, context, system)` — content 在前,context 为中,systemPrompt 在后 |
|
||
| ② | AI 去重失败降级 | `convertContentWithAI` 返回 `null` 时,有效规则不带 `duplicateLevel` 进预览,弹出 warning |
|
||
| ③ | `importTemplate` 参数 | `importTemplate(srcPath, name, context)` — 增加 context,workspaceRoot 内部获取 |
|
||
| ⑤ | prompt 约束加强 | 去重 prompt 明确 "你只负责去重判定,禁止修改任何已有字段,禁止添加/删除规则" |
|
||
|
||
---
|
||
|
||
## 二、架构设计
|
||
|
||
### 2.1 模块划分
|
||
|
||
```
|
||
src/rules/
|
||
├── export-service.ts ← 新增: 模板导出(xlsx 写盘)
|
||
├── converters/
|
||
│ ├── template-converter.ts ← 新增: 程序解析 + 准入校验
|
||
│ └── dedup-prompt.ts ← 新增: 专用去重 prompt 构建
|
||
├── import-service.ts ← 修改: 新增 importTemplate 方法
|
||
├── import-preview.ts ← 修改: 新增错误规则组 + skippedCount 提示
|
||
├── import-types.ts ← 修改: 扩展接口字段
|
||
├── yaml-parser.ts ← 不改(loadActiveRules 已被 import)
|
||
└── converters/prompt-builder.ts ← 不碰
|
||
|
||
src/views/setupView.ts ← 修改: checkbox + 导出按钮 + 分流逻辑
|
||
src/activation/commands.ts ← 修改: 注册 exportTemplate 命令
|
||
src/i18n/messages.ts ← 修改: 新增 i18n key
|
||
package.json ← 修改: contributes.commands 追加 1 条
|
||
```
|
||
|
||
### 2.2 关键接口扩展
|
||
|
||
```ts
|
||
// src/rules/import-types.ts — 扩展
|
||
|
||
interface ValidationIssue {
|
||
field: string;
|
||
severity: 'error' | 'warning';
|
||
message: string;
|
||
}
|
||
|
||
interface ImportableRule extends CustomRule {
|
||
duplicateOf?: string;
|
||
duplicateLevel?: 'exact' | 'overlap' | 'none';
|
||
duplicateReason?: string;
|
||
validationIssues?: ValidationIssue[]; // 新增: 非空则为错误规则
|
||
rowNumber?: number; // 新增: 原始行号
|
||
}
|
||
|
||
interface ConversionResult {
|
||
rules: ImportableRule[];
|
||
yamlContent: string;
|
||
sourceFileName: string;
|
||
exactCount: number;
|
||
overlapCount: number;
|
||
skippedCount?: number; // 新增: 被跳过的空行数
|
||
errorCount?: number; // 新增: 错误规则数
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 三、详细实现
|
||
|
||
### 3.1 新增 `src/rules/export-service.ts`
|
||
|
||
````ts
|
||
import * as XLSX from 'xlsx';
|
||
import * as vscode from 'vscode';
|
||
import { t } from '../i18n/messages';
|
||
|
||
const RULES_HEADER = ['id', 'severity', 'description', 'message', 'languages', 'excludeLanguages'];
|
||
const RULES_EXAMPLE = [
|
||
'no-todo', 'warning', '禁止提交 TODO 注释',
|
||
'发现 TODO 注释,请清理后提交', 'javascript,typescript', '',
|
||
];
|
||
|
||
const GUIDE_AOA: string[][] = [
|
||
['字段', '含义', '取值/格式', '示例'],
|
||
['id', '规则唯一标识', '小写字母、数字、连字符,全局唯一', 'no-todo'],
|
||
['severity', '严重级别', 'error / warning / info', 'warning'],
|
||
['description', '规则简述(给人看)', '自由文本', '禁止提交 TODO 注释'],
|
||
['message', '命中时展示给开发者的提示语', '自由文本', '发现 TODO 注释,请清理后提交'],
|
||
['languages', '生效的语言', '逗号分隔,留空表示对所有语言生效', 'javascript,typescript'],
|
||
['excludeLanguages', '排除的语言', '逗号分隔,可留空', ''],
|
||
['', '', '', ''],
|
||
['填写说明', '', '', ''],
|
||
['1. severity 仅接受 error / warning / info 三个值', '', '', ''],
|
||
['2. languages / excludeLanguages 多值用英文逗号分隔', '', '', ''],
|
||
['3. 示例行可删除,仅作填写参考', '', '', ''],
|
||
['4. 该模板可直接用于「从模板导入」功能回环校验', '', '', ''],
|
||
];
|
||
|
||
export async function exportTemplate(): Promise<void> {
|
||
const uri = await vscode.window.showSaveDialog({
|
||
defaultUri: vscode.Uri.file('code-review-rules-template.xlsx'),
|
||
filters: { 'Excel': ['xlsx'] },
|
||
saveLabel: t('exportTemplate.saveLabel'),
|
||
});
|
||
if (!uri) { return; }
|
||
|
||
const wb = XLSX.utils.book_new();
|
||
|
||
const wsRules = XLSX.utils.aoa_to_sheet([RULES_HEADER, RULES_EXAMPLE]);
|
||
wsRules['!cols'] = [
|
||
{ wch: 16 }, { wch: 10 }, { wch: 32 }, { wch: 40 }, { wch: 24 }, { wch: 20 },
|
||
];
|
||
XLSX.utils.book_append_sheet(wb, wsRules, '规则');
|
||
|
||
const wsGuide = XLSX.utils.aoa_to_sheet(GUIDE_AOA);
|
||
wsGuide['!cols'] = [{ wch: 22 }, { wch: 28 }, { wch: 42 }, { wch: 36 }];
|
||
XLSX.utils.book_append_sheet(wb, wsGuide, '说明');
|
||
|
||
try {
|
||
XLSX.writeFile(wb, uri.fsPath);
|
||
const openFolder = t('exportTemplate.openFolder');
|
||
const choice = await vscode.window.showInformationMessage(
|
||
t('exportTemplate.success'), openFolder,
|
||
);
|
||
if (choice === openFolder) {
|
||
vscode.commands.executeCommand('revealFileInOS', uri);
|
||
}
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
vscode.window.showErrorMessage(t('exportTemplate.fail', { 0: msg }));
|
||
}
|
||
}
|
||
````
|
||
|
||
### 3.2 新增 `src/rules/converters/template-converter.ts`
|
||
|
||
````ts
|
||
import * as path from 'path';
|
||
import * as XLSX from 'xlsx';
|
||
import type { ImportableRule, ValidationIssue } from '../import-types';
|
||
import type { Severity } from '../../types';
|
||
import { t } from '../../i18n/messages';
|
||
|
||
const REQUIRED_HEADERS = ['id', 'severity', 'description', 'message'];
|
||
const VALID_SEVERITY = ['error', 'warning', 'info'];
|
||
|
||
function splitList(v: unknown): string[] {
|
||
const s = String(v ?? '').trim();
|
||
if (!s) { return []; }
|
||
return s.split(/[,;、\n]/).map(x => x.trim()).filter(Boolean);
|
||
}
|
||
|
||
export interface TemplateParseResult {
|
||
rules: ImportableRule[];
|
||
validRules: ImportableRule[];
|
||
yamlContent: string;
|
||
skippedCount: number;
|
||
}
|
||
|
||
export function parseTemplate(srcPath: string): TemplateParseResult {
|
||
// 准入校验-1: 文件格式
|
||
const ext = path.extname(srcPath).toLowerCase();
|
||
if (!['.xlsx', '.xls'].includes(ext)) {
|
||
throw new Error(t('import.template.badFormat'));
|
||
}
|
||
let wb: XLSX.WorkBook;
|
||
try { wb = XLSX.readFile(srcPath); }
|
||
catch { throw new Error(t('import.template.badFormat')); }
|
||
|
||
// 准入校验-2: 表头结构
|
||
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||
const rows = XLSX.utils.sheet_to_json<Record<string, string>>(sheet, { defval: '' });
|
||
if (rows.length === 0) {
|
||
throw new Error(t('import.template.empty'));
|
||
}
|
||
const header = Object.keys(rows[0]).map(k => k.trim().toLowerCase());
|
||
const missing = REQUIRED_HEADERS.filter(h => !header.includes(h));
|
||
if (missing.length > 0) {
|
||
throw new Error(t('import.template.notTemplate', { 0: missing.join(', ') }));
|
||
}
|
||
|
||
// 数据行解析 + 校验
|
||
const totalRows = rows.length;
|
||
const rules: ImportableRule[] = rows
|
||
.filter(r => String(r.id ?? '').trim() !== '')
|
||
.map((r, idx) => {
|
||
const rowNo = idx + 2;
|
||
const issues: ValidationIssue[] = [];
|
||
|
||
const sevRaw = String(r.severity ?? '').trim().toLowerCase();
|
||
const severity: Severity = VALID_SEVERITY.includes(sevRaw) ? (sevRaw as Severity) : 'warning';
|
||
if (!VALID_SEVERITY.includes(sevRaw)) {
|
||
issues.push({ field: 'severity', severity: 'warning', message: `severity 非法: "${r.severity ?? ''}"` });
|
||
}
|
||
|
||
const description = String(r.description ?? '').trim();
|
||
if (!description) {
|
||
issues.push({ field: 'description', severity: 'error', message: 'description 为空' });
|
||
}
|
||
|
||
const message = String(r.message ?? '').trim();
|
||
if (!message) {
|
||
issues.push({ field: 'message', severity: 'error', message: 'message 为空' });
|
||
}
|
||
|
||
return {
|
||
id: String(r.id).trim(),
|
||
severity,
|
||
description,
|
||
message,
|
||
languages: splitList(r.languages),
|
||
excludeLanguages: splitList(r.excludeLanguages),
|
||
rowNumber: rowNo,
|
||
validationIssues: issues.length > 0 ? issues : undefined,
|
||
};
|
||
});
|
||
|
||
const validRules = rules.filter(r => !r.validationIssues);
|
||
const yamlContent = buildYaml(validRules);
|
||
const skippedCount = totalRows - rules.length;
|
||
|
||
return { rules, validRules, yamlContent, skippedCount };
|
||
}
|
||
|
||
function buildYaml(rules: ImportableRule[]): string {
|
||
const lines: string[] = [];
|
||
for (const r of rules) {
|
||
lines.push(`- id: ${r.id}`);
|
||
lines.push(` severity: ${r.severity}`);
|
||
lines.push(` description: ${r.description}`);
|
||
lines.push(` message: ${r.message}`);
|
||
if (r.languages?.length) {
|
||
lines.push(` languages: [${r.languages.join(', ')}]`);
|
||
}
|
||
if (r.excludeLanguages?.length) {
|
||
lines.push(` excludeLanguages: [${r.excludeLanguages.join(', ')}]`);
|
||
}
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
````
|
||
|
||
### 3.3 新增 `src/rules/converters/dedup-prompt.ts`
|
||
|
||
> **约束**:AI 只做去重判定,严禁修改已有字段、禁止添加/删除规则。若输入 YAML 无重复,原样返回即可。
|
||
|
||
```ts
|
||
import type { CustomRule } from '../../types';
|
||
import { getLanguage, type Language } from '../../i18n/messages';
|
||
|
||
export function buildDedupOnlyPrompt(
|
||
yamlContent: string,
|
||
existingRules: CustomRule[],
|
||
): { system: string; user: string } {
|
||
const lang = getLanguage();
|
||
const s = PROMPTS[lang];
|
||
|
||
const existingList = existingRules.length === 0
|
||
? s.noExisting
|
||
: existingRules.map(r =>
|
||
`- id: ${r.id} | severity: ${r.severity} | description: ${r.description} | message: ${r.message}`
|
||
).join('\n');
|
||
|
||
const system = [
|
||
s.role,
|
||
s.taskTitle,
|
||
s.taskLines.join('\n'),
|
||
s.rulesTitle,
|
||
s.rulesLines.join('\n'),
|
||
s.constraintTitle,
|
||
s.constraintLines.join('\n'),
|
||
s.existingTitle,
|
||
existingList,
|
||
].join('\n\n');
|
||
|
||
const user = s.userPrefix + '\n\n' + yamlContent;
|
||
return { system, user };
|
||
}
|
||
|
||
const PROMPTS: Record<Language, {
|
||
role: string;
|
||
taskTitle: string;
|
||
taskLines: string[];
|
||
rulesTitle: string;
|
||
rulesLines: string[];
|
||
constraintTitle: string;
|
||
constraintLines: string[];
|
||
existingTitle: string;
|
||
noExisting: string;
|
||
userPrefix: string;
|
||
}> = {
|
||
'zh-CN': {
|
||
role: '你是规则去重判定助手。你只输出 YAML,不输出任何解释。',
|
||
taskTitle: '## 任务',
|
||
taskLines: [
|
||
'下面是已标准化的规则 YAML。你只负责对照"现有规则"为每条规则标注去重字段。',
|
||
'为每条规则补充以下字段(如果无重复则标注 none):',
|
||
'- duplicateOf: 重复的规则 ID(如 eslint/no-console、custom/my-rule)',
|
||
'- duplicateLevel: exact(完全相同)/ overlap(部分重叠)/ none(无重复)',
|
||
'- duplicateReason: 仅 overlap 时必填,简要说明重叠原因',
|
||
],
|
||
rulesTitle: '## 判定规则',
|
||
rulesLines: [
|
||
'1. exact: id 完全相同,或 description + message 语义完全一致',
|
||
'2. overlap: 检测目标/场景部分重叠,但并非完全相同',
|
||
'3. none: 与现有规则无冲突',
|
||
],
|
||
constraintTitle: '## ⚠️ 严格约束(必须遵守)',
|
||
constraintLines: [
|
||
'1. 严禁修改任何已有字段的值(id、severity、description、message、languages、excludeLanguages)',
|
||
'2. 严禁添加新规则,严禁删除或合并规则',
|
||
'3. 规则数量必须与输入完全一致,顺序必须与输入完全一致',
|
||
'4. 你只允许添加三个字段: duplicateOf、duplicateLevel、duplicateReason',
|
||
'5. 如果某条规则与现有规则无任何重复,设置 duplicateLevel: none 即可,不需要补充 duplicateOf',
|
||
'6. 输出纯 YAML,不要用 markdown 代码块包裹',
|
||
],
|
||
existingTitle: '## 现有规则',
|
||
noExisting: '(无)',
|
||
userPrefix: '## 待去重的规则 YAML',
|
||
},
|
||
'en': {
|
||
role: 'You are a rule deduplication assistant. Output YAML only, no explanations.',
|
||
taskTitle: '## Task',
|
||
taskLines: [
|
||
'Below is standardized rule YAML. Only annotate dedup fields against the "Existing Rules" list.',
|
||
'For each rule, add (mark none if no conflict):',
|
||
'- duplicateOf: duplicated rule ID (e.g. eslint/no-console, custom/my-rule)',
|
||
'- duplicateLevel: exact / overlap / none',
|
||
'- duplicateReason: required only for overlap',
|
||
],
|
||
rulesTitle: '## Judgement Rules',
|
||
rulesLines: [
|
||
'1. exact: identical id, or semantically identical description+message',
|
||
'2. overlap: partially overlapping target/scenario',
|
||
'3. none: no conflict with existing rules',
|
||
],
|
||
constraintTitle: '## ⚠️ Strict Constraints (MUST follow)',
|
||
constraintLines: [
|
||
'1. Do NOT modify any existing field values (id, severity, description, message, languages, excludeLanguages)',
|
||
'2. Do NOT add, delete, or merge rules',
|
||
'3. Rule count and order must exactly match the input',
|
||
'4. Only add three fields: duplicateOf, duplicateLevel, duplicateReason',
|
||
'5. If a rule has no duplication, set duplicateLevel: none without duplicateOf',
|
||
'6. Output pure YAML, do NOT wrap in markdown code fences',
|
||
],
|
||
existingTitle: '## Existing Rules',
|
||
noExisting: '(none)',
|
||
userPrefix: '## YAML to deduplicate',
|
||
},
|
||
'ja': {
|
||
role: 'あなたはルール重複判定アシスタントです。YAML のみ出力し、説明は不要です。',
|
||
taskTitle: '## タスク',
|
||
taskLines: [
|
||
'以下は標準化されたルール YAML です。既存ルールと照合し、重複フィールドのみ注釈してください。',
|
||
'各ルールに以下を追加(重複がない場合は none と表記):',
|
||
'- duplicateOf: 重複ルール ID(例: eslint/no-console, custom/my-rule)',
|
||
'- duplicateLevel: exact / overlap / none',
|
||
'- duplicateReason: overlap 時のみ必須',
|
||
],
|
||
rulesTitle: '## 判定ルール',
|
||
rulesLines: [
|
||
'1. exact: ID が同一、または description+message が意味的に完全一致',
|
||
'2. overlap: 検出対象/シナリオが部分重複',
|
||
'3. none: 既存ルールと競合なし',
|
||
],
|
||
constraintTitle: '## ⚠️ 厳格な制約(必ず遵守)',
|
||
constraintLines: [
|
||
'1. 既存フィールド(id, severity, description, message, languages, excludeLanguages)の値を一切変更しない',
|
||
'2. ルールの追加、削除、統合を一切行わない',
|
||
'3. ルールの数と順序は入力と完全に一致させる',
|
||
'4. 追加できるフィールドは duplicateOf, duplicateLevel, duplicateReason のみ',
|
||
'5. 重複がないルールは duplicateLevel: none とし、duplicateOf は付けない',
|
||
'6. 純粋な YAML を出力し、markdown コードブロックで囲まない',
|
||
],
|
||
existingTitle: '## 既存ルール',
|
||
noExisting: '(なし)',
|
||
userPrefix: '## 重複排除対象の YAML',
|
||
},
|
||
};
|
||
```
|
||
|
||
### 3.4 修改 `src/rules/import-service.ts`
|
||
|
||
新增 `ImportService.importTemplate` 方法:
|
||
|
||
```ts
|
||
import { parseTemplate } from './converters/template-converter';
|
||
import { buildDedupOnlyPrompt } from './converters/dedup-prompt';
|
||
|
||
export class ImportService {
|
||
// ... 现有 registerConverter、convert、applyConversion 不动
|
||
|
||
async importTemplate(
|
||
srcPath: string,
|
||
name: string,
|
||
context: vscode.ExtensionContext,
|
||
): Promise<ConversionResult> {
|
||
// [1] 程序解析 + 准入校验(失败即 throw,由调用方 catch 弹错误提示)
|
||
const { rules, validRules, yamlContent, skippedCount } = parseTemplate(srcPath);
|
||
|
||
// [2] AI 去重(仅有效规则;错误规则不送 AI)
|
||
let dedupedValidRules: ImportableRule[] = validRules;
|
||
if (validRules.length > 0) {
|
||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||
const existingRules = workspaceRoot ? loadActiveRules(workspaceRoot) : [];
|
||
const { system, user } = buildDedupOnlyPrompt(yamlContent, existingRules);
|
||
const dedupedYaml = await convertContentWithAI(user, context, system);
|
||
if (dedupedYaml) {
|
||
dedupedValidRules = parseImportableYaml(dedupedYaml);
|
||
} else {
|
||
// AI 去重失败,降级:有效规则不带 duplicateLevel,用户自行判断
|
||
vscode.window.showWarningMessage(t('import.dedupFailed'));
|
||
}
|
||
}
|
||
|
||
// [3] 合并
|
||
const errorRules = rules.filter(r => r.validationIssues?.length);
|
||
const allRules = [...dedupedValidRules, ...errorRules];
|
||
|
||
const exactCount = dedupedValidRules.filter(r => r.duplicateLevel === 'exact').length;
|
||
const overlapCount = dedupedValidRules.filter(r => r.duplicateLevel === 'overlap').length;
|
||
|
||
return {
|
||
rules: allRules,
|
||
yamlContent,
|
||
sourceFileName: path.basename(srcPath),
|
||
exactCount,
|
||
overlapCount,
|
||
skippedCount,
|
||
errorCount: errorRules.length,
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
> **注意**:`loadActiveRules`、`convertContentWithAI`、`parseImportableYaml` 均为本文件已有函数/导入,无需额外 import。
|
||
|
||
### 3.5 修改 `src/rules/import-preview.ts`
|
||
|
||
> **原则**:增量扩展,仅新增错误规则组渲染 + skippedCount 提示。现有 exact/overlap/none 三组逻辑不动。UI 样式沿用现有 `renderSection`,不做独立设计。
|
||
|
||
#### 3.5.1 `showImportPreview` 改动
|
||
|
||
`keepRule` 初始化只计算非错误规则:
|
||
|
||
```ts
|
||
const keepRule: Record<string, boolean> = {};
|
||
for (const rule of result.rules) {
|
||
if (!rule.validationIssues?.length) {
|
||
keepRule[rule.id] = rule.duplicateLevel !== 'exact';
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.5.2 新增 `renderErrorSection` 辅助函数
|
||
|
||
```ts
|
||
function renderErrorSection(rules: ImportableRule[]): string {
|
||
if (rules.length === 0) { return ''; }
|
||
const sectionId = 'section-error';
|
||
return `
|
||
<div style="margin-bottom:12px;">
|
||
<div class="section-header" onclick="toggleSection('${sectionId}')">
|
||
<span style="font-size:14px;">🚫</span>
|
||
<span class="section-title">${t('import.sectionInvalid')}(${rules.length})</span>
|
||
<span class="section-arrow">▼</span>
|
||
</div>
|
||
<div id="${sectionId}">
|
||
${rules.map(renderErrorCard).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderErrorCard(rule: ImportableRule): string {
|
||
const issues = (rule.validationIssues || []).map(i =>
|
||
`<div style="color:#f48771;font-size:12px;margin-bottom:4px;">⚠ ${i.message}</div>`
|
||
).join('');
|
||
|
||
return `
|
||
<div class="rule-card" style="opacity:0.7;border-color:rgba(248,81,73,0.3);">
|
||
<div class="rule-card-header" style="cursor:default;">
|
||
<div class="rule-card-summary">
|
||
<span style="font-family:monospace;font-size:13px;font-weight:600;">${rule.id}</span>
|
||
<span style="color:#f48771;font-size:11px;font-weight:600;">${t('import.cannotImport')}</span>
|
||
</div>
|
||
</div>
|
||
<div class="rule-card-body" style="border-top:1px solid rgba(248,81,73,0.15);padding-top:8px;">
|
||
${issues}
|
||
<div style="color:#8b949e;font-size:11px;margin-top:6px;">
|
||
severity: ${rule.severity} | description: ${rule.description} | message: ${rule.message}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
```
|
||
|
||
#### 3.5.3 `renderPreviewHtml` 分组逻辑
|
||
|
||
```ts
|
||
const errorRules = result.rules.filter(r => r.validationIssues?.length);
|
||
const cleanRules = result.rules.filter(r => !r.validationIssues?.length);
|
||
|
||
const exactRules = cleanRules.filter(r => r.duplicateLevel === 'exact');
|
||
const overlapRules = cleanRules.filter(r => r.duplicateLevel === 'overlap');
|
||
const noneRules = cleanRules.filter(
|
||
r => r.duplicateLevel !== 'exact' && r.duplicateLevel !== 'overlap'
|
||
);
|
||
```
|
||
|
||
#### 3.5.4 顶部 skippedRows 提示
|
||
|
||
```ts
|
||
const skippedHint = result.skippedCount
|
||
? `<div class="summary-bar" style="border-color:rgba(88,166,255,0.3);color:#58a6ff;">${t('import.template.skipped', { 0: String(result.skippedCount) })}</div>`
|
||
: '';
|
||
```
|
||
|
||
#### 3.5.5 分组渲染顺序
|
||
|
||
```ts
|
||
${skippedHint}
|
||
${renderErrorSection(errorRules)}
|
||
${renderSection(t('import.sectionExact'), '⛔', exactRules, false)}
|
||
${renderSection(t('import.sectionOverlap'), '⚠️', overlapRules, true)}
|
||
${renderSection(t('import.sectionNone'), '✅', noneRules, false)}
|
||
```
|
||
|
||
#### 3.5.6 validRules 全空时的处理
|
||
|
||
当 `cleanRules.length === 0`(文件全是错误规则):
|
||
|
||
```ts
|
||
// 确认按钮禁用 + 提示
|
||
const confirmDisabled = cleanRules.length === 0;
|
||
// 按钮: <button class="btn btn-primary" ${confirmDisabled ? 'disabled style="opacity:0.5;cursor:not-allowed;"' : ''} onclick="doConfirm()">
|
||
// 状态栏: 替换为 "无有效规则可导入,请修改文件后重新导入"
|
||
const emptyValidHint = cleanRules.length === 0
|
||
? `<div class="validation-error" style="display:block;">${t('import.emptyValidRules')}</div>`
|
||
: '';
|
||
```
|
||
|
||
#### 3.5.7 确认导入时只写入有效规则
|
||
|
||
`doConfirm` 中 `collectEditedRules` 排除错误卡片(或直接沿用现有 `keepRule` 过滤——错误规则不在 `keepRule` 中,`applyConversion` 自动忽略)。
|
||
|
||
### 3.6 修改 `src/views/setupView.ts`
|
||
|
||
#### 3.6.1 HTML(「自定义规则」区块新增 checkbox + 按钮)
|
||
|
||
```html
|
||
<div class="field" style="margin-top:8px;">
|
||
<div class="input-group">
|
||
<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="error-hint" id="ruleNameError">${t('setup.ruleNameRequired')}</div>
|
||
<div class="field-hint">${t('setup.ruleNameHint')}</div>
|
||
|
||
<label style="display:flex;align-items:center;gap:6px;margin-top:8px;font-size:12px;">
|
||
<input type="checkbox" id="useTemplateMode">
|
||
${t('setup.fromTemplate')}
|
||
</label>
|
||
|
||
<button class="btn btn-sm" style="margin-top:6px;" onclick="exportTemplate()">${t('setup.exportTemplate')}</button>
|
||
</div>
|
||
```
|
||
|
||
#### 3.6.2 webview script
|
||
|
||
```js
|
||
function addRule() {
|
||
const name = document.getElementById('newRuleInput').value;
|
||
const useTemplateMode = document.getElementById('useTemplateMode').checked;
|
||
vscode.postMessage({ type: 'addRule', name, useTemplateMode });
|
||
}
|
||
function exportTemplate() {
|
||
vscode.postMessage({ type: 'exportTemplate' });
|
||
}
|
||
```
|
||
|
||
#### 3.6.3 Provider: `onDidReceiveMessage` 分流
|
||
|
||
```ts
|
||
import { exportTemplate } from '../rules/export-service';
|
||
|
||
// message handler:
|
||
case 'addRule':
|
||
await this.addRule(msg.name, msg.useTemplateMode);
|
||
await this.pushConfig();
|
||
break;
|
||
|
||
case 'exportTemplate':
|
||
await exportTemplate();
|
||
break;
|
||
```
|
||
|
||
#### 3.6.4 Provider: `addRule` 方法签名改为支持分流
|
||
|
||
> **注意**:`addRule` 当前参数为 `(name: string)`,改为 `(name: string, useTemplateMode?: boolean)`,原有 AI 分支保持不变。
|
||
|
||
```ts
|
||
private async addRule(name: string, useTemplateMode?: boolean): Promise<void> {
|
||
if (!name.trim()) { return; }
|
||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||
if (!workspaceRoot) { return; }
|
||
|
||
if (useTemplateMode) {
|
||
// ── 模板直通链路 ──
|
||
const result = await vscode.window.showOpenDialog({
|
||
canSelectMany: false,
|
||
openLabel: t('setup.selectTemplateFile'),
|
||
filters: { 'Excel': ['xlsx', 'xls'] },
|
||
});
|
||
if (!result || result.length === 0) { return; }
|
||
|
||
try {
|
||
const conversion = await vscode.window.withProgress({
|
||
location: vscode.ProgressLocation.Notification,
|
||
title: t('setup.importingTemplate'),
|
||
}, async () => {
|
||
return await this.importService.importTemplate(
|
||
result[0].fsPath, name, this.context,
|
||
);
|
||
});
|
||
|
||
const decision = await showImportPreview(conversion);
|
||
if (decision?.confirmed) {
|
||
const rulesDir = path.join(workspaceRoot, '.code-review', 'rules');
|
||
if (!fs.existsSync(rulesDir)) { fs.mkdirSync(rulesDir, { recursive: true }); }
|
||
const yamlFileName = name.endsWith('.yaml') ? name : `${name}.yaml`;
|
||
const yamlPath = path.join(rulesDir, yamlFileName);
|
||
if (fs.existsSync(yamlPath)) {
|
||
vscode.window.showErrorMessage(t('setup.ruleFileExists', { 0: name }));
|
||
return;
|
||
}
|
||
this.importService.applyConversion(conversion, decision, yamlPath);
|
||
}
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
vscode.window.showErrorMessage(msg);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// ── 原 AI 链路,以下不动 ──
|
||
// ... 现有 addRule 逻辑
|
||
}
|
||
```
|
||
|
||
### 3.7 修改 `src/activation/commands.ts`
|
||
|
||
```ts
|
||
import { exportTemplate } from '../rules/export-service';
|
||
|
||
// 在 registerCommands 中追加:
|
||
context.subscriptions.push(
|
||
vscode.commands.registerCommand('codeReviewer.exportTemplate', () => exportTemplate()),
|
||
);
|
||
```
|
||
|
||
### 3.8 修改 `package.json`
|
||
|
||
`contributes.commands` 追加:
|
||
|
||
```json
|
||
{
|
||
"command": "codeReviewer.exportTemplate",
|
||
"title": "Code Purifier: 导出规则模板"
|
||
}
|
||
```
|
||
|
||
### 3.9 修改 `src/i18n/messages.ts`
|
||
|
||
| key | 中文 | 英文 | 日文 |
|
||
|-----|------|------|------|
|
||
| `setup.fromTemplate` | 从模板导入(跳过 AI 解析,仅支持 Excel 模板) | Import from template (skip AI parsing, Excel only) | テンプレートからインポート(AI 解析スキップ、Excel のみ) |
|
||
| `setup.exportTemplate` | 📤 导出模板 | 📤 Export Template | 📤 テンプレートをエクスポート |
|
||
| `setup.selectTemplateFile` | 选择模板文件 | Select template file | テンプレートファイルを選択 |
|
||
| `setup.importingTemplate` | 正在导入模板... | Importing template... | テンプレートをインポート中... |
|
||
| `import.template.badFormat` | 文件格式错误,请使用 Excel 模板(.xlsx/.xls) | Bad file format, please use Excel template (.xlsx/.xls) | ファイル形式エラー、Excel テンプレートを使用してください |
|
||
| `import.template.empty` | 文件为空 | File is empty | ファイルが空です |
|
||
| `import.template.notTemplate` | 不是模板文件,缺少列: {0} | Not a template file, missing columns: {0} | テンプレートファイルではありません、欠損列: {0} |
|
||
| `import.template.skipped` | 已跳过 {0} 行空数据 | Skipped {0} empty rows | {0} 行の空データをスキップしました |
|
||
| `import.sectionInvalid` | 错误规则 | Error Rules | エラー行 |
|
||
| `import.cannotImport` | 将自动丢弃,请修改文件后重新导入 | Will be auto-discarded, please fix the file and retry | 自動破棄されます、ファイルを修正して再インポートしてください |
|
||
| `import.dedupFailed` | AI 去重失败,规则将不带去重标记导入 | AI dedup failed, rules imported without dedup marks | AI 重複排除失敗、重複マークなしでインポート |
|
||
| `import.emptyValidRules` | 无有效规则可导入,请修改文件后重新导入 | No valid rules to import, please fix the file and retry | 有効なルールがありません、ファイルを修正して再インポートしてください |
|
||
| `exportTemplate.saveLabel` | 导出模板 | Export Template | エクスポート |
|
||
| `exportTemplate.success` | 模板已导出 | Template exported | テンプレートをエクスポートしました |
|
||
| `exportTemplate.fail` | 导出失败:{0} | Export failed: {0} | エクスポート失敗: {0} |
|
||
| `exportTemplate.openFolder` | 打开文件夹 | Reveal in Folder | フォルダを開く |
|
||
|
||
---
|
||
|
||
## 四、验收标准
|
||
|
||
### 4.1 导出模板
|
||
|
||
1. 侧边栏「自定义规则」区块出现「📤 导出模板」按钮
|
||
2. 点击弹出保存对话框,默认文件名 `code-review-rules-template.xlsx`
|
||
3. 生成文件含 2 个 sheet:「规则」(表头+示例行)、「说明」(字段说明+填写规范)
|
||
4. 列名: `id, severity, description, message, languages, excludeLanguages`
|
||
5. 导出成功右下角提示,点击「打开文件夹」可定位文件
|
||
6. 命令面板 `Code Purifier: 导出规则模板` 触发同样流程
|
||
7. 中/英/日三语切换下,按钮与提示文案正确
|
||
8. 取消保存对话框不报错
|
||
|
||
### 4.2 模板导入
|
||
|
||
1. 「自定义规则」区块出现 checkbox「从模板导入」
|
||
2. 不勾选 → 走原 AI 链路,行为零回归
|
||
3. 勾选 → 文件选择仅过滤 `xlsx/xls`
|
||
4. 非 Excel → 提示"文件格式错误"
|
||
5. 损坏 Excel → 提示"文件格式错误"
|
||
6. 表头非标准 → 提示"缺少列: ..."
|
||
7. 标准模板 → 进入预览,exact 重复默认不勾选
|
||
8. 预览含错误规则组(置顶,无 checkbox 只读)、+ 原有 3 组
|
||
9. 空行被跳过 → 顶部提示"已跳过 N 行空数据"
|
||
10. 确认导入写入 `.code-review/rules/<name>.yaml`
|
||
11. 准入校验失败仅提示,无残留文件
|
||
12. severity 拼错行 → 错误规则组,标注原因,确认时自动丢弃
|
||
13. description/message 为空 → 同上
|
||
14. 表内 id 重复 → 不校验,按普通有效规则进 AI 去重
|
||
15. 错误规则不参与 AI 去重
|
||
16. validRules 为空 → 确认按钮禁用,提示"无有效规则可导入"
|
||
17. AI 去重失败 → 降级,有效规则不带去重标记进预览,弹出 warning
|
||
18. 导入速度明显快于 AI 链路
|
||
|
||
### 4.3 闭环验证
|
||
|
||
1. 导出模板 → 不修改直接导入 → 预览出现 `no-todo` 规则
|
||
2. 导出模板 → 删示例行 → 填 2 条新规则 → 导入 → 预览 2 条新规则
|
||
3. 导出模板 → 填与已有规则相同 id → 导入 → 预览标记 exact 重复
|
||
|
||
---
|
||
|
||
## 五、风险与应对
|
||
|
||
| 风险 | 等级 | 应对 |
|
||
|------|------|------|
|
||
| 导入侧 AI 链路被误改 | 中 | 不碰 `ExcelConverter`、`prompt-builder.ts`、原 addRule 分支 |
|
||
| AI 去重 prompt 越权修改字段 | 中 | prompt 增加严格约束段 + 预览人工确认兜底 |
|
||
| 去重 AI 故障(网络/限流) | 中 | 降级处理:有效规则不带去重标记进预览 + warning 提示 |
|
||
| `xlsx` 写盘无权限 | 低 | try/catch + 友好提示 |
|
||
| 跨平台路径 | 低 | `vscode.Uri.fsPath` 不硬编码分隔符 |
|
||
| 国际化漏键 | 低 | 中英日三语同步增补 |
|
||
|
||
---
|
||
|
||
## 六、后续可扩展点(本次不做)
|
||
|
||
- 导出当前规则(备份/迁移)
|
||
- 模板多语言(根据语言切换示例)
|
||
- 模板版本号(用于兼容判断)
|
||
- YAML 直通链路校验补齐
|
||
- AI 链路校验补齐
|
||
- 纯本地去重函数
|