# 导入预览 · 错误规则可编辑与「添加」设计书 > 版本:v1.0 > 日期:2026-07-31 > 适用项目:vscode-code-reviewer > 参考文档:`2026-07-30-export-template-design-v2.md`(错误规则组雏形)、`2026-07-25-import-preview-edit-design.md`(预览编辑) --- ## 一、方案概览 ### 1.1 目标 模板导入(勾选「使用模板文件导入」)进入预览后,**错误规则**不再是纯只读展示,而是: - 卡片提供**完整编辑表单**(与有效规则一致:id / severity / description / message / languages / excludeLanguages) - 每张错误卡片有独立「添加」按钮,点击后: 1. **重新校验格式**(id/description/message 非空、severity 合法) 2. 校验通过后**单条 AI 去重**(复用 `buildDedupOnlyPrompt`),失败先重试一次,仍失败降级为 `none` 3. 按去重结果(exact/overlap/none)把该规则**移入对应分区**,变为正常可编辑规则(带保留/注释切换) - 添加时若 id 与预览中已有规则重复 → **阻止并提示修改 id** - 未添加的错误规则维持现状:确认导入时自动丢弃、不参与校验 - 确认写盘必须包含已添加的规则(避免走 raw yaml 路径丢失) ### 1.2 数据流 ``` 现状(仅展示): parseTemplate → errorRules(带 validationIssues)→ 预览只读展示 → 确认时自动丢弃 改造后: parseTemplate → errorRules → 预览可编辑错误卡片 ├─ 用户编辑字段 → 点击「添加」 │ ├─ [扩展端] 格式校验 → 失败 → postMessage addError(卡片内提示) │ ├─ [扩展端] id 冲突检查(对照预览已有有效规则)→ 冲突 → addError │ ├─ [扩展端] AI 单条去重(重试 1 次 → 降级 none)→ postMessage ruleAdded │ └─ [前端] DOM 手术:错误卡片移入 exact/overlap/none 分区, │ 去除 data-error、换保留/注释按钮、更新计数 └─ 确认导入 ├─ 已添加或已编辑 → 前端回传 editedRules(含已添加规则)→ renderRulesToYaml 写盘 └─ 无编辑无添加 → 走原 buildFinalYamlFromRaw(零回归) ``` ### 1.3 范围 | 类型 | 内容 | |------|------| | 含 | 错误卡片完整编辑表单;「添加」按钮(校验 + 单条 AI 去重 + 移入分区);id 冲突拦截;去重失败重试/降级;确认写盘含已添加规则;计数动态更新 | | 不含 | 非模板导入路径(AI 链路无 validationIssues,不受影响);批量「全部添加」按钮;错误规则的本地重复检查 | | 不触碰 | `parseTemplate` 校验逻辑、`buildDedupOnlyPrompt`、`applyConversion`、其余转换器 | --- ## 二、架构设计 ### 2.1 模块划分 ``` src/rules/ ├── import-service.ts ← 修改: 新增 export async function dedupSingleRule() ├── import-preview.ts ← 修改: 错误卡片可编辑 + 添加流程(前端 JS + 扩展消息处理) └── import-types.ts ← 不改(复用现有类型) src/i18n/messages.ts ← 修改: 新增 key,调整 import.cannotImport 文案 ``` ### 2.2 关键接口 ```ts // import-service.ts 新增导出函数 export interface DedupResult { duplicateLevel: 'exact' | 'overlap' | 'none'; duplicateOf?: string; duplicateReason?: string; } // 单条规则 AI 去重:失败重试 1 次,仍失败返回 null(调用方降级为 none) export async function dedupSingleRule( rule: ImportableRule, context: vscode.ExtensionContext, ): Promise ``` ```ts // import-preview.ts — Webview 消息协议扩展 // 前端 → 扩展 interface AddErrorRuleMessage { type: 'addErrorRule'; ruleId: string; // 原始卡片 id(用于在 result.rules 中定位) rule: { // 当前卡片全部字段(含用户编辑) id: string; severity: string; description: string; message: string; languages?: string[]; excludeLanguages?: string[]; }; } // 扩展 → 前端 interface AddErrorMessage { type: 'addError'; ruleId: string; message: string; } interface RuleAddedMessage { type: 'ruleAdded'; ruleId: string; // 原始卡片 id(前端按此定位 DOM) id: string; // 去重后的最终 id(可能被用户编辑过) duplicateLevel: 'exact' | 'overlap' | 'none'; duplicateOf?: string; duplicateReason?: string; dedupFailed: boolean; // true 表示降级为 none } ``` `convertContentWithAI` 增加可选第 4 参 `quiet?: boolean`(去重失败时抑制内置 error toast,改由前端降级提示)。现有调用点均传 3 参,向后兼容。 --- ## 三、详细实现 ### 3.1 `import-service.ts`:新增 `dedupSingleRule` ```ts export interface DedupResult { duplicateLevel: 'exact' | 'overlap' | 'none'; duplicateOf?: string; duplicateReason?: string; } export async function dedupSingleRule( rule: ImportableRule, context: vscode.ExtensionContext, ): Promise { const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; const existingRules = workspaceRoot ? loadActiveRules(workspaceRoot) : []; const singleYaml = [ `- id: ${rule.id}`, ` severity: ${rule.severity}`, ` description: ${rule.description}`, ` message: ${rule.message}`, ...(rule.languages?.length ? [` languages: [${rule.languages.join(', ')}]`] : []), ...(rule.excludeLanguages?.length ? [` excludeLanguages: [${rule.excludeLanguages.join(', ')}]`] : []), ].join('\n'); const { system, user } = buildDedupOnlyPrompt(singleYaml, existingRules); for (let attempt = 0; attempt < 2; attempt++) { const out = await convertContentWithAI(user, context, system, true); // quiet if (!out) continue; const parsed = parseImportableYaml(out); if (parsed.length === 0) continue; const r = parsed[0]; return { duplicateLevel: r.duplicateLevel ?? 'none', duplicateOf: r.duplicateOf, duplicateReason: r.duplicateReason, }; } return null; } ``` ### 3.2 `import-service.ts`:`convertContentWithAI` 增加 quiet 参数 ```ts export async function convertContentWithAI( content: string, context: vscode.ExtensionContext, systemPrompt?: string, quiet?: boolean, ): Promise { // ... getApiKey 失败:quiet 时仅返回 null,不弹 toast // ... provider.chat 异常:quiet 时仅返回 null,不弹 toast // ... 其余逻辑不变 } ``` ### 3.3 `import-preview.ts`:错误卡片渲染改造 **废弃 `renderErrorCard` / `renderErrorSection` 的只读版**,统一由 `renderRuleCard` 承担,新增 `isError` 分支: ``` renderRuleCard(rule, { isError }) ├─ 普通卡片:现状逻辑不变 └─ 错误卡片: ├─ 卡片属性 data-ruleid + data-error="true" + 错误边框(保留现 opacity/border 样式) ├─ 折叠态摘要:id(可编辑)+ 错误徽标(新 i18n,如「需修复后添加」) ├─ 展开态表单:与普通卡片完全一致(id 可改、severity 下拉、 │ description/message textarea、languages/excludeLanguages 标签) ├─ 表单顶部:错误原因列表(复用现 issues 渲染) ├─ 顶部操作区:用「添加」按钮替代「保留/注释」toggle(新 i18n) └─ 无 duplicateInfo(尚未去重) ``` **默认展开**错误卡片(`body-` display:block),让用户立即看到错误原因。 **分区容器加 `data-section` 属性**(前端 DOM 手术定位用): - 错误分区容器:`data-section="error"`(原 `renderErrorSection`,标题/图标不变) - `renderSection` 三个分区容器分别加 `data-section="exact" | "overlap" | "none"` **顶部 summary 计数加 id**(前端更新用): - `⛔ 完全重复 N 条` → `` - `⚠️ 部分重叠 N 条` → `` - `✅ 无重复 N 条` → `` - 错误分区标题计数 → ``(放在 `renderErrorSection` 的 section-title 内) **确认按钮不再静态禁用**:改为 JS 动态控制。初始无有效规则时保持禁用 + 显示 `emptyValidHint`;一旦「添加」成功移入有效分区,JS 启用按钮并隐藏提示。 ### 3.4 `import-preview.ts`:扩展端消息处理 `showImportPreview` 的 `onDidReceiveMessage` 增加分支: ```ts panel.webview.onDidReceiveMessage(async (msg) => { if (msg.type === 'toggleRule') { keepRule[msg.ruleId] = msg.keep; } else if (msg.type === 'addErrorRule') { await handleAddErrorRule(msg, result, keepRule, context, panel); } else if (msg.type === 'confirm') { resolve({ keepRule, confirmed: true, editedRules: msg.editedRules }); panel.dispose(); } else if (msg.type === 'cancel') { resolve(null); panel.dispose(); } }); ``` `handleAddErrorRule` 逻辑(顺序严格): ```ts async function handleAddErrorRule(msg, result, keepRule, context, panel) { const rule = msg.rule; // [1] 格式校验(复用前端同一套规则) const err = validateRule(rule); // id/severity/description/message if (err) { panel.webview.postMessage({ type: 'addError', ruleId: msg.ruleId, message: err }); return; } // [2] id 冲突检查(对照 result.rules 中非错误规则,忽略大小写) const conflict = result.rules.some(r => !r.validationIssues?.length && r.id.toLowerCase() === rule.id.toLowerCase() ); if (conflict) { panel.webview.postMessage({ type: 'addError', ruleId: msg.ruleId, message: t('import.idConflict', { 0: rule.id }), }); return; } // [3] AI 单条去重(内部已重试 1 次),失败降级 none const dedup = await dedupSingleRule(rule, context); const level = dedup?.duplicateLevel ?? 'none'; const dedupFailed = !dedup; // [4] 更新 result.rules 中该条规则(按原始 id 定位) const idx = result.rules.findIndex(r => r.id === msg.ruleId); if (idx >= 0) { result.rules[idx] = { ...result.rules[idx], id: rule.id, severity: rule.severity, description: rule.description, message: rule.message, languages: rule.languages, excludeLanguages: rule.excludeLanguages, duplicateLevel: level, duplicateOf: dedup?.duplicateOf, duplicateReason: dedup?.duplicateReason, validationIssues: undefined, }; } // [5] keepRule 按新 id 记录(exact → false) keepRule[rule.id] = level !== 'exact'; // [6] 通知前端移动卡片 panel.webview.postMessage({ type: 'ruleAdded', ruleId: msg.ruleId, // 原始 id,前端定位 DOM id: rule.id, // 新 id duplicateLevel: level, duplicateOf: dedup?.duplicateOf, duplicateReason: dedup?.duplicateReason, dedupFailed, }); } function validateRule(rule): string | null { if (!rule.id || !rule.id.trim()) return t('import.validationIdEmpty'); if (!['error', 'warning', 'info'].includes(rule.severity)) return t('import.validationSeverityInvalid'); if (!rule.description || !rule.description.trim()) return t('import.validationDescEmpty', { 0: rule.id }); if (!rule.message || !rule.message.trim()) return t('import.validationMsgEmpty', { 0: rule.id }); return null; } ``` > `context` 需传入 `showImportPreview`(新增参数)或在模块内暂存——采用**新增参数**:`showImportPreview(result, context)`。 ### 3.5 `import-preview.ts`:前端 JS 改造 新增 / 修改函数: ```js // 从单张卡片提取当前字段(供添加与校验复用;从 collectEditedRules 抽取公共逻辑) function collectCardRule(ruleId) { /* 读 id-display-input / select / textareas / tag-lists */ } let addedRules = 0; // 已成功添加的错误规则数 // 点击「添加」 function addErrorRule(ruleId) { const btn = document.querySelector(`[data-addbtn="${ruleId}"]`); btn.disabled = true; btn.textContent = ADDING_TEXT; const rule = collectCardRule(ruleId); vscode.postMessage({ type: 'addErrorRule', ruleId, rule }); } // 接收扩展消息 window.addEventListener('message', e => { const msg = e.data; if (msg.type === 'addError') { // 卡片内展示 msg.message,恢复添加按钮可点击 } else if (msg.type === 'ruleAdded') { moveCardToSection(msg); } }); function moveCardToSection(msg) { const card = document.querySelector(`.rule-card[data-ruleid="${msg.ruleId}"]`); // 1. 更新 id 相关:card.dataset.ruleid = msg.id;两个 id input 值为 msg.id // 2. 移除 data-error 与错误边框样式 // 3. 还原「添加」按钮为保留/注释 toggle(按 keepRule 状态)—— 需从扩展同步 keep 状态: // msg.duplicateLevel === 'exact' 时默认注释态,否则保留态 // 4. 更新徽标:exact →「将注释」;overlap/none →「保留」(badge-exact/overlap/none) // 5. 追加 duplicateInfo(exact/overlap 文案,复用现有拼接逻辑) // 6. 移入对应分区:document.querySelector(`[data-section="${section}"]`).appendChild(card) // 7. addedRules++;更新计数与确认按钮状态 } // 分区标题计数(N 条)与顶部 summary 计数统一重算 function updateSectionCounts() { // 按 data-section 遍历,重算 4 个分区标题计数 + count-exact/overlap/none // 错误分区为空 → 隐藏整个分区容器 // 无任何有效规则 → 禁用确认按钮 + 显示 emptyValidHint;否则启用 } // 确认:hasEdits 或 addedRules>0 时必带 editedRules function doConfirm() { const err = validate(); if (err) { /* 现逻辑 */ return; } const edited = collectEditedRules(); const hasEdits = Object.keys(editedRules).length > 0; const withData = (hasEdits || addedRules > 0) ? edited : undefined; vscode.postMessage({ type: 'confirm', editedRules: withData }); } ``` `updateSummary()`(保留/注释计数)已按 `data-error` 跳过错误卡片,无需改动;`moveCardToSection` 移除 `data-error` 后该卡片自动纳入统计。 ### 3.6 关键边界 | 边界 | 处理 | |------|------| | 添加时 id 冲突(预览内已有有效规则) | `addError` 提示改 id,卡片留在错误分区 | | AI 去重首次失败 | 自动重试 1 次(共 2 次尝试,quiet 模式不弹错) | | 重试仍失败 | 降级 `none` 移入无重复分区,前端提示「AI 去重失败,已以无重复方式添加」 | | 添加后 id 被修改导致与后续规则重复 | 后续添加时冲突检查覆盖全量有效规则,会拦截 | | 只添加未编辑字段 | `addedRules>0` 仍回传 `editedRules`,走 `renderRulesToYaml`,已添加规则不会丢失 | | 错误规则未添加即确认 | 维持现状:`data-error` 卡片被 `collectEditedRules`/`validate` 跳过,自动丢弃 | | 初始无有效规则 | 确认按钮禁用;「添加」成功第一条后 JS 启用 | | 未配置 API Key | `convertContentWithAI` 返回 null(quiet),重试后降级 none,流程不中断 | ### 3.7 i18n 新增 / 调整 | key | zh-CN | en | ja | |-----|-------|----|----| | `import.add`(新增) | 添加 | Add | 追加 | | `import.adding`(新增) | 校验并去重中... | Validating & deduping... | 検証・重複排除中... | | `import.idConflict`(新增) | id {0} 与已有规则重复,请修改 id | id {0} conflicts with an existing rule, change the id | id {0} が既存ルールと重複、id を変更してください | | `import.addDedupFallback`(新增) | AI 去重失败,已以无重复方式添加 | AI dedup failed, added as no-duplicate | AI 重複排除失敗、重複なしとして追加 | | `import.validationSeverityInvalid`(新增) | severity 非法 | Invalid severity | severity が不正です | | `import.cannotImport`(调整) | 需修复后点击添加 | Fix then click Add | 修正して「追加」をクリック | --- ## 四、文件变更清单 | 文件 | 操作 | 内容 | |------|------|------| | `src/rules/import-service.ts` | 修改 | 新增 `dedupSingleRule`(含 `DedupResult` 接口);`convertContentWithAI` 增加 `quiet?` 参数 | | `src/rules/import-preview.ts` | 修改 | 错误卡片完整表单 + 添加流程;`showImportPreview(result, context)`;`handleAddErrorRule`;前端 `addErrorRule`/`moveCardToSection`/`updateSectionCounts`/`doConfirm`;分区 `data-section` 与计数 id;确认按钮动态控制 | | `src/i18n/messages.ts` | 修改 | 新增 5 个 key,调整 `import.cannotImport` 三语文案 | | `src/rules/import-types.ts` | 不改 | — | | `src/rules/converters/template-converter.ts` | 不改 | 校验逻辑不动 | ## 五、验证 验证顺序 `lint → compile`(当前仓库无测试文件): - `npm run lint`(ESLint `src/`) - `npm run compile`(tsc) - 手动验证(Extension Dev Host): 1. 导出模板 → 填一行错误数据(如空 description、拼错 severity)→ 模板导入 → 预览中错误卡片可展开编辑 2. 修复后点「添加」→ 卡片移入对应分区、计数更新、可切换保留/注释 3. 不改任何字段再添加一条 → 确认导入 → 写盘 YAML 含已添加规则 4. id 冲突 → 添加被拦截并提示 5. 断网/无 Key → 重试后降级无重复分区,卡片有降级提示 6. 未添加的错误规则 → 确认后自动丢弃