1220 lines
54 KiB
JavaScript
1220 lines
54 KiB
JavaScript
// js/ui/glossary-ui.js (multi-sets)
|
||
|
||
(function() {
|
||
function el(id) { return document.getElementById(id); }
|
||
function escapeHtml(str) { return String(str || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\"/g,'"'); }
|
||
|
||
function normalizeTermForMatch(term) {
|
||
return String(term || '').trim().toLowerCase();
|
||
}
|
||
|
||
function buildSimpleTextFromEntries(entries) {
|
||
if (!Array.isArray(entries) || entries.length === 0) return '';
|
||
return entries.map(item => {
|
||
const cols = [item.term || '', item.translation || ''];
|
||
const extras = [item.caseSensitive ? '1' : '0', item.wholeWord ? '1' : '0', item.enabled === false ? '0' : '1'];
|
||
const defaults = ['0', '0', '1'];
|
||
let len = extras.length;
|
||
while (len > 0 && extras[len - 1] === defaults[len - 1]) len--;
|
||
return cols.concat(extras.slice(0, len)).join('\t');
|
||
}).join('\n');
|
||
}
|
||
|
||
function parseTMXFormat(xmlText) {
|
||
try {
|
||
const parser = new DOMParser();
|
||
const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
|
||
const parseError = xmlDoc.querySelector('parsererror');
|
||
if (parseError) throw new Error('XML 解析失败');
|
||
|
||
const tuNodes = xmlDoc.querySelectorAll('tu');
|
||
const entries = [];
|
||
|
||
tuNodes.forEach(tu => {
|
||
const tuvs = tu.querySelectorAll('tuv');
|
||
if (tuvs.length < 2) return;
|
||
|
||
let sourceTerm = '';
|
||
let targetTerm = '';
|
||
|
||
tuvs.forEach(tuv => {
|
||
const lang = tuv.getAttribute('xml:lang') || tuv.getAttribute('lang') || '';
|
||
const seg = tuv.querySelector('seg');
|
||
if (!seg) return;
|
||
|
||
const text = seg.textContent.trim();
|
||
if (!text) return;
|
||
|
||
if (lang.toLowerCase().startsWith('en') || lang.toLowerCase().startsWith('zh-hans') === false) {
|
||
if (!sourceTerm) sourceTerm = text;
|
||
} else {
|
||
targetTerm = text;
|
||
}
|
||
});
|
||
|
||
if (sourceTerm && targetTerm) {
|
||
entries.push({
|
||
id: generateUUID(),
|
||
term: sourceTerm,
|
||
translation: targetTerm,
|
||
caseSensitive: false,
|
||
wholeWord: false,
|
||
enabled: true
|
||
});
|
||
}
|
||
});
|
||
|
||
return { success: true, entries };
|
||
} catch (err) {
|
||
return { success: false, error: err.message };
|
||
}
|
||
}
|
||
|
||
|
||
function parseTBXFormat(xmlText) {
|
||
try {
|
||
const parser = new DOMParser();
|
||
const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
|
||
const parseError = xmlDoc.querySelector('parsererror');
|
||
if (parseError) throw new Error('XML 解析失败');
|
||
|
||
// TBX (TermBase eXchange) 格式
|
||
// 结构: <martif> -> <body> -> <termEntry> -> <langSet> -> <tig> -> <term>
|
||
const termEntries = xmlDoc.querySelectorAll('termEntry');
|
||
const entries = [];
|
||
|
||
termEntries.forEach(termEntry => {
|
||
const langSets = termEntry.querySelectorAll('langSet');
|
||
if (langSets.length < 2) return;
|
||
|
||
let sourceTerm = '';
|
||
let targetTerm = '';
|
||
|
||
langSets.forEach(langSet => {
|
||
const lang = langSet.getAttribute('xml:lang') || '';
|
||
const term = langSet.querySelector('term, tig term, ntig term');
|
||
if (!term) return;
|
||
|
||
const text = term.textContent.trim();
|
||
if (!text) return;
|
||
|
||
// 简单的语言判断
|
||
if (lang.toLowerCase().startsWith('en') || !sourceTerm) {
|
||
sourceTerm = text;
|
||
} else {
|
||
targetTerm = text;
|
||
}
|
||
});
|
||
|
||
if (sourceTerm && targetTerm && sourceTerm !== targetTerm) {
|
||
entries.push({
|
||
id: generateUUID(),
|
||
term: sourceTerm,
|
||
translation: targetTerm,
|
||
caseSensitive: false,
|
||
wholeWord: false,
|
||
enabled: true
|
||
});
|
||
}
|
||
});
|
||
|
||
if (entries.length === 0) {
|
||
return { success: false, error: 'TBX 文件中未找到有效的术语条目' };
|
||
}
|
||
|
||
return { success: true, entries };
|
||
} catch (err) {
|
||
return { success: false, error: `TBX 解析失败: ${err.message}` };
|
||
}
|
||
}
|
||
|
||
function parseCSVFormat(text) {
|
||
// CSV 格式解析(兼容 SDLTB 导出的 CSV)
|
||
const lines = text.trim().split(/\r?\n/);
|
||
if (lines.length === 0) return { success: false, error: 'CSV 文件为空' };
|
||
|
||
const entries = [];
|
||
let headerSkipped = false;
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i].trim();
|
||
if (!line) continue;
|
||
|
||
// 简单的 CSV 解析(支持引号包裹的字段)
|
||
const fields = [];
|
||
let field = '';
|
||
let inQuotes = false;
|
||
|
||
for (let j = 0; j < line.length; j++) {
|
||
const char = line[j];
|
||
if (char === '"') {
|
||
inQuotes = !inQuotes;
|
||
} else if (char === ',' && !inQuotes) {
|
||
fields.push(field.trim());
|
||
field = '';
|
||
} else {
|
||
field += char;
|
||
}
|
||
}
|
||
fields.push(field.trim());
|
||
|
||
// 移除引号
|
||
const cleanFields = fields.map(f => f.replace(/^"(.*)"$/, '$1'));
|
||
|
||
// 检测是否为表头(包含 "source" "target" "term" 等关键字)
|
||
if (!headerSkipped && cleanFields.some(f =>
|
||
/^(source|target|term|translation|原文|译文)$/i.test(f.toLowerCase())
|
||
)) {
|
||
headerSkipped = true;
|
||
continue;
|
||
}
|
||
|
||
// 至少需要两列
|
||
if (cleanFields.length < 2) continue;
|
||
|
||
const term = cleanFields[0] || '';
|
||
const translation = cleanFields[1] || '';
|
||
|
||
if (term && translation) {
|
||
entries.push({
|
||
id: generateUUID(),
|
||
term,
|
||
translation,
|
||
caseSensitive: false,
|
||
wholeWord: false,
|
||
enabled: true
|
||
});
|
||
}
|
||
}
|
||
|
||
if (entries.length === 0) {
|
||
return { success: false, error: 'CSV 文件中未找到有效的术语条目' };
|
||
}
|
||
|
||
return { success: true, entries };
|
||
}
|
||
|
||
function parseSimpleGlossaryText(text) {
|
||
const raw = String(text || '');
|
||
const trimmed = raw.trim();
|
||
const result = {
|
||
rawLineCount: 0,
|
||
entries: [],
|
||
duplicates: [],
|
||
invalidLines: [],
|
||
errorMessage: ''
|
||
};
|
||
|
||
if (!trimmed) return result;
|
||
|
||
if (trimmed.startsWith('<?xml') || trimmed.includes('<tmx')) {
|
||
const tmxResult = parseTMXFormat(trimmed);
|
||
if (!tmxResult.success) {
|
||
result.errorMessage = 'TMX 解析失败:' + tmxResult.error;
|
||
return result;
|
||
}
|
||
const map = new Map();
|
||
const duplicates = [];
|
||
tmxResult.entries.forEach(item => {
|
||
const key = normalizeTermForMatch(item.term);
|
||
if (map.has(key)) duplicates.push(item.term);
|
||
map.set(key, item);
|
||
});
|
||
result.entries = Array.from(map.values());
|
||
result.rawLineCount = tmxResult.entries.length;
|
||
result.duplicates = duplicates;
|
||
return result;
|
||
}
|
||
|
||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||
try {
|
||
const parsedJson = JSON.parse(trimmed);
|
||
let arr = [];
|
||
if (Array.isArray(parsedJson)) arr = parsedJson;
|
||
else if (parsedJson && Array.isArray(parsedJson.entries)) arr = parsedJson.entries;
|
||
else throw new Error('JSON 中未找到 entries 数组');
|
||
const normalized = normalizeImportedEntries(arr);
|
||
const map = new Map();
|
||
const duplicates = [];
|
||
normalized.forEach(item => {
|
||
const key = normalizeTermForMatch(item.term);
|
||
if (map.has(key)) duplicates.push(item.term);
|
||
map.set(key, { ...item });
|
||
});
|
||
result.entries = Array.from(map.values());
|
||
result.rawLineCount = normalized.length;
|
||
result.duplicates = duplicates;
|
||
} catch (err) {
|
||
result.errorMessage = 'JSON 解析失败:' + (err && err.message ? err.message : String(err));
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// 尝试检测是否为 CSV 格式(包含逗号分隔)
|
||
const firstLine = trimmed.split(/\r?\n/)[0];
|
||
const hasCommas = firstLine.includes(',');
|
||
const hasTabs = firstLine.includes('\t');
|
||
|
||
if (hasCommas && !hasTabs) {
|
||
// 很可能是 CSV 格式
|
||
const csvResult = parseCSVFormat(trimmed);
|
||
if (csvResult.success) {
|
||
const map = new Map();
|
||
const duplicates = [];
|
||
csvResult.entries.forEach(item => {
|
||
const key = normalizeTermForMatch(item.term);
|
||
if (map.has(key)) duplicates.push(item.term);
|
||
map.set(key, item);
|
||
});
|
||
result.entries = Array.from(map.values());
|
||
result.rawLineCount = csvResult.entries.length;
|
||
result.duplicates = duplicates;
|
||
return result;
|
||
}
|
||
}
|
||
|
||
const lines = raw.split(/\r?\n/);
|
||
const trimmedLines = lines.map(line => line.trim()).filter(Boolean);
|
||
const map = new Map();
|
||
const duplicates = [];
|
||
const invalidLines = [];
|
||
|
||
trimmedLines.forEach((line, idx) => {
|
||
let cols = line.split('\t');
|
||
if (cols.length === 1) {
|
||
if (line.includes('=>')) cols = line.split('=>');
|
||
else if (line.includes(',')) cols = line.split(',');
|
||
}
|
||
cols = cols.map(part => part.trim());
|
||
const term = cols[0] || '';
|
||
const translation = cols[1] || '';
|
||
if (!term || !translation) {
|
||
invalidLines.push({ index: idx + 1, content: line });
|
||
return;
|
||
}
|
||
const caseSensitive = cols[2] ? ['1','true','yes','y'].includes(cols[2].toLowerCase()) : false;
|
||
const wholeWord = cols[3] ? ['1','true','yes','y'].includes(cols[3].toLowerCase()) : false;
|
||
const enabledVal = cols[4] ? ['1','true','yes','y'].includes(cols[4].toLowerCase()) : true;
|
||
const entry = {
|
||
id: generateUUID(),
|
||
term,
|
||
translation,
|
||
caseSensitive,
|
||
wholeWord,
|
||
enabled: enabledVal
|
||
};
|
||
const key = normalizeTermForMatch(term);
|
||
if (map.has(key)) duplicates.push(term);
|
||
map.set(key, entry);
|
||
});
|
||
|
||
result.rawLineCount = trimmedLines.length;
|
||
result.entries = Array.from(map.values());
|
||
result.duplicates = duplicates;
|
||
result.invalidLines = invalidLines;
|
||
return result;
|
||
}
|
||
|
||
function normalizeImportedEntries(arr) {
|
||
return (Array.isArray(arr) ? arr : []).map(item => ({
|
||
id: generateUUID(),
|
||
term: String(item.term || '').trim(),
|
||
translation: String(item.translation || '').trim(),
|
||
caseSensitive: !!item.caseSensitive,
|
||
wholeWord: !!item.wholeWord,
|
||
enabled: item.enabled === undefined ? true : !!item.enabled
|
||
})).filter(it => it.term && it.translation);
|
||
}
|
||
|
||
function createGlossaryModal(title) {
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'fixed inset-0 z-[10000] bg-slate-900/45 backdrop-blur-sm flex items-center justify-center p-4';
|
||
|
||
const card = document.createElement('div');
|
||
card.className = 'w-full max-w-4xl bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden flex flex-col';
|
||
|
||
const header = document.createElement('div');
|
||
header.className = 'flex items-center justify-between px-6 py-4 border-b border-slate-200 bg-slate-50';
|
||
const titleEl = document.createElement('h3');
|
||
titleEl.className = 'text-lg font-semibold text-slate-800';
|
||
titleEl.textContent = title;
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.className = 'text-slate-500 hover:text-slate-800 transition-colors';
|
||
closeBtn.innerHTML = '<iconify-icon icon="carbon:close" width="20"></iconify-icon>';
|
||
header.appendChild(titleEl);
|
||
header.appendChild(closeBtn);
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'px-6 py-5 overflow-y-auto max-h-[70vh] space-y-4';
|
||
|
||
card.appendChild(header);
|
||
card.appendChild(body);
|
||
overlay.appendChild(card);
|
||
|
||
const previousOverflow = document.body.style.overflow;
|
||
document.body.style.overflow = 'hidden';
|
||
document.body.appendChild(overlay);
|
||
|
||
function close() {
|
||
overlay.remove();
|
||
document.body.style.overflow = previousOverflow;
|
||
}
|
||
|
||
closeBtn.addEventListener('click', close);
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||
|
||
return { overlay, card, body, close };
|
||
}
|
||
|
||
function openGlossaryExportModal(setId) {
|
||
const sets = loadGlossarySets();
|
||
const target = sets[setId];
|
||
if (!target) { showNotification && showNotification('找不到对应术语库', 'error'); return; }
|
||
const entries = Array.isArray(target.entries) ? target.entries : [];
|
||
const simpleText = buildSimpleTextFromEntries(entries);
|
||
const jsonText = JSON.stringify(entries, null, 2);
|
||
|
||
const modal = createGlossaryModal('导出术语库');
|
||
const info = document.createElement('div');
|
||
info.className = 'text-sm text-slate-600 leading-relaxed';
|
||
info.innerHTML = `<p>当前术语库 <span class="font-semibold text-slate-800">${escapeHtml(target.name || '')}</span> 共 <span class="font-semibold">${entries.length}</span> 条词条。可复制下方简易文本或下载 JSON。</p>`;
|
||
|
||
const textareaWrap = document.createElement('div');
|
||
const textareaLabel = document.createElement('label');
|
||
textareaLabel.className = 'block text-sm font-medium text-slate-600 mb-2';
|
||
textareaLabel.textContent = '简易文本(术语[TAB]译文 ...)';
|
||
const textarea = document.createElement('textarea');
|
||
textarea.className = 'w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed focus:border-blue-300 focus:outline-none focus:ring-2 focus:ring-blue-200 transition';
|
||
textarea.rows = 10;
|
||
textarea.value = simpleText;
|
||
textareaWrap.appendChild(textareaLabel);
|
||
textareaWrap.appendChild(textarea);
|
||
|
||
const details = document.createElement('details');
|
||
details.className = 'border border-slate-200 rounded-xl bg-slate-50/60';
|
||
const summary = document.createElement('summary');
|
||
summary.className = 'cursor-pointer px-4 py-2 text-sm font-medium text-slate-700';
|
||
summary.textContent = '查看 JSON 格式';
|
||
const pre = document.createElement('pre');
|
||
pre.className = 'px-4 py-3 text-xs text-slate-600 overflow-x-auto whitespace-pre-wrap';
|
||
pre.textContent = jsonText;
|
||
details.appendChild(summary);
|
||
details.appendChild(pre);
|
||
|
||
const btnRow = document.createElement('div');
|
||
btnRow.className = 'flex flex-wrap items-center justify-between gap-3 pt-2';
|
||
const leftHint = document.createElement('div');
|
||
leftHint.className = 'text-xs text-slate-500';
|
||
leftHint.textContent = '提示:简易文本可直接粘贴到导入面板,JSON 适合备份。';
|
||
const btnGroup = document.createElement('div');
|
||
btnGroup.className = 'flex flex-wrap gap-2';
|
||
|
||
const copyBtn = document.createElement('button');
|
||
copyBtn.type = 'button';
|
||
copyBtn.className = 'inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 bg-white text-sm hover:border-blue-300 hover:text-blue-600 transition';
|
||
copyBtn.innerHTML = '<iconify-icon icon="carbon:copy" width="16"></iconify-icon>复制文本';
|
||
copyBtn.addEventListener('click', async () => {
|
||
try {
|
||
if (navigator.clipboard) await navigator.clipboard.writeText(textarea.value || '');
|
||
else { textarea.select(); document.execCommand('copy'); }
|
||
showNotification && showNotification('已复制到剪贴板', 'success');
|
||
} catch (err) {
|
||
showNotification && showNotification('复制失败,请手动复制', 'warning');
|
||
}
|
||
});
|
||
|
||
const downloadTextBtn = document.createElement('button');
|
||
downloadTextBtn.type = 'button';
|
||
downloadTextBtn.className = 'inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 bg-white text-sm hover:border-blue-300 hover:text-blue-600 transition';
|
||
downloadTextBtn.innerHTML = '<iconify-icon icon="carbon:download" width="16"></iconify-icon>下载 TXT';
|
||
downloadTextBtn.addEventListener('click', () => {
|
||
const blob = new Blob([textarea.value || ''], { type: 'text/plain;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'glossary.txt';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(url);
|
||
});
|
||
|
||
const downloadJsonBtn = document.createElement('button');
|
||
downloadJsonBtn.type = 'button';
|
||
downloadJsonBtn.className = 'inline-flex items-center gap-1 rounded-lg border border-blue-500 px-3 py-1.5 bg-blue-50 text-sm text-blue-600 hover:border-blue-600 hover:bg-blue-100 transition';
|
||
downloadJsonBtn.innerHTML = '<iconify-icon icon="carbon:document-export" width="16"></iconify-icon>下载 JSON';
|
||
downloadJsonBtn.addEventListener('click', () => {
|
||
const blob = new Blob([jsonText], { type: 'application/json;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'glossary-entries.json';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(url);
|
||
});
|
||
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.type = 'button';
|
||
closeBtn.className = 'inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 bg-white text-sm hover:border-slate-300 transition';
|
||
closeBtn.innerHTML = '<iconify-icon icon="carbon:close" width="16"></iconify-icon>关闭';
|
||
closeBtn.addEventListener('click', () => modal.close());
|
||
|
||
btnGroup.appendChild(copyBtn);
|
||
btnGroup.appendChild(downloadTextBtn);
|
||
btnGroup.appendChild(downloadJsonBtn);
|
||
btnGroup.appendChild(closeBtn);
|
||
btnRow.appendChild(leftHint);
|
||
btnRow.appendChild(btnGroup);
|
||
|
||
modal.body.appendChild(info);
|
||
modal.body.appendChild(textareaWrap);
|
||
modal.body.appendChild(details);
|
||
modal.body.appendChild(btnRow);
|
||
}
|
||
|
||
function openGlossaryImportModal(setId) {
|
||
const sets = loadGlossarySets();
|
||
const target = sets[setId];
|
||
if (!target) { showNotification && showNotification('找不到对应术语库', 'error'); return; }
|
||
const existingEntries = Array.isArray(target.entries) ? target.entries : [];
|
||
|
||
const modal = createGlossaryModal('导入术语库');
|
||
const intro = document.createElement('div');
|
||
intro.className = 'text-sm text-slate-600 leading-relaxed space-y-2';
|
||
intro.innerHTML = `
|
||
<p>支持五种格式:<strong>CSV</strong>(逗号分隔)、<strong>简易文本</strong>(TAB 分隔)、<strong>JSON</strong>、<strong>TMX</strong>(Translation Memory eXchange)、<strong>TBX</strong>(TermBase eXchange)。导入前可预览冲突并逐条决定保留现有或采用新词条。</p>
|
||
<p class="text-xs text-slate-500">提示:可直接粘贴文本/JSON/TMX/TBX/CSV,或上传文件</p>`;
|
||
|
||
// 添加文件上传区域
|
||
const fileUploadArea = document.createElement('div');
|
||
fileUploadArea.className = 'rounded-xl border-2 border-dashed border-slate-200 bg-slate-50/50 p-4 text-center';
|
||
fileUploadArea.innerHTML = `
|
||
<div class="flex flex-col items-center gap-2">
|
||
<iconify-icon icon="carbon:document-import" width="32" class="text-slate-400"></iconify-icon>
|
||
<div class="text-sm text-slate-600">
|
||
<label class="cursor-pointer text-blue-600 hover:text-blue-700 font-medium">
|
||
点击上传文件
|
||
<input type="file" accept=".csv,.tmx,.tbx,.txt,.json,.xml" class="hidden" id="glossaryFileInput">
|
||
</label>
|
||
或拖拽文件到此处
|
||
</div>
|
||
<div class="text-xs text-slate-400">支持: .csv, .tmx, .tbx, .txt, .json, .xml</div>
|
||
</div>
|
||
`;
|
||
|
||
const textarea = document.createElement('textarea');
|
||
textarea.className = 'w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed focus:border-blue-300 focus:outline-none focus:ring-2 focus:ring-blue-200 transition';
|
||
textarea.rows = 8;
|
||
textarea.placeholder = '或直接粘贴术语文本、JSON、TMX 内容...';
|
||
|
||
const actionRow = document.createElement('div');
|
||
actionRow.className = 'flex flex-wrap items-center justify-between gap-3';
|
||
const hint = document.createElement('div');
|
||
hint.className = 'text-xs text-slate-500';
|
||
hint.textContent = `当前术语库共有 ${existingEntries.length} 条,导入时可选择覆盖冲突条目。`;
|
||
const btnGroup = document.createElement('div');
|
||
btnGroup.className = 'flex flex-wrap gap-2';
|
||
|
||
const previewBtn = document.createElement('button');
|
||
previewBtn.type = 'button';
|
||
previewBtn.className = 'inline-flex items-center gap-1 rounded-lg border border-blue-500 px-3 py-1.5 text-sm text-blue-600 bg-blue-50 hover:border-blue-600 hover:bg-blue-100 transition';
|
||
previewBtn.innerHTML = '<iconify-icon icon="carbon:result" width="16"></iconify-icon>解析文本';
|
||
|
||
const applyBtn = document.createElement('button');
|
||
applyBtn.type = 'button';
|
||
applyBtn.className = 'inline-flex items-center gap-1 rounded-lg border border-green-500 px-3 py-1.5 text-sm text-white bg-green-500 hover:bg-green-600 disabled:bg-slate-200 disabled:border-slate-200 disabled:text-slate-400 transition';
|
||
applyBtn.innerHTML = '<iconify-icon icon="carbon:checkmark" width="16"></iconify-icon>确认导入';
|
||
applyBtn.disabled = true;
|
||
|
||
const cancelBtn = document.createElement('button');
|
||
cancelBtn.type = 'button';
|
||
cancelBtn.className = 'inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 text-sm bg-white hover:border-slate-300 transition';
|
||
cancelBtn.innerHTML = '<iconify-icon icon="carbon:close" width="16"></iconify-icon>取消';
|
||
cancelBtn.addEventListener('click', () => modal.close());
|
||
|
||
btnGroup.appendChild(previewBtn);
|
||
btnGroup.appendChild(applyBtn);
|
||
btnGroup.appendChild(cancelBtn);
|
||
actionRow.appendChild(hint);
|
||
actionRow.appendChild(btnGroup);
|
||
|
||
const previewArea = document.createElement('div');
|
||
previewArea.className = 'space-y-4';
|
||
|
||
modal.body.appendChild(intro);
|
||
modal.body.appendChild(fileUploadArea);
|
||
modal.body.appendChild(textarea);
|
||
modal.body.appendChild(actionRow);
|
||
modal.body.appendChild(previewArea);
|
||
|
||
const state = {
|
||
setId,
|
||
parseResult: null,
|
||
conflicts: [],
|
||
conflictChoices: new Map(),
|
||
nonConflictEntries: [],
|
||
incomingCount: 0,
|
||
originalEntries: existingEntries
|
||
};
|
||
|
||
// 文件上传处理
|
||
const fileInput = modal.body.querySelector('#glossaryFileInput');
|
||
fileInput.addEventListener('change', async (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
|
||
const fileName = file.name.toLowerCase();
|
||
try {
|
||
if (fileName.endsWith('.tbx') || fileName.endsWith('.xml')) {
|
||
// 处理 TBX/XML 文件
|
||
const text = await file.text();
|
||
const result = parseTBXFormat(text);
|
||
if (!result.success) {
|
||
// 如果 TBX 解析失败,尝试作为普通 TMX/文本解析
|
||
textarea.value = text;
|
||
state.parseResult = parseSimpleGlossaryText(text);
|
||
} else {
|
||
state.parseResult = {
|
||
rawLineCount: result.entries.length,
|
||
entries: result.entries,
|
||
duplicates: [],
|
||
invalidLines: [],
|
||
errorMessage: ''
|
||
};
|
||
}
|
||
} else if (fileName.endsWith('.csv')) {
|
||
// 处理 CSV 文件
|
||
const text = await file.text();
|
||
const result = parseCSVFormat(text);
|
||
if (!result.success) {
|
||
showNotification && showNotification(result.error, 'error');
|
||
return;
|
||
}
|
||
state.parseResult = {
|
||
rawLineCount: result.entries.length,
|
||
entries: result.entries,
|
||
duplicates: [],
|
||
invalidLines: [],
|
||
errorMessage: ''
|
||
};
|
||
} else {
|
||
// TMX/TXT/JSON 文本文件
|
||
const text = await file.text();
|
||
textarea.value = text;
|
||
state.parseResult = parseSimpleGlossaryText(text);
|
||
}
|
||
|
||
// 自动触发预览
|
||
analyzeAndPreview();
|
||
} catch (err) {
|
||
showNotification && showNotification('文件读取失败: ' + err.message, 'error');
|
||
}
|
||
});
|
||
|
||
// 拖拽上传
|
||
fileUploadArea.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
fileUploadArea.classList.add('border-blue-400', 'bg-blue-50');
|
||
});
|
||
fileUploadArea.addEventListener('dragleave', () => {
|
||
fileUploadArea.classList.remove('border-blue-400', 'bg-blue-50');
|
||
});
|
||
fileUploadArea.addEventListener('drop', async (e) => {
|
||
e.preventDefault();
|
||
fileUploadArea.classList.remove('border-blue-400', 'bg-blue-50');
|
||
const file = e.dataTransfer.files[0];
|
||
if (file) {
|
||
fileInput.files = e.dataTransfer.files;
|
||
fileInput.dispatchEvent(new Event('change'));
|
||
}
|
||
});
|
||
|
||
function renderConflictPreview() {
|
||
const pr = state.parseResult;
|
||
previewArea.innerHTML = '';
|
||
if (!pr) return;
|
||
|
||
if (pr.errorMessage) {
|
||
const errorBox = document.createElement('div');
|
||
errorBox.className = 'rounded-xl border border-red-200 bg-red-50/80 text-red-600 text-sm px-4 py-3';
|
||
errorBox.innerHTML = `<div class="font-semibold mb-1">解析失败</div><div>${escapeHtml(pr.errorMessage)}</div>`;
|
||
previewArea.appendChild(errorBox);
|
||
return;
|
||
}
|
||
|
||
if (pr.entries.length === 0) {
|
||
const empty = document.createElement('div');
|
||
empty.className = 'rounded-xl border border-slate-200 bg-white px-4 py-4 text-sm text-slate-600 text-center';
|
||
empty.innerHTML = '<iconify-icon icon="carbon:information" class="text-slate-400" width="18"></iconify-icon><span class="ml-2">未解析到有效术语,请检查文本格式。</span>';
|
||
previewArea.appendChild(empty);
|
||
applyBtn.disabled = true;
|
||
applyBtn.innerHTML = '<iconify-icon icon="carbon:checkmark" width="16"></iconify-icon>确认导入';
|
||
return;
|
||
}
|
||
|
||
if (pr.invalidLines.length > 0) {
|
||
const warn = document.createElement('div');
|
||
warn.className = 'rounded-xl border border-amber-200 bg-amber-50/80 text-amber-700 text-sm px-4 py-3';
|
||
const list = pr.invalidLines.map(item => `<li>第 ${item.index} 行:${escapeHtml(item.content)}</li>`).join('');
|
||
warn.innerHTML = `<div class="font-semibold mb-1">已忽略 ${pr.invalidLines.length} 行格式不正确的记录</div><ul class="list-disc list-inside space-y-0.5">${list}</ul>`;
|
||
previewArea.appendChild(warn);
|
||
}
|
||
|
||
if (pr.duplicates.length > 0) {
|
||
const dup = document.createElement('div');
|
||
dup.className = 'rounded-xl border border-indigo-200 bg-indigo-50/80 text-indigo-700 text-sm px-4 py-3';
|
||
const uniq = Array.from(new Set(pr.duplicates.map(t => t.trim()).filter(Boolean)));
|
||
dup.innerHTML = `<div class="font-semibold mb-1">导入文本内存在重复术语,已保留最后一次出现</div><div class="text-xs mt-1">${uniq.map(t => escapeHtml(t)).join('、')}</div>`;
|
||
previewArea.appendChild(dup);
|
||
}
|
||
|
||
const summary = document.createElement('div');
|
||
summary.className = 'rounded-xl border border-slate-200 bg-white shadow-sm px-4 py-3 text-sm text-slate-600 flex flex-col gap-1';
|
||
summary.innerHTML = `
|
||
<div>有效条目:<strong class="text-slate-800">${state.incomingCount}</strong> 条</div>
|
||
<div>新增:<strong class="text-emerald-600">${state.nonConflictEntries.length}</strong> 条,冲突:<strong class="text-amber-600">${state.conflicts.length}</strong> 条</div>`;
|
||
previewArea.appendChild(summary);
|
||
|
||
if (state.conflicts.length > 0) {
|
||
const card = document.createElement('div');
|
||
card.className = 'rounded-2xl border-2 border-dashed border-amber-200 bg-amber-50/60 p-4 space-y-4';
|
||
const header = document.createElement('div');
|
||
header.className = 'flex flex-col md:flex-row md:items-center md:justify-between gap-3';
|
||
header.innerHTML = `
|
||
<div class="text-sm font-semibold text-amber-700 flex items-center gap-2">
|
||
<iconify-icon icon="carbon:warning" width="18"></iconify-icon>
|
||
<span>检测到 ${state.conflicts.length} 条冲突项</span>
|
||
</div>`;
|
||
const actionBtns = document.createElement('div');
|
||
actionBtns.className = 'flex flex-wrap gap-2 text-xs';
|
||
const useNewBtn = document.createElement('button');
|
||
useNewBtn.type = 'button';
|
||
useNewBtn.className = 'inline-flex items-center gap-1 rounded-lg border border-emerald-400 px-3 py-1 bg-emerald-100 text-emerald-700 hover:bg-emerald-200 transition';
|
||
useNewBtn.innerHTML = '<iconify-icon icon="carbon:checkmark" width="14"></iconify-icon>全部使用导入版本';
|
||
const useOldBtn = document.createElement('button');
|
||
useOldBtn.type = 'button';
|
||
useOldBtn.className = 'inline-flex items-center gap-1 rounded-lg border border-slate-300 px-3 py-1 bg-white text-slate-600 hover:border-slate-400 transition';
|
||
useOldBtn.innerHTML = '<iconify-icon icon="carbon:renew" width="14"></iconify-icon>全部保留现有版本';
|
||
actionBtns.appendChild(useNewBtn);
|
||
actionBtns.appendChild(useOldBtn);
|
||
header.appendChild(actionBtns);
|
||
card.appendChild(header);
|
||
|
||
const list = document.createElement('div');
|
||
list.className = 'space-y-3';
|
||
state.conflicts.forEach((conflict, idx) => {
|
||
const choice = state.conflictChoices.get(conflict.key) || 'new';
|
||
const item = document.createElement('div');
|
||
item.className = 'rounded-xl border border-amber-200 bg-white px-4 py-3 space-y-3 shadow-sm';
|
||
item.innerHTML = `
|
||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||
<div class="text-sm font-semibold text-slate-700">${escapeHtml(conflict.term)}</div>
|
||
<div class="flex flex-wrap gap-2 text-xs">
|
||
<label class="inline-flex items-center gap-1 rounded-full border ${choice==='old' ? 'border-slate-400 bg-slate-100 text-slate-700' : 'border-slate-200 bg-white text-slate-500'} px-3 py-1 transition">
|
||
<input type="radio" class="hidden" name="conflict-${idx}" data-conflict-key="${conflict.key}" value="old" ${choice==='old'?'checked':''}>
|
||
<span>保留现有</span>
|
||
</label>
|
||
<label class="inline-flex items-center gap-1 rounded-full border ${choice==='new' ? 'border-emerald-400 bg-emerald-100 text-emerald-700' : 'border-slate-200 bg-white text-slate-500'} px-3 py-1 transition">
|
||
<input type="radio" class="hidden" name="conflict-${idx}" data-conflict-key="${conflict.key}" value="new" ${choice==='new'?'checked':''}>
|
||
<span>使用导入版本</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="grid md:grid-cols-2 gap-3 text-xs text-slate-600">
|
||
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
||
<div class="font-semibold text-slate-600 mb-1">现有条目</div>
|
||
<div class="leading-relaxed">译文:${escapeHtml(conflict.existing.translation)}</div>
|
||
<div class="mt-1 text-[11px] text-slate-500">大小写敏感:${conflict.existing.caseSensitive ? '是' : '否'} | 全词匹配:${conflict.existing.wholeWord ? '是' : '否'} | 启用:${conflict.existing.enabled === false ? '否' : '是'}</div>
|
||
</div>
|
||
<div class="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2">
|
||
<div class="font-semibold text-emerald-700 mb-1">导入条目</div>
|
||
<div class="leading-relaxed">译文:${escapeHtml(conflict.incoming.translation)}</div>
|
||
<div class="mt-1 text-[11px] text-emerald-600">大小写敏感:${conflict.incoming.caseSensitive ? '是' : '否'} | 全词匹配:${conflict.incoming.wholeWord ? '是' : '否'} | 启用:${conflict.incoming.enabled === false ? '否' : '是'}</div>
|
||
</div>
|
||
</div>`;
|
||
list.appendChild(item);
|
||
});
|
||
card.appendChild(list);
|
||
previewArea.appendChild(card);
|
||
|
||
actionBtns.querySelectorAll('button').forEach(btn => {
|
||
btn.addEventListener('click', (evt) => {
|
||
evt.preventDefault();
|
||
const mode = btn === useNewBtn ? 'new' : 'old';
|
||
state.conflicts.forEach(conflict => state.conflictChoices.set(conflict.key, mode));
|
||
renderConflictPreview();
|
||
});
|
||
});
|
||
|
||
list.querySelectorAll('input[type="radio"]').forEach(radio => {
|
||
radio.addEventListener('change', () => {
|
||
const key = radio.getAttribute('data-conflict-key');
|
||
const value = radio.value;
|
||
state.conflictChoices.set(key, value);
|
||
renderConflictPreview();
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
function analyzeAndPreview() {
|
||
if (!state.parseResult) {
|
||
const text = textarea.value;
|
||
state.parseResult = parseSimpleGlossaryText(text);
|
||
}
|
||
|
||
const parsed = state.parseResult;
|
||
state.incomingCount = parsed.entries.length;
|
||
|
||
const currentMap = new Map(state.originalEntries.map(item => [normalizeTermForMatch(item.term), item]));
|
||
const conflicts = [];
|
||
const nonConflict = [];
|
||
parsed.entries.forEach(entry => {
|
||
const key = normalizeTermForMatch(entry.term);
|
||
const existing = currentMap.get(key);
|
||
if (existing) conflicts.push({ key, term: entry.term, existing, incoming: entry });
|
||
else nonConflict.push(entry);
|
||
});
|
||
state.conflicts = conflicts;
|
||
state.nonConflictEntries = nonConflict;
|
||
state.conflictChoices = new Map(conflicts.map(c => [c.key, 'new']));
|
||
|
||
const canApply = parsed.entries.length > 0 && !parsed.errorMessage;
|
||
applyBtn.disabled = !canApply;
|
||
if (canApply) {
|
||
applyBtn.innerHTML = `<iconify-icon icon="carbon:checkmark" width="16"></iconify-icon>确认导入(${parsed.entries.length} 条)`;
|
||
} else {
|
||
applyBtn.innerHTML = '<iconify-icon icon="carbon:checkmark" width="16"></iconify-icon>确认导入';
|
||
}
|
||
renderConflictPreview();
|
||
}
|
||
|
||
previewBtn.addEventListener('click', () => {
|
||
state.parseResult = null; // 重置,强制重新解析
|
||
analyzeAndPreview();
|
||
});
|
||
|
||
applyBtn.addEventListener('click', async () => {
|
||
if (!state.parseResult || state.parseResult.entries.length === 0 || state.parseResult.errorMessage) return;
|
||
|
||
const entryCount = state.parseResult.entries.length;
|
||
const setsLatest = loadGlossarySets();
|
||
const targetLatest = setsLatest[setId];
|
||
if (!targetLatest) {
|
||
showNotification && showNotification('术语库已被删除', 'error');
|
||
modal.close();
|
||
return;
|
||
}
|
||
|
||
// 准备合并数据
|
||
const existing = Array.isArray(targetLatest.entries) ? targetLatest.entries : [];
|
||
const result = [];
|
||
const conflictMap = new Map(state.conflicts.map(conflict => [conflict.key, conflict]));
|
||
const choiceMap = state.conflictChoices;
|
||
|
||
existing.forEach(item => {
|
||
const key = normalizeTermForMatch(item.term);
|
||
if (conflictMap.has(key)) {
|
||
const conflict = conflictMap.get(key);
|
||
const choice = choiceMap.get(key) || 'new';
|
||
if (choice === 'new') {
|
||
const newEntry = { ...conflict.incoming, id: item.id };
|
||
result.push(newEntry);
|
||
} else {
|
||
result.push(item);
|
||
}
|
||
} else {
|
||
result.push(item);
|
||
}
|
||
});
|
||
|
||
state.nonConflictEntries.forEach(entry => {
|
||
result.push({ ...entry });
|
||
});
|
||
|
||
// 对于大数据量显示进度条
|
||
if (entryCount > 1000 && typeof window.glossaryProgress !== 'undefined') {
|
||
try {
|
||
// 显示进度条
|
||
window.glossaryProgress.show(`正在导入 ${entryCount.toLocaleString()} 条术语到 "${escapeHtml(targetLatest.name)}"...`);
|
||
modal.close(); // 关闭模态框,显示进度条
|
||
|
||
// 使用异步保存并更新进度
|
||
targetLatest.entries = result;
|
||
await saveGlossarySetAsync(setId, targetLatest.name, targetLatest.enabled, result, (current, total) => {
|
||
window.glossaryProgress.update(current, total, `正在保存术语...`);
|
||
});
|
||
|
||
// 刷新增强编辑器(如果正在使用)
|
||
if (typeof window.glossaryEditorEnhanced !== 'undefined' &&
|
||
window.glossaryEditorEnhanced._currentSetId === setId) {
|
||
await window.glossaryEditorEnhanced.open(setId);
|
||
} else {
|
||
renderEntriesTable(setId);
|
||
}
|
||
|
||
renderGlossarySetsTable();
|
||
window.glossaryProgress.complete(`成功导入 ${entryCount.toLocaleString()} 条术语(新增 ${state.nonConflictEntries.length},冲突 ${state.conflicts.length})`, true);
|
||
} catch (err) {
|
||
window.glossaryProgress.complete(`导入失败: ${err.message}`, false);
|
||
}
|
||
} else {
|
||
// 小数据量直接保存
|
||
targetLatest.entries = result;
|
||
saveGlossarySets(setsLatest);
|
||
|
||
// 刷新增强编辑器(如果正在使用)
|
||
if (typeof window.glossaryEditorEnhanced !== 'undefined' &&
|
||
window.glossaryEditorEnhanced._currentSetId === setId) {
|
||
window.glossaryEditorEnhanced.open(setId);
|
||
} else {
|
||
renderEntriesTable(setId);
|
||
}
|
||
|
||
renderGlossarySetsTable();
|
||
modal.close();
|
||
showNotification && showNotification(`已导入 ${entryCount.toLocaleString()} 条术语(新增 ${state.nonConflictEntries.length},冲突 ${state.conflicts.length})`, 'success');
|
||
}
|
||
});
|
||
}
|
||
|
||
function renderGlossarySetsTable() {
|
||
const container = el('glossarySetsTable');
|
||
if (!container || typeof loadGlossarySets !== 'function') return;
|
||
const sets = loadGlossarySets();
|
||
const ids = Object.keys(sets);
|
||
const hint = el('glossarySetsCountHint');
|
||
const enabledCount = ids.reduce((acc, id) => acc + (sets[id] && sets[id].enabled ? 1 : 0), 0);
|
||
|
||
if (hint) {
|
||
if (ids.length === 0) {
|
||
hint.textContent = '暂无术语库';
|
||
hint.classList.add('text-slate-400');
|
||
} else {
|
||
hint.textContent = `启用 ${enabledCount} / ${ids.length}`;
|
||
hint.classList.remove('text-slate-400');
|
||
}
|
||
}
|
||
|
||
if (ids.length === 0) {
|
||
container.innerHTML = `
|
||
<div class="rounded-xl border border-dashed border-slate-200 bg-slate-50/70 px-4 py-6 text-center text-sm text-slate-500">
|
||
<iconify-icon icon="carbon:book" width="22" class="mx-auto mb-2 text-slate-400"></iconify-icon>
|
||
<p>暂无术语库,可点击上方按钮快速创建或导入现有 JSON 文件。</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
const cards = ids.map(id => {
|
||
const s = sets[id];
|
||
const count = Array.isArray(s.entries) ? s.entries.length : 0;
|
||
return `
|
||
<section class="rounded-xl border border-slate-200 bg-white shadow-sm px-4 py-3 md:px-5 md:py-4 transition-all hover:border-blue-200 hover:shadow-md" data-id="${id}">
|
||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||
<div class="flex items-start gap-3 flex-1">
|
||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-50 text-blue-500">
|
||
<iconify-icon icon="carbon:catalog" width="18"></iconify-icon>
|
||
</div>
|
||
<div class="flex-1">
|
||
<input type="text" data-action="rename-set" data-id="${id}" value="${escapeHtml(s.name || '')}" class="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-700 focus:border-blue-300 focus:outline-none focus:ring-2 focus:ring-blue-200 transition" placeholder="未命名术语库">
|
||
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||
<span class="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5">
|
||
<iconify-icon icon="carbon:data-vis-1" width="14"></iconify-icon>${count} 条词条
|
||
</span>
|
||
<span class="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5">ID: ${id.slice(0, 8)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex flex-col items-start md:items-end gap-2">
|
||
<label class="inline-flex items-center gap-2 rounded-full border border-slate-200 px-3 py-1 text-xs font-medium text-slate-600 bg-slate-50">
|
||
<input type="checkbox" data-action="toggle-set" data-id="${id}" ${s.enabled ? 'checked' : ''} class="h-4 w-4 text-blue-600 border-slate-300 rounded">
|
||
<span>${s.enabled ? '已启用' : '未启用'}</span>
|
||
</label>
|
||
<div class="flex flex-wrap gap-2 text-xs">
|
||
<button class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-2.5 py-1 hover:border-blue-300 hover:text-blue-600 transition" data-action="edit-set" data-id="${id}">
|
||
<iconify-icon icon="carbon:edit" width="14"></iconify-icon>编辑
|
||
</button>
|
||
<button class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-2.5 py-1 hover:border-blue-300 hover:text-blue-600 transition" data-action="export-set" data-id="${id}">
|
||
<iconify-icon icon="carbon:export" width="14"></iconify-icon>导出
|
||
</button>
|
||
<button class="inline-flex items-center gap-1 rounded-lg border border-red-200 px-2.5 py-1 text-red-500 hover:border-red-400 hover:bg-red-50 transition" data-action="delete-set" data-id="${id}">
|
||
<iconify-icon icon="carbon:trash-can" width="14"></iconify-icon>删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>`;
|
||
}).join('');
|
||
|
||
container.innerHTML = `<div class="grid gap-3">${cards}</div>`;
|
||
|
||
container.querySelectorAll('[data-action="toggle-set"]').forEach(cb => {
|
||
cb.addEventListener('change', (e) => {
|
||
const id = e.target.getAttribute('data-id');
|
||
toggleGlossarySet(id, e.target.checked);
|
||
renderGlossarySetsTable();
|
||
});
|
||
});
|
||
container.querySelectorAll('[data-action="rename-set"]').forEach(inp => {
|
||
const handler = (e) => renameGlossarySet(e.target.getAttribute('data-id'), e.target.value);
|
||
inp.addEventListener('change', handler);
|
||
inp.addEventListener('blur', handler);
|
||
});
|
||
container.querySelectorAll('[data-action="delete-set"]').forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
const id = e.currentTarget.getAttribute('data-id');
|
||
if (!confirm('确认删除该术语库?此操作不可撤销。')) return;
|
||
deleteGlossarySet(id);
|
||
const panel = el('glossaryEditorPanel');
|
||
if (panel && panel.dataset.editingId === id) { panel.classList.add('hidden'); panel.dataset.editingId = ''; }
|
||
renderGlossarySetsTable();
|
||
});
|
||
});
|
||
container.querySelectorAll('[data-action="export-set"]').forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
const id = e.currentTarget.getAttribute('data-id');
|
||
const data = exportGlossarySet(id);
|
||
const blob = new Blob([data], { type: 'application/json;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a'); a.href = url; a.download = 'glossary-set.json';
|
||
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
|
||
});
|
||
});
|
||
container.querySelectorAll('[data-action="edit-set"]').forEach(btn => btn.addEventListener('click', (e) => openEditorForSet(e.currentTarget.getAttribute('data-id'))));
|
||
}
|
||
|
||
function openEditorForSet(setId) {
|
||
// 优先使用增强版编辑器(支持搜索、分页、批量操作)
|
||
if (typeof window.glossaryEditorEnhanced !== 'undefined') {
|
||
window.glossaryEditorEnhanced.open(setId);
|
||
return;
|
||
}
|
||
|
||
// 降级到旧版编辑器
|
||
const panel = el('glossaryEditorPanel');
|
||
if (!panel) return;
|
||
panel.classList.remove('hidden');
|
||
panel.dataset.editingId = setId;
|
||
renderEntriesTable(setId);
|
||
}
|
||
|
||
function renderEntriesTable(setId) {
|
||
const wrap = el('glossaryEntriesTable');
|
||
if (!wrap) return;
|
||
const sets = loadGlossarySets();
|
||
const s = sets[setId];
|
||
if (!s) { wrap.innerHTML = '<div class="rounded-lg bg-amber-50 border border-amber-200 text-amber-700 text-sm px-3 py-2">术语库不存在,可能已被删除。</div>'; return; }
|
||
const entries = Array.isArray(s.entries) ? s.entries : [];
|
||
|
||
const rows = entries.map(e => `
|
||
<tr class="border-b last:border-0 hover:bg-slate-50/70 transition">
|
||
<td class="px-3 py-2 align-top">
|
||
<input type="text" data-row="${e.id}" data-col="term" class="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm focus:border-blue-300 focus:outline-none focus:ring-2 focus:ring-blue-200 transition" value="${escapeHtml(e.term)}" placeholder="术语或词组">
|
||
</td>
|
||
<td class="px-3 py-2 align-top">
|
||
<textarea data-row="${e.id}" data-col="translation" class="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed focus:border-blue-300 focus:outline-none focus:ring-2 focus:ring-blue-200 transition" rows="2" placeholder="对应译文">${escapeHtml(e.translation)}</textarea>
|
||
</td>
|
||
<td class="px-3 py-2 text-center">
|
||
<input type="checkbox" data-row="${e.id}" data-col="caseSensitive" ${e.caseSensitive?'checked':''} class="h-4 w-4 text-blue-600 border-slate-300 rounded">
|
||
</td>
|
||
<td class="px-3 py-2 text-center">
|
||
<input type="checkbox" data-row="${e.id}" data-col="wholeWord" ${e.wholeWord?'checked':''} class="h-4 w-4 text-blue-600 border-slate-300 rounded">
|
||
</td>
|
||
<td class="px-3 py-2 text-center">
|
||
<input type="checkbox" data-row="${e.id}" data-col="enabled" ${e.enabled!==false?'checked':''} class="h-4 w-4 text-blue-600 border-slate-300 rounded">
|
||
</td>
|
||
<td class="px-3 py-2 text-right">
|
||
<button class="inline-flex items-center gap-1 rounded-lg border border-red-200 px-2 py-1 text-xs text-red-500 hover:border-red-400 hover:bg-red-50 transition" data-action="del-entry" data-row="${e.id}">
|
||
<iconify-icon icon="carbon:trash-can" width="14"></iconify-icon>删除
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
wrap.innerHTML = `
|
||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-4">
|
||
<div class="text-sm font-medium text-slate-700">当前术语库:${escapeHtml(s.name || '')}(${entries.length} 条)</div>
|
||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||
<button id="addEntryBtn" class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 bg-white hover:border-blue-300 hover:text-blue-600 transition">
|
||
<iconify-icon icon="carbon:add-alt" width="14"></iconify-icon>新增词条
|
||
</button>
|
||
<button id="importEntriesBtn" class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 bg-white hover:border-blue-300 hover:text-blue-600 transition">
|
||
<iconify-icon icon="carbon:import" width="14"></iconify-icon>导入
|
||
</button>
|
||
<button id="exportEntriesBtn" class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 bg-white hover:border-blue-300 hover:text-blue-600 transition">
|
||
<iconify-icon icon="carbon:export" width="14"></iconify-icon>导出
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||
<table class="min-w-full text-sm text-slate-700">
|
||
<thead class="bg-slate-100 text-xs font-medium uppercase tracking-wide text-slate-500">
|
||
<tr>
|
||
<th class="px-3 py-2 text-left">术语/词组</th>
|
||
<th class="px-3 py-2 text-left">译文</th>
|
||
<th class="px-3 py-2 text-center">大小写敏感</th>
|
||
<th class="px-3 py-2 text-center">全词匹配</th>
|
||
<th class="px-3 py-2 text-center">启用</th>
|
||
<th class="px-3 py-2 text-right">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${rows || '<tr><td colspan="6" class="px-6 py-6 text-center text-sm text-slate-400">暂无词条,请先新增或导入。</td></tr>'}</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
|
||
wrap.querySelectorAll('input[data-row][data-col], textarea[data-row][data-col]').forEach(inp => {
|
||
const handler = () => {
|
||
const row = inp.getAttribute('data-row');
|
||
const col = inp.getAttribute('data-col');
|
||
const sets2 = loadGlossarySets();
|
||
const s2 = sets2[setId];
|
||
if (!s2) return;
|
||
const idx = s2.entries.findIndex(x => x.id === row);
|
||
if (idx === -1) return;
|
||
let val;
|
||
if (inp.type === 'checkbox') {
|
||
val = inp.checked;
|
||
} else {
|
||
val = inp.value;
|
||
}
|
||
s2.entries[idx][col] = val;
|
||
saveGlossarySets(sets2);
|
||
};
|
||
inp.addEventListener('change', handler);
|
||
if (inp.type !== 'checkbox') inp.addEventListener('input', handler);
|
||
});
|
||
|
||
wrap.querySelectorAll('[data-action="del-entry"]').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const row = btn.getAttribute('data-row');
|
||
const sets2 = loadGlossarySets();
|
||
const s2 = sets2[setId];
|
||
if (!s2) return;
|
||
s2.entries = s2.entries.filter(x => x.id !== row);
|
||
saveGlossarySets(sets2);
|
||
renderEntriesTable(setId);
|
||
});
|
||
});
|
||
|
||
const addBtn = el('addEntryBtn');
|
||
if (addBtn) addBtn.addEventListener('click', () => {
|
||
const sets2 = loadGlossarySets();
|
||
const s2 = sets2[setId];
|
||
s2.entries.push({ id: generateUUID(), term: '', translation: '', caseSensitive: false, wholeWord: false, enabled: true });
|
||
saveGlossarySets(sets2);
|
||
renderEntriesTable(setId);
|
||
});
|
||
|
||
const importBtn = el('importEntriesBtn');
|
||
if (importBtn) importBtn.addEventListener('click', () => openGlossaryImportModal(setId));
|
||
|
||
const exportBtn = el('exportEntriesBtn');
|
||
if (exportBtn) exportBtn.addEventListener('click', () => openGlossaryExportModal(setId));
|
||
}
|
||
|
||
function bindTopControls() {
|
||
const addSetBtn = el('addGlossarySetBtn');
|
||
if (addSetBtn) addSetBtn.addEventListener('click', () => {
|
||
const s = prompt('输入术语库名称', '新术语库');
|
||
if (s === null) return;
|
||
createGlossarySet(s || '新术语库');
|
||
renderGlossarySetsTable();
|
||
});
|
||
|
||
const importSetBtn = el('importGlossarySetBtn');
|
||
const importSetFile = el('importGlossarySetFile');
|
||
if (importSetBtn && importSetFile) {
|
||
importSetBtn.addEventListener('click', () => importSetFile.click());
|
||
importSetFile.addEventListener('change', async (e) => {
|
||
const file = e.target.files && e.target.files[0];
|
||
if (!file) return;
|
||
|
||
try {
|
||
const text = await new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(String(reader.result || '{}'));
|
||
reader.onerror = () => reject(reader.error);
|
||
reader.readAsText(file);
|
||
});
|
||
|
||
// 检查数据大小以决定是否显示进度条
|
||
const data = JSON.parse(text);
|
||
const entryCount = data.entries?.length || 0;
|
||
|
||
// 对于大于 1000 条的导入显示进度条
|
||
if (entryCount > 1000 && typeof window.glossaryProgress !== 'undefined') {
|
||
window.glossaryProgress.show(`正在导入 ${entryCount.toLocaleString()} 条术语...`);
|
||
|
||
await importGlossarySetAsync(text, (current, total) => {
|
||
window.glossaryProgress.update(current, total, `正在保存术语...`);
|
||
});
|
||
|
||
window.glossaryProgress.complete(`成功导入 ${entryCount.toLocaleString()} 条术语`, true);
|
||
renderGlossarySetsTable();
|
||
} else {
|
||
// 小量数据直接导入,不显示进度条
|
||
importGlossarySet(text);
|
||
renderGlossarySetsTable();
|
||
showNotification && showNotification('术语库已导入', 'success');
|
||
}
|
||
} catch (err) {
|
||
if (typeof window.glossaryProgress !== 'undefined') {
|
||
window.glossaryProgress.complete(`导入失败: ${err.message}`, false);
|
||
}
|
||
showNotification && showNotification(`导入失败:${err.message}`, 'error');
|
||
} finally {
|
||
importSetFile.value = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
const exportAllBtn = el('exportAllGlossarySetsBtn');
|
||
if (exportAllBtn) exportAllBtn.addEventListener('click', () => {
|
||
const data = exportAllGlossarySets();
|
||
const blob = new Blob([data], { type: 'application/json;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a'); a.href = url; a.download = 'glossary-sets.json';
|
||
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
|
||
});
|
||
}
|
||
|
||
window.glossaryUI = { renderGlossarySetsTable, openEditorForSet, renderEntriesTable };
|
||
|
||
// 暴露导入导出函数供增强编辑器使用
|
||
window.openGlossaryImportModal = openGlossaryImportModal;
|
||
window.openGlossaryExportModal = openGlossaryExportModal;
|
||
|
||
// 监听术语库加载完成事件
|
||
window.addEventListener('glossarySetsLoaded', function() {
|
||
console.log('[GlossaryUI] Glossary sets loaded, rendering...');
|
||
renderGlossarySetsTable();
|
||
});
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
console.log('[GlossaryUI] DOM loaded, initializing...');
|
||
|
||
// 绑定顶部控件
|
||
bindTopControls();
|
||
|
||
// 检查缓存是否已经准备好
|
||
if (window._glossarySetsCache && Object.keys(window._glossarySetsCache).length > 0) {
|
||
console.log('[GlossaryUI] Cache already ready, rendering immediately');
|
||
renderGlossarySetsTable();
|
||
} else {
|
||
console.log('[GlossaryUI] Waiting for glossary data to load...');
|
||
// 显示加载提示
|
||
const container = el('glossarySetsTable');
|
||
if (container) {
|
||
container.innerHTML = `
|
||
<div class="rounded-xl border border-dashed border-slate-200 bg-slate-50/70 px-4 py-6 text-center text-sm text-slate-500">
|
||
<iconify-icon icon="carbon:hourglass" width="22" class="mx-auto mb-2 text-slate-400 animate-pulse"></iconify-icon>
|
||
<p>正在加载术语库...</p>
|
||
</div>`;
|
||
}
|
||
}
|
||
});
|
||
})();
|