feat: 规则转换器/导入/过滤/i18n 基础设施

- converters: 8 个转换器(markdown/txt/excel/docx/pptx/yaml + prompt-builder + converter 接口)
- import: import-preview/import-service/import-types
- rule-filter: 规则预过滤功能
- static-rules.json: 静态规则数据
- i18n/messages.ts: 国际化消息
- types/mammoth.d.ts: mammoth 类型声明
This commit is contained in:
范智鹏
2026-07-26 23:04:13 +08:00
parent 6d29f304e3
commit 83712aa260
16 changed files with 4396 additions and 0 deletions
+593
View File
@@ -0,0 +1,593 @@
import * as vscode from 'vscode';
import type { ConversionResult, PreviewDecision, ImportableRule } from './import-types';
import { t } from '../i18n/messages';
export async function showImportPreview(
result: ConversionResult,
): Promise<PreviewDecision | null> {
return new Promise((resolve) => {
const panel = vscode.window.createWebviewPanel(
'ruleImportPreview',
t('importPreview.title'),
vscode.ViewColumn.Active,
{ enableScripts: true },
);
const keepRule: Record<string, boolean> = {};
for (const rule of result.rules) {
keepRule[rule.id] = rule.duplicateLevel !== 'exact';
}
panel.webview.html = renderPreviewHtml(result, keepRule);
panel.webview.onDidReceiveMessage((msg) => {
if (msg.type === 'toggleRule') {
keepRule[msg.ruleId] = msg.keep;
} else if (msg.type === 'confirm') {
resolve({
keepRule,
confirmed: true,
editedRules: msg.editedRules as ImportableRule[] | undefined,
});
panel.dispose();
} else if (msg.type === 'cancel') {
resolve(null);
panel.dispose();
}
});
panel.onDidDispose(() => resolve(null));
});
}
const SEVERITY_OPTIONS = ['error', 'warning', 'info'];
const SEVERITY_COLORS: Record<string, string> = {
error: '#f48771',
warning: '#d29922',
info: '#58a6ff',
};
function renderPreviewHtml(
result: ConversionResult,
keepRule: Record<string, boolean>,
): string {
const exactRules = result.rules.filter(r => r.duplicateLevel === 'exact');
const overlapRules = result.rules.filter(r => r.duplicateLevel === 'overlap');
const noneRules = result.rules.filter(
r => r.duplicateLevel !== 'exact' && r.duplicateLevel !== 'overlap'
);
const totalKept = Object.values(keepRule).filter(Boolean).length;
const totalCommented = Object.values(keepRule).filter(v => !v).length;
function renderRuleCard(rule: ImportableRule): string {
const kept = keepRule[rule.id];
const color = SEVERITY_COLORS[rule.severity] || '#8b949e';
const editRule = rule;
let duplicateInfo = '';
if (rule.duplicateLevel === 'exact') {
const prefix = rule.duplicateOf?.startsWith('custom/') ? `${t('import.customRulePrefix')} ${rule.duplicateOf.slice(7)}` : (rule.duplicateOf ?? 'unknown');
duplicateInfo = `<div style="color:#8b949e;font-size:12px;margin-top:4px;">${t('import.duplicateOf', { 0: prefix })}</div>`;
} else if (rule.duplicateLevel === 'overlap') {
const prefix = rule.duplicateOf?.startsWith('custom/') ? `${t('import.customRulePrefix')} ${rule.duplicateOf.slice(7)}` : (rule.duplicateOf ?? 'unknown');
duplicateInfo = `
<div style="color:#d29922;font-size:12px;margin-top:4px;">${t('import.overlapWith', { 0: prefix })}</div>
${rule.duplicateReason ? `<div style="color:#8b949e;font-size:12px;margin-top:2px;">${t('import.overlapReason', { 0: rule.duplicateReason })}</div>` : ''}
`;
}
const statusBadge = rule.duplicateLevel === 'exact'
? `<span class="badge badge-exact">${kept ? t('import.badgeRestored') : t('import.badgeWillComment')}</span>`
: rule.duplicateLevel === 'overlap'
? `<span class="badge badge-overlap">${kept ? t('importPreview.keep') : t('importPreview.comment')}</span>`
: `<span class="badge badge-none">${t('importPreview.keep')}</span>`;
const tagValue = (tags: string[] | undefined) => tags && tags.length > 0 ? tags.join(',') : '';
const tagDisplay = (tags: string[] | undefined) => tags && tags.length > 0 ? tags.map(t => `<span class="tag" data-value="${t}">${t}<span class="tag-remove" data-tag="${t}">×</span></span>`).join('') : '';
return `
<div class="rule-card" data-ruleid="${rule.id}">
<div class="rule-card-header" onclick="toggleCard('${rule.id}')">
<div class="rule-card-summary">
<input class="rule-id-input${/^rule-\d+$/.test(rule.id) ? ' placeholder-id' : ''}" value="${rule.id}" onchange="syncId('${rule.id}', this.value)" onclick="event.stopPropagation()">
<span class="rule-severity-tag" style="background:${color}20;color:${color};border:1px solid ${color}40;">${editRule.severity}</span>
<span class="rule-desc-preview">${editRule.description}</span>
</div>
<div class="rule-card-meta">
${statusBadge}
<span class="expand-icon">▼</span>
</div>
</div>
<div class="rule-card-body" id="body-${rule.id}" style="display:none;">
<div class="edit-header">
<div class="id-row">
<span class="id-display-label">${t('import.idLabel')}</span>
<input class="id-display-input${/^rule-\d+$/.test(rule.id) ? ' placeholder-id' : ''}" value="${rule.id}" onchange="syncId('${rule.id}', this.value)">
${/^rule-\d+$/.test(rule.id) ? `<span class="placeholder-hint">${t('import.placeholderIdHint')}</span>` : ''}
</div>
<div class="keep-toggle">
<button class="toggle-btn ${kept ? 'active' : ''}" data-action="keep" onclick="event.stopPropagation();toggleKeep('${rule.id}', true)">${t('importPreview.keep')}</button>
<button class="toggle-btn ${!kept ? 'active' : ''}" data-action="comment" onclick="event.stopPropagation();toggleKeep('${rule.id}', false)">${t('importPreview.comment')}</button>
</div>
</div>
${duplicateInfo}
<div class="edit-field">
<label>${t('import.severityLabel')}</label>
<select onchange="updateRule('${rule.id}','severity',this.value)">
${SEVERITY_OPTIONS.map(s => `<option value="${s}" ${s === editRule.severity ? 'selected' : ''}>${s}</option>`).join('')}
</select>
</div>
<div class="edit-field">
<label>${t('import.descriptionLabel')}</label>
<textarea rows="2" onchange="updateRule('${rule.id}','description',this.value)">${editRule.description}</textarea>
</div>
<div class="edit-field">
<label>${t('import.messageLabel')}</label>
<textarea rows="2" onchange="updateRule('${rule.id}','message',this.value)">${editRule.message}</textarea>
</div>
<div class="edit-field">
<label>${t('import.languagesLabel')}</label>
<div class="tag-input-wrapper">
<div class="tag-list" data-ruleid="${rule.id}" data-field="languages">
${tagDisplay(editRule.languages)}
</div>
<input class="tag-input" data-ruleid="${rule.id}" data-field="languages" placeholder="${t('import.tagPlaceholder')}" value="">
</div>
</div>
<div class="edit-field">
<label>${t('import.excludeLanguagesLabel')}</label>
<div class="tag-input-wrapper">
<div class="tag-list" data-ruleid="${rule.id}" data-field="excludeLanguages">
${tagDisplay(editRule.excludeLanguages)}
</div>
<input class="tag-input" data-ruleid="${rule.id}" data-field="excludeLanguages" placeholder="${t('import.tagPlaceholder')}" value="">
</div>
</div>
</div>
</div>
`;
}
function renderSection(title: string, icon: string, rules: ImportableRule[], _defaultExpanded: boolean): string {
if (rules.length === 0) { return ''; }
const sectionId = `section-${title.replace(/\s/g, '')}`;
const show = rules.some(r => keepRule[r.id] !== undefined);
return `
<div style="margin-bottom:12px;">
<div class="section-header" onclick="toggleSection('${sectionId}')">
<span style="font-size:14px;">${icon}</span>
<span class="section-title">${title}${t('import.ruleCount', { 0: rules.length })}</span>
<span class="section-arrow">▶</span>
</div>
<div id="${sectionId}" style="display:${show ? 'block' : 'none'};">
${rules.map(renderRuleCard).join('')}
</div>
</div>
`;
}
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-foreground);
background: var(--vscode-editor-background);
padding: 16px; line-height: 1.5;
}
.header {
border-bottom: 1px solid var(--vscode-panel-border);
padding-bottom: 12px; margin-bottom: 12px;
}
.header-title { font-size: 16px; font-weight: 700; margin-bottom: 4px; }
.header-sub { font-size: 12px; color: var(--vscode-descriptionForeground); }
.summary {
display: flex; gap: 12px; margin-bottom: 16px;
}
.summary-item {
padding: 8px 12px; border-radius: 6px; font-size: 12px;
border: 1px solid var(--vscode-panel-border);
background: var(--vscode-sideBar-background, var(--vscode-editor-background));
}
.summary-bar {
margin-bottom: 16px; padding: 8px 12px; border-radius: 6px;
font-size: 12px; background: rgba(139,92,246,0.1);
border: 1px solid rgba(139,92,246,0.3); color: #a78bfa;
}
.actions {
display: flex; gap: 8px; padding-top: 12px;
border-top: 1px solid var(--vscode-panel-border);
justify-content: flex-end;
}
.btn {
padding: 6px 16px; border: 1px solid var(--vscode-panel-border);
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border-radius: 6px; cursor: pointer; font-size: 12px;
}
.btn:hover { background: var(--vscode-button-secondaryHoverBackground); }
.btn-primary { background: #7c3aed; color: #fff; border-color: #7c3aed; }
.btn-primary:hover { background: #8b5cf6; }
.section-header {
display: flex; align-items: center; gap: 8px; margin-bottom: 8px;
cursor: pointer; padding: 4px 0;
}
.section-title { font-weight: 600; font-size: 13px; }
.section-arrow { font-size: 10px; color: var(--vscode-descriptionForeground); }
.rule-card {
border: 1px solid var(--vscode-panel-border);
border-radius: 8px; margin-bottom: 8px; overflow: hidden;
}
.rule-card-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 12px; cursor: pointer; gap: 8px;
}
.rule-card-header:hover { background: var(--vscode-list-hoverBackground); }
.rule-card-summary {
display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;
}
.rule-id-input {
font-family: monospace; font-size: 13px; font-weight: 600; white-space: nowrap;
background: transparent;
border: 1px solid transparent;
border-radius: 3px;
color: var(--vscode-foreground);
padding: 1px 4px; outline: none;
width: auto; min-width: 120px;
}
.rule-id-input:focus {
border-color: var(--vscode-focusBorder, #7c3aed);
background: var(--vscode-input-background);
}
.rule-id-input.placeholder-id {
border-color: #f59e0b !important;
box-shadow: 0 0 0 1px rgba(245, 158, 11, 0.3);
}
.rule-severity-tag {
padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; white-space: nowrap;
}
.rule-desc-preview {
font-size: 12px; color: var(--vscode-descriptionForeground);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.rule-card-meta {
display: flex; align-items: center; gap: 8px; flex-shrink: 0;
}
.expand-icon { font-size: 10px; color: var(--vscode-descriptionForeground); }
.badge {
padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: 600;
}
.badge-exact { background: rgba(248,81,73,0.15); color: #f48771; }
.badge-overlap { background: rgba(210,153,34,0.15); color: #d29922; }
.badge-none { background: rgba(35,134,54,0.15); color: #3fb950; }
.rule-card-body {
padding: 0 12px 12px; border-top: 1px solid var(--vscode-panel-border);
}
.edit-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 0 8px;
}
.id-row {
display: flex; align-items: center; gap: 8px;
}
.keep-toggle { display: flex; gap: 4px; }
.toggle-btn {
padding: 3px 12px; border-radius: 4px; cursor: pointer; font-size: 11px;
border: 1px solid var(--vscode-panel-border);
background: transparent; color: var(--vscode-foreground);
}
.toggle-btn.active {
background: rgba(35,134,54,0.15); color: #3fb950; border-color: rgba(35,134,54,0.3);
}
.toggle-btn.active[data-action="comment"] {
background: rgba(248,81,73,0.15); color: #f48771; border-color: rgba(248,81,73,0.3);
}
.edit-field { margin-top: 10px; }
.edit-field label {
display: block; font-size: 11px; font-weight: 600;
color: var(--vscode-descriptionForeground); margin-bottom: 4px;
text-transform: uppercase; letter-spacing: 0.5px;
}
.id-display-label {
font-size: 11px; font-weight: 600;
color: var(--vscode-descriptionForeground);
text-transform: uppercase; letter-spacing: 0.5px;
}
.id-display-input {
font-family: monospace; font-size: 13px; font-weight: 600;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-panel-border);
border-radius: 3px;
color: var(--vscode-input-foreground);
padding: 4px 8px; outline: none;
width: auto; min-width: 200px;
}
.id-display-input:focus {
border-color: var(--vscode-focusBorder, #7c3aed);
}
.id-display-input.placeholder-id {
border-color: #f59e0b !important;
box-shadow: 0 0 0 1px rgba(245, 158, 11, 0.3);
}
.placeholder-hint {
color: #d29922; font-size: 11px;
}
.edit-field select, .edit-field textarea {
width: 100%; padding: 6px 8px; border-radius: 4px;
border: 1px solid var(--vscode-panel-border);
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
}
.edit-field textarea { resize: vertical; }
.tag-input-wrapper {
border: 1px solid var(--vscode-panel-border);
border-radius: 4px; padding: 4px 6px;
background: var(--vscode-input-background);
display: flex; flex-wrap: wrap; gap: 4px; align-items: center;
}
.tag-list { display: flex; flex-wrap: wrap; gap: 4px; }
.tag {
display: inline-flex; align-items: center; gap: 3px;
padding: 1px 6px; border-radius: 3px; font-size: 11px;
background: rgba(88,166,255,0.15); color: #58a6ff;
border: 1px solid rgba(88,166,255,0.3);
}
.tag-remove {
cursor: pointer; font-size: 13px; line-height: 1; opacity: 0.7;
}
.tag-remove:hover { opacity: 1; }
.tag-input {
border: none; outline: none; flex: 1; min-width: 80px;
background: transparent; color: var(--vscode-input-foreground);
font-size: 12px; padding: 2px 0;
}
.tag-input::placeholder { color: var(--vscode-input-placeholderForeground); }
.validation-error {
padding: 8px 12px; margin-bottom: 12px; border-radius: 6px;
background: rgba(248,81,73,0.15); color: #f48771;
border: 1px solid rgba(248,81,73,0.3); font-size: 12px;
}
</style>
</head>
<body>
<div class="header">
<div class="header-title">${t('importPreview.title')}</div>
<div class="header-sub">${t('importPreview.source', { 0: result.sourceFileName, 1: String(result.rules.length) })}</div>
</div>
<div class="summary">
<div class="summary-item" style="border-color:rgba(248,81,73,0.3);color:#f48771;">${t('import.exactDuplicate', { 0: exactRules.length })}</div>
<div class="summary-item" style="border-color:rgba(210,153,34,0.3);color:#d29922;">${t('import.overlapDuplicate', { 0: overlapRules.length })}</div>
<div class="summary-item" style="border-color:rgba(35,134,54,0.3);color:#3fb950;">${t('import.noDuplicate', { 0: noneRules.length })}</div>
</div>
<div class="summary-bar" id="statusBar">
${t('import.statusBar', { 0: `<b id="keepCount">${totalKept}</b>`, 1: `<b id="commentCount">${totalCommented}</b>` })}
<span id="editHint" style="display:none;">${t('import.editedHint', { 0: '<b id="editCount">0</b>' })}</span>
</div>
<div id="validationError" class="validation-error" style="display:none;"></div>
${renderSection(t('import.sectionExact'), '⛔', exactRules, false)}
${renderSection(t('import.sectionOverlap'), '⚠️', overlapRules, true)}
${renderSection(t('import.sectionNone'), '✅', noneRules, false)}
<div class="actions">
<button class="btn" onclick="cancel()">${t('importPreview.cancel')}</button>
<button class="btn btn-primary" onclick="doConfirm()">${t('importPreview.confirm')}</button>
</div>
<script>
const vscode = acquireVsCodeApi();
const editedRules = {};
const VALIDATION_DESC_EMPTY = ${JSON.stringify(t('import.validationDescEmpty'))};
const VALIDATION_MSG_EMPTY = ${JSON.stringify(t('import.validationMsgEmpty'))};
const VALIDATION_ID_EMPTY = ${JSON.stringify(t('import.validationIdEmpty'))};
function syncId(originalId, newValue) {
const card = document.querySelector('.rule-card[data-ruleid="' + originalId + '"]');
if (!card) return;
const headerInput = card.querySelector('.rule-id-input');
const panelInput = card.querySelector('.id-display-input');
if (headerInput) headerInput.value = newValue;
if (panelInput) panelInput.value = newValue;
if (!editedRules[originalId]) editedRules[originalId] = {};
editedRules[originalId].id = newValue;
updateEditHint();
const isPlaceholder = /^rule-\\d+$/.test(newValue);
[headerInput, panelInput].forEach(el => {
if (el) {
el.classList.toggle('placeholder-id', isPlaceholder);
}
});
}
function toggleCard(ruleId) {
const body = document.getElementById('body-' + ruleId);
const card = body.closest('.rule-card');
const icon = card.querySelector('.expand-icon');
if (body.style.display === 'none') {
body.style.display = 'block';
icon.textContent = '▲';
} else {
body.style.display = 'none';
icon.textContent = '▼';
}
}
function toggleSection(id) {
const el = document.getElementById(id);
const arrow = el.previousElementSibling.querySelector('.section-arrow');
if (el.style.display === 'none') {
el.style.display = 'block';
arrow.textContent = '▼';
} else {
el.style.display = 'none';
arrow.textContent = '▶';
}
}
function toggleKeep(ruleId, keep) {
vscode.postMessage({ type: 'toggleRule', ruleId, keep });
const card = document.querySelector('.rule-card[data-ruleid="' + ruleId + '"]');
if (!card) return;
const btns = card.querySelectorAll('.toggle-btn');
btns.forEach(b => b.classList.toggle('active', (keep && b.dataset.action === 'keep') || (!keep && b.dataset.action === 'comment')));
updateSummary();
}
function updateRule(ruleId, field, value) {
if (!editedRules[ruleId]) {
editedRules[ruleId] = {};
}
editedRules[ruleId][field] = value;
updateEditHint();
}
function collectEditedRules() {
const result = [];
document.querySelectorAll('.rule-card').forEach(card => {
const originalId = card.dataset.ruleid;
const idInput = card.querySelector('.id-display-input');
const ruleId = idInput ? idInput.value.trim() || originalId : originalId;
const severityEl = card.querySelector('.edit-field select');
const textareas = card.querySelectorAll('.edit-field textarea');
const descEl = textareas[0];
const msgEl = textareas[1];
const langList = card.querySelector('.tag-list[data-field="languages"]');
const exclList = card.querySelector('.tag-list[data-field="excludeLanguages"]');
const rule = {
id: ruleId,
severity: severityEl ? severityEl.value : 'warning',
description: descEl ? descEl.value : '',
message: msgEl ? msgEl.value : '',
languages: langList ? Array.from(langList.querySelectorAll('.tag')).map(t => t.dataset.value) : [],
excludeLanguages: exclList ? Array.from(exclList.querySelectorAll('.tag')).map(t => t.dataset.value) : [],
};
result.push(rule);
});
return result;
}
function validate() {
const rules = collectEditedRules();
for (const rule of rules) {
if (!rule.id || !rule.id.trim()) {
return VALIDATION_ID_EMPTY;
}
if (!rule.description || !rule.description.trim()) {
return VALIDATION_DESC_EMPTY.replace('{0}', rule.id);
}
if (!rule.message || !rule.message.trim()) {
return VALIDATION_MSG_EMPTY.replace('{0}', rule.id);
}
}
return null;
}
function doConfirm() {
const err = validate();
if (err) {
const errEl = document.getElementById('validationError');
errEl.textContent = err;
errEl.style.display = 'block';
return;
}
const edited = collectEditedRules();
const hasEdits = Object.keys(editedRules).length > 0;
vscode.postMessage({ type: 'confirm', editedRules: hasEdits ? edited : undefined });
}
function cancel() {
vscode.postMessage({ type: 'cancel' });
}
function updateSummary() {
let keepCount = 0, commentCount = 0;
document.querySelectorAll('.rule-card').forEach(card => {
const ruleId = card.dataset.ruleid;
const keepBtns = card.querySelectorAll('.toggle-btn');
let isKept = true;
keepBtns.forEach(b => {
if (b.classList.contains('active') && b.dataset.action === 'comment') isKept = false;
});
if (isKept) keepCount++; else commentCount++;
});
document.getElementById('keepCount').textContent = keepCount;
document.getElementById('commentCount').textContent = commentCount;
}
function updateEditHint() {
const count = Object.keys(editedRules).length;
const hint = document.getElementById('editHint');
const countEl = document.getElementById('editCount');
if (count > 0) {
hint.style.display = 'inline';
countEl.textContent = count;
} else {
hint.style.display = 'none';
}
}
document.addEventListener('keydown', function(e) {
if (e.target.classList.contains('tag-input') && e.key === 'Enter') {
e.preventDefault();
const input = e.target;
const val = input.value.trim();
if (!val) return;
const ruleId = input.dataset.ruleid;
const field = input.dataset.field;
const list = input.parentElement.querySelector('.tag-list');
const existing = list.querySelectorAll('.tag');
const exists = Array.from(existing).some(t => t.dataset.value === val);
if (exists) { input.value = ''; return; }
const tag = document.createElement('span');
tag.className = 'tag';
tag.dataset.value = val;
tag.innerHTML = val + '<span class="tag-remove">×</span>';
list.appendChild(tag);
input.value = '';
const tags = Array.from(list.querySelectorAll('.tag')).map(t => t.dataset.value);
if (!editedRules[ruleId]) editedRules[ruleId] = {};
editedRules[ruleId][field] = tags;
updateEditHint();
}
});
document.addEventListener('click', function(e) {
if (e.target.classList.contains('tag-remove')) {
const tag = e.target.closest('.tag');
const list = tag.closest('.tag-list');
const ruleId = list.dataset.ruleid;
const field = list.dataset.field;
tag.remove();
const remaining = Array.from(list.querySelectorAll('.tag')).map(t => t.dataset.value);
if (!editedRules[ruleId]) editedRules[ruleId] = {};
editedRules[ruleId][field] = remaining;
updateEditHint();
}
});
</script>
</body>
</html>`;
}