fix: 术语表上传/导入/导出全面修复+协同维护+离线保存

上传不触发问题:
- 原生file控件改为隐藏,由【导入CSV】按钮触发(triggerGlossaryImport)
- 折叠区和弹窗内都提供导入入口
- 导入后自动打开弹窗显示结果+提示导入条数

CSV解析鲁棒性:
- 用FileReader读UTF-8文本,Papa.parse(字符串)替代File对象,加手动parseCsvText兜底
- transformHeader剥离BOM+规范化,修复带BOM的CSV首列名变成zh导致中文列丢失
- normalizeGlossaryRow支持3列(zh/en/id)、2列任意组合、旧src/dst格式

协同维护+离线保存:
- loadGlossaryFromServer失败时从localStorage缓存回退
- saveGlossary先写localStorage再同步服务端,服务端失败提示"已保存到本机"
- openGlossaryModal用getOrCreateInstance复用实例避免重复创建

文案:
- glossaryHelp更新为三语说明+支持任意两列
- 新增glossaryImportBtn/glossaryImportNone/glossaryImportError/glossarySavedLocal

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 10:02:03 +08:00
parent dd3c1cfe05
commit d86d5c1c64
2 changed files with 151 additions and 49 deletions

View File

@@ -108,7 +108,7 @@
"unlimitedPlaceholder": "留空为无限制",
"glossaryGenTitle": "术语表",
"glossaryLabel": "术语表(全公司共享)",
"glossaryHelp": "选择一个或多个CSV文件。文件需包含'src'和'dst'两列标题,分别代表原文和译文。",
"glossaryHelp": "全公司共享的三语术语表(中文/English/Bahasa。翻译时按目标语言自动取对应列。支持任意两列如仅中英。点击下方按钮导入CSV或查看编辑。",
"viewGlossaryBtn": "查看术语表",
"clearGlossaryBtn": "清空",
"glossaryGenEnableLabel": "自动生成术语表",
@@ -186,7 +186,11 @@
"glossaryUnsaved": "未保存",
"glossarySaved": "术语表已保存,对所有用户生效。",
"glossarySaveFailed": "保存失败",
"glossaryImported": "已导入 {n} 条术语,请点击【保存】使其对所有用户生效。"
"glossaryImported": "已导入 {n} 条术语,请点击【保存】使其对所有用户生效。",
"glossaryImportBtn": "导入CSV",
"glossaryImportNone": "未能从CSV中解析出有效术语请检查文件格式表头应为 zh,en,id 或 src,dst。",
"glossaryImportError": "导入失败",
"glossarySavedLocal": "已保存到本机(服务端同步失败,数据保留在本地,下次可重试保存)"
},
"en": {
"pageTitle": "DocuTranslate - Interactive Document Translation",
@@ -297,7 +301,7 @@
"unlimitedPlaceholder": "Leave empty for unlimited",
"glossaryGenTitle": "Glossary",
"glossaryLabel": "Glossary (shared company-wide)",
"glossaryHelp": "Select one or more CSV files. Files must contain 'src' and 'dst' headers, representing source and destination terms.",
"glossaryHelp": "Company-wide shared trilingual glossary (中文/English/Bahasa). The target-language column is used automatically during translation. Any two columns (e.g. Chinese-English only) are supported. Use the buttons below to import CSV or view/edit.",
"viewGlossaryBtn": "View Glossary",
"clearGlossaryBtn": "Clear",
"glossaryGenEnableLabel": "Auto-generate Glossary",
@@ -375,7 +379,11 @@
"glossaryUnsaved": "Unsaved",
"glossarySaved": "Glossary saved and applied to all users.",
"glossarySaveFailed": "Save failed",
"glossaryImported": "Imported {n} terms. Click [Save] to apply to all users."
"glossaryImported": "Imported {n} terms. Click [Save] to apply to all users.",
"glossaryImportBtn": "Import CSV",
"glossaryImportNone": "No valid terms parsed from the CSV. Check the format (headers should be zh,en,id or src,dst).",
"glossaryImportError": "Import failed",
"glossarySavedLocal": "Saved locally (server sync failed; data kept locally, retry save later)"
},
"id": {
"pageTitle": "DocuTranslate - Terjemahan Dokumen Interaktif",
@@ -486,7 +494,7 @@
"unlimitedPlaceholder": "Kosongkan untuk tanpa batas",
"glossaryGenTitle": "Glosarium",
"glossaryLabel": "Glosarium (dibagikan ke seluruh perusahaan)",
"glossaryHelp": "Pilih file CSV dengan kolom src dan dst.",
"glossaryHelp": "Glosarium trilingual dibagikan ke seluruh perusahaan (中文/English/Bahasa). Kolom bahasa target digunakan otomatis saat menerjemahkan. Mendukung dua kolom saja (mis. Chinese-English). Gunakan tombol di bawah untuk mengimpor CSV atau melihat/mengedit.",
"viewGlossaryBtn": "Lihat Glosarium",
"clearGlossaryBtn": "Hapus",
"glossaryGenEnableLabel": "Buat Glosarium Otomatis",
@@ -564,6 +572,10 @@
"glossaryUnsaved": "Belum disimpan",
"glossarySaved": "Glosarium disimpan, berlaku untuk semua pengguna.",
"glossarySaveFailed": "Gagal menyimpan",
"glossaryImported": "Mengimpor {n} istilah. Klik [Simpan] untuk berlaku ke semua pengguna."
"glossaryImported": "Mengimpor {n} istilah. Klik [Simpan] untuk berlaku ke semua pengguna.",
"glossaryImportBtn": "Impor CSV",
"glossaryImportNone": "Tidak ada istilah valid yang diurai dari CSV. Periksa format (header seharusnya zh,en,id atau src,dst).",
"glossaryImportError": "Impor gagal",
"glossarySavedLocal": "Tersimpan secara lokal (sinkronisasi server gagal; data tetap lokal, coba simpan lagi nanti)"
}
}

View File

@@ -684,21 +684,20 @@
<div class="accordion-body">
<div class="mb-3">
<label class="form-label">{{ t('glossaryLabel') }}</label>
<input class="form-control" type="file" @change="handleGlossaryFiles"
multiple accept=".csv" ref="glossaryInput">
<div class="form-text">{{ t('glossaryHelp') }}</div>
<div class="btn-group mt-2">
<div class="form-text mb-2">{{ t('glossaryHelp') }}</div>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-info"
@click="openGlossaryModal">
<i class="bi bi-card-list me-1"></i><span>{{ t('viewGlossaryBtn') }} <span
v-if="glossaryCount">({{glossaryCount}})</span></span>
</button>
<button type="button" class="btn btn-sm btn-outline-danger"
@click="clearGlossary">
<i class="bi bi-trash me-1"></i><span>{{ t('clearGlossaryBtn')
}}</span>
<button type="button" class="btn btn-sm btn-outline-warning"
@click="triggerGlossaryImport">
<i class="bi bi-upload me-1"></i><span>{{ t('glossaryImportBtn') }}</span>
</button>
</div>
<input class="form-control d-none" type="file" @change="handleGlossaryFiles"
multiple accept=".csv" ref="glossaryInput">
</div>
</div>
</div>
@@ -883,6 +882,9 @@
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-warning" @click="triggerGlossaryImport">
<i class="bi bi-upload me-1"></i>{{ t('glossaryImportBtn') }}
</button>
<button type="button" class="btn btn-outline-secondary" @click="exportGlossaryCsv">
<i class="bi bi-download me-1"></i>{{ t('glossaryExportCsv') }}
</button>
@@ -1390,25 +1392,38 @@
glossaryRows.value.splice(i, 1);
glossaryDirty.value = true;
};
const _normalizeGlossaryRowShape = (r) => ({
zh: String(r.zh||''), en: String(r.en||''), id: String(r.id||'')
});
const loadGlossaryFromServer = async () => {
try {
const res = await fetch('/service/glossary');
const data = await res.json();
glossaryRows.value = (data && Array.isArray(data.rows)) ? data.rows.map(r => ({
zh: String(r.zh||''), en: String(r.en||''), id: String(r.id||'')
})) : [];
const rows = (data && Array.isArray(data.rows)) ? data.rows.map(_normalizeGlossaryRowShape) : [];
glossaryRows.value = rows;
glossaryDirty.value = false;
// 镜像到 localStorage离线/服务端不可用时回退使用
try { localStorage.setItem('glossary_cache', JSON.stringify(rows)); } catch (_) {}
} catch (e) {
console.error('Failed to load shared glossary', e);
console.error('Failed to load shared glossary, trying local cache', e);
// 离线回退:从 localStorage 读取上次缓存
try {
const cached = JSON.parse(localStorage.getItem('glossary_cache') || '[]');
if (Array.isArray(cached) && cached.length) {
glossaryRows.value = cached.map(_normalizeGlossaryRowShape);
}
} catch (_) {}
}
};
const saveGlossary = async () => {
if (glossarySaving.value) return;
glossarySaving.value = true;
const rows = glossaryRows.value
.map(r => ({zh:(r.zh||'').trim(), en:(r.en||'').trim(), id:(r.id||'').trim()}))
.filter(r => r.zh || r.en || r.id);
// 先写本地缓存(离线保存),保证数据不丢
try { localStorage.setItem('glossary_cache', JSON.stringify(rows)); } catch (_) {}
try {
const rows = glossaryRows.value
.map(r => ({zh:(r.zh||'').trim(), en:(r.en||'').trim(), id:(r.id||'').trim()}))
.filter(r => r.zh || r.en || r.id);
const res = await fetch('/service/glossary', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
@@ -1416,13 +1431,13 @@
});
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
glossaryRows.value = (data.rows || []).map(r => ({
zh: String(r.zh||''), en: String(r.en||''), id: String(r.id||'')
}));
glossaryRows.value = (data.rows || []).map(_normalizeGlossaryRowShape);
glossaryDirty.value = false;
alert(t('glossarySaved'));
} catch (e) {
alert(t('glossarySaveFailed') + ': ' + e.message);
// 服务端保存失败时,本地缓存已保存,标记未同步
glossaryDirty.value = true;
alert(t('glossarySavedLocal') + ': ' + e.message);
} finally {
glossarySaving.value = false;
}
@@ -1449,34 +1464,104 @@
URL.revokeObjectURL(url);
};
// 触发隐藏文件选择框由按钮调用避免原生file控件样式不一致
const triggerGlossaryImport = () => {
if (glossaryInput.value) glossaryInput.value.click();
};
// 手动 CSV 解析兜底:当 PapaParse 不可用或解析异常时使用
const parseCsvText = (text) => {
text = text.replace(/^/, ''); // 剥离 BOM
const lines = text.split(/\r?\n/).filter(l => l.length);
if (!lines.length) return [];
const parseLine = (line) => {
const out = []; let cur = '', inQ = false;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (inQ) {
if (c === '"' && line[i+1] === '"') { cur += '"'; i++; }
else if (c === '"') inQ = false;
else cur += c;
} else {
if (c === '"') inQ = true;
else if (c === ',') { out.push(cur); cur = ''; }
else cur += c;
}
}
out.push(cur);
return out;
};
const headers = parseLine(lines[0]).map(h => h.trim().toLowerCase());
const rows = [];
for (let i = 1; i < lines.length; i++) {
const cells = parseLine(lines[i]);
const obj = {};
headers.forEach((h, idx) => { obj[h] = (cells[idx] || '').trim(); });
rows.push(obj);
}
return rows;
};
// 把解析出的行(可能含 zh/en/id/src/dst 等键)规范化为三列
const normalizeGlossaryRow = (r) => {
const row = {zh:'', en:'', id:''};
const has = (...keys) => keys.some(k => k in r && String(r[k]||'').trim());
if (has('zh','en','id')) {
row.zh = (r.zh||'').trim(); row.en = (r.en||'').trim(); row.id = (r.id||'').trim();
} else if (has('src','dst')) {
// 旧 2 列或任意 src/dstsrc→en(英文)dst→zh(中文)
row.zh = (r.dst||'').trim(); row.en = (r.src||'').trim();
} else {
// 纯 2 列中文-英文(无表头或表头不规范):按 [中, 英] 推断
const vals = Object.values(r).map(v => String(v||'').trim()).filter(Boolean);
if (vals.length >= 2) { row.zh = vals[0]; row.en = vals[1]; }
else if (vals.length === 1) { row.zh = vals[0]; }
}
return (row.zh || row.en || row.id) ? row : null;
};
const handleGlossaryFiles = (e) => {
const files = e.target.files;
if (!files.length) return;
if (!files || !files.length) return;
let processed = 0;
const totalFiles = files.length;
Array.from(files).forEach(f => {
Papa.parse(f, {
header: true, skipEmptyLines: true,
// 剥离 BOM + 规范化表头(小写、去空白),兼容 Excel 另存的 CSV
transformHeader: (h) => String(h||'').replace(/^/, '').trim().toLowerCase(),
complete: (res) => {
if (!res.data) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const text = ev.target.result || '';
// 优先用 PapaParse失败则用手动解析
let parsed = null;
if (window.Papa) {
const res = Papa.parse(text, {
header: true, skipEmptyLines: true,
transformHeader: (h) => String(h||'').replace(/^/, '').trim().toLowerCase()
});
parsed = res.data;
}
if (!parsed) parsed = parseCsvText(text);
let added = 0;
res.data.forEach(r => {
const row = {zh:'', en:'', id:''};
if ('zh' in r || 'en' in r || 'id' in r) {
row.zh = (r.zh||'').trim(); row.en = (r.en||'').trim(); row.id = (r.id||'').trim();
} else if ('src' in r && 'dst' in r) {
// 旧 2 列src 视为英文、dst 视为中文(最常见英译中场景)
row.zh = (r.dst||'').trim(); row.en = (r.src||'').trim();
} else { return; }
if (row.zh || row.en || row.id) {
glossaryRows.value.push(row);
glossaryDirty.value = true;
added++;
}
parsed.forEach(r => {
const row = normalizeGlossaryRow(r);
if (row) { glossaryRows.value.push(row); added++; }
});
if (added) alert(t('glossaryImported').replace('{n}', added));
glossaryDirty.value = true;
processed++;
if (processed === totalFiles) {
openGlossaryModal(); // 自动打开弹窗让用户看到导入结果
if (added > 0) {
alert(t('glossaryImported').replace('{n}', added));
} else {
alert(t('glossaryImportNone'));
}
}
} catch (err) {
console.error('glossary import error', err);
alert(t('glossaryImportError') + ': ' + err.message);
}
});
};
reader.onerror = () => alert(t('glossaryImportError') + ': read failed');
reader.readAsText(f, 'utf-8');
});
if (glossaryInput.value) glossaryInput.value.value = '';
};
@@ -1484,7 +1569,12 @@
// 清空当前编辑并从服务端重新拉取(避免误删共享数据)
await loadGlossaryFromServer();
};
const openGlossaryModal = () => new bootstrap.Modal(document.getElementById('glossaryModal')).show();
// 复用同一个 Modal 实例,避免重复创建
const glossaryModalInstance = () => {
const el = document.getElementById('glossaryModal');
return bootstrap.Modal.getOrCreateInstance(el);
};
const openGlossaryModal = () => glossaryModalInstance().show();
// Task Management
const createNewTask = (backendId = null) => {
@@ -2154,7 +2244,7 @@
errors, clearError,
t, createNewTask, removeTask, handleTaskFileSelect, handleTaskFileDrop, toggleTaskState,
handleGlossaryFiles, clearGlossary, openGlossaryModal,
addGlossaryRow, deleteGlossaryRow, saveGlossary, exportGlossaryCsv,
addGlossaryRow, deleteGlossaryRow, saveGlossary, exportGlossaryCsv, triggerGlossaryImport,
openPreview, setPreviewMode, toggleSyncScroll, printPdf, copyLog, getFileIcon,
exportConfig, importConfig, setLang, setTheme, capitalize,
currentWorkflowConfig, ocrOptions, stepMap,