feat: 全公司共享三语术语表(中/英/印尼),保存即默认引用
- glossary_store.py: 进程级单例JSON存储,原子写入+asyncio锁,DOCUTRANSLATE_GLOSSARY_PATH可配置
- lang_mapping.py: 三列(zh/en/id)与{src:dst}互转,按目标语言自动取列;兼容旧2列CSV
- app.py: 新增GET/POST /service/glossary路由
- environment.py: 新增DOCUTRANSLATE_GLOSSARY_PATH环境变量
- index.html: 弹窗改为可编辑3列表+增删行+保存/导入/导出CSV;页面打开自动加载共享术语表;
翻译时前端按to_lang编译为{src:dst},payload.glossary_dict契约不变
- i18nData.json: 三语新增术语表相关UI文案
- 翻译管线零改动:MT走translation_options.terms,非MT走system prompt注入
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,6 +8,8 @@ wheels/
|
||||
tests/resource/
|
||||
tests/
|
||||
docutranslate/output/
|
||||
# Shared glossary runtime data
|
||||
data/
|
||||
# Virtual environments
|
||||
.venv
|
||||
#idea
|
||||
|
||||
@@ -2206,6 +2206,28 @@ async def service_get_app_version():
|
||||
return JSONResponse(content={"version": __version__})
|
||||
|
||||
|
||||
@service_router.get(
|
||||
"/glossary", tags=["Application"], description="获取全公司共享术语表(三列:中文/English/Bahasa)"
|
||||
)
|
||||
async def service_get_glossary():
|
||||
from docutranslate.glossary.glossary_store import get_glossary_store
|
||||
store = get_glossary_store()
|
||||
return JSONResponse(content={"rows": store.get_rows()})
|
||||
|
||||
|
||||
@service_router.post(
|
||||
"/glossary", tags=["Application"], description="覆盖保存全公司共享术语表(保存即对所有用户生效)"
|
||||
)
|
||||
async def service_save_glossary(body: dict = Body(...)):
|
||||
from docutranslate.glossary.glossary_store import get_glossary_store
|
||||
raw_rows = body.get("rows") if isinstance(body, dict) else None
|
||||
if not isinstance(raw_rows, list):
|
||||
raise HTTPException(status_code=400, detail="请求体需为 {\"rows\": [...]}")
|
||||
store = get_glossary_store()
|
||||
saved = await store.save(raw_rows)
|
||||
return JSONResponse(content={"rows": saved})
|
||||
|
||||
|
||||
@service_router.post(
|
||||
"/flat-translate",
|
||||
summary="translate(sync)",
|
||||
|
||||
@@ -24,6 +24,10 @@ DOCUTRANSLATE_PROXY_ENABLED: bool = (
|
||||
# 任务缓存数量
|
||||
DOCUTRANSLATE_CACHE_NUM: int = int(os.getenv("DOCUTRANSLATE_CACHE_NUM", "10"))
|
||||
|
||||
# --- 共享术语表存储路径 ---
|
||||
# 留空则使用工作目录下 ./data/glossary.json
|
||||
DOCUTRANSLATE_GLOSSARY_PATH: str = os.getenv("DOCUTRANSLATE_GLOSSARY_PATH", "")
|
||||
|
||||
# --- 翻译 API 默认配置 ---
|
||||
# 默认 API 地址 (自定义接口的 Base URL)
|
||||
DOCUTRANSLATE_BASE_URL: str = os.getenv("DOCUTRANSLATE_BASE_URL", "")
|
||||
|
||||
115
docutranslate/glossary/glossary_store.py
Normal file
115
docutranslate/glossary/glossary_store.py
Normal file
@@ -0,0 +1,115 @@
|
||||
# SPDX-FileCopyrightText: 2025 QinHan
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""
|
||||
共享术语表持久化存储。
|
||||
|
||||
设计要点:
|
||||
- 进程级单例,所有用户读写同一份 JSON 文件。
|
||||
- 原子写入(临时文件 + os.replace),asyncio.Lock 保护并发写。
|
||||
- 三列结构 {zh, en, id},按目标语言编译为单向 {src: dst}(见 lang_mapping.py)。
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from docutranslate.logger import global_logger
|
||||
|
||||
_COLS = ("zh", "en", "id")
|
||||
_VERSION = 1
|
||||
|
||||
|
||||
def _default_path() -> Path:
|
||||
"""解析术语表存储路径:优先环境变量,否则工作目录下 ./data/glossary.json"""
|
||||
from docutranslate.environment import DOCUTRANSLATE_GLOSSARY_PATH
|
||||
if DOCUTRANSLATE_GLOSSARY_PATH:
|
||||
p = Path(DOCUTRANSLATE_GLOSSARY_PATH).expanduser()
|
||||
else:
|
||||
p = Path.cwd() / "data" / "glossary.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def _normalize_row(raw: Any) -> dict:
|
||||
"""规范化一行:确保 zh/en/id 三键均为 str(空串允许)。"""
|
||||
if not isinstance(raw, dict):
|
||||
return {c: "" for c in _COLS}
|
||||
return {c: str(raw.get(c, "") or "") for c in _COLS}
|
||||
|
||||
|
||||
class GlossaryStore:
|
||||
"""进程级共享术语表存储(单例)。"""
|
||||
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
self._initialized = True
|
||||
self.logger = global_logger
|
||||
self._path = _default_path()
|
||||
self._lock = asyncio.Lock()
|
||||
self._cache: list[dict] | None = None
|
||||
self.logger.info(f"术语表存储路径: {self._path}")
|
||||
|
||||
def _read_from_disk(self) -> list[dict]:
|
||||
if not self._path.exists():
|
||||
return []
|
||||
try:
|
||||
with self._path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
rows = data.get("rows", []) if isinstance(data, dict) else []
|
||||
return [_normalize_row(r) for r in rows]
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
self.logger.error(f"读取术语表失败,回退为空: {e}")
|
||||
return []
|
||||
|
||||
def _write_to_disk(self, rows: list[dict]) -> None:
|
||||
payload = {"version": _VERSION, "rows": rows}
|
||||
# 原子写:先写临时文件再替换
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
prefix="glossary_", suffix=".json", dir=str(self._path.parent)
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp_path, self._path)
|
||||
except Exception:
|
||||
if os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
raise
|
||||
|
||||
def get_rows(self) -> list[dict]:
|
||||
"""同步读取所有行(带进程内缓存)。"""
|
||||
if self._cache is None:
|
||||
self._cache = self._read_from_disk()
|
||||
return [dict(r) for r in self._cache]
|
||||
|
||||
async def save(self, rows: list[dict]) -> list[dict]:
|
||||
"""校验并原子保存全部行,返回保存后的规范化行。"""
|
||||
normalized = [_normalize_row(r) for r in rows]
|
||||
# 过滤掉三列全空的行
|
||||
normalized = [r for r in normalized if any(r[c] for c in _COLS)]
|
||||
async with self._lock:
|
||||
self._write_to_disk(normalized)
|
||||
self._cache = [dict(r) for r in normalized]
|
||||
return [dict(r) for r in normalized]
|
||||
|
||||
async def reload(self) -> list[dict]:
|
||||
"""强制从磁盘重新加载(调试/外部编辑后)。"""
|
||||
async with self._lock:
|
||||
self._cache = self._read_from_disk()
|
||||
return [dict(r) for r in self._cache]
|
||||
|
||||
|
||||
def get_glossary_store() -> GlossaryStore:
|
||||
"""获取全局单例。"""
|
||||
return GlossaryStore()
|
||||
123
docutranslate/glossary/lang_mapping.py
Normal file
123
docutranslate/glossary/lang_mapping.py
Normal file
@@ -0,0 +1,123 @@
|
||||
# SPDX-FileCopyrightText: 2025 QinHan
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""
|
||||
三语术语表(中文/English/Bahasa)与单向 {src: dst} 字典的互转,
|
||||
以及与 CSV 的导入/导出。
|
||||
|
||||
列约定:zh / en / id
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
# 目标语言 -> 列名 的映射(小写匹配)
|
||||
_LANG_ALIASES = {
|
||||
# 中文(简/繁)
|
||||
"zh": "zh",
|
||||
"zh-cn": "zh",
|
||||
"zh-hans": "zh",
|
||||
"zh-hant": "zh",
|
||||
"chinese": "zh",
|
||||
"simplified chinese": "zh",
|
||||
"traditional chinese": "zh",
|
||||
"中文": "zh",
|
||||
"简体中文": "zh",
|
||||
"繁体中文": "zh",
|
||||
"汉语": "zh",
|
||||
# 英文
|
||||
"en": "en",
|
||||
"en-us": "en",
|
||||
"en-gb": "en",
|
||||
"english": "en",
|
||||
"英文": "en",
|
||||
"英语": "en",
|
||||
# 印尼语
|
||||
"id": "id",
|
||||
"id-id": "id",
|
||||
"indonesian": "id",
|
||||
"bahasa indonesia": "id",
|
||||
"印尼文": "id",
|
||||
"印尼语": "id",
|
||||
"印度尼西亚语": "id",
|
||||
}
|
||||
|
||||
_COLS = ("zh", "en", "id")
|
||||
|
||||
|
||||
def lang_to_col(to_lang: str | None) -> str | None:
|
||||
"""目标语言字符串 -> 列名 'zh'|'en'|'id',无法识别返回 None。"""
|
||||
if not to_lang:
|
||||
return None
|
||||
key = str(to_lang).strip().lower()
|
||||
return _LANG_ALIASES.get(key)
|
||||
|
||||
|
||||
def build_glossary_dict(rows: list[dict], to_lang: str | None) -> dict[str, str] | None:
|
||||
"""
|
||||
将三列 rows 编译为单向 {src: dst} 字典。
|
||||
|
||||
规则:目标列作为 dst,其它两列作为 src,覆盖任意源语言命中文本。
|
||||
返回 None 表示目标语言不受支持(不注入术语表,安全降级)。
|
||||
"""
|
||||
target_col = lang_to_col(to_lang)
|
||||
if target_col is None or not rows:
|
||||
return None
|
||||
result: dict[str, str] = {}
|
||||
for raw in rows:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
target = str(raw.get(target_col, "") or "").strip()
|
||||
if not target:
|
||||
continue
|
||||
for col in _COLS:
|
||||
if col == target_col:
|
||||
continue
|
||||
src = str(raw.get(col, "") or "").strip()
|
||||
if src and src not in result:
|
||||
result[src] = target
|
||||
return result or None
|
||||
|
||||
|
||||
def rows_to_csv(rows: list[dict]) -> str:
|
||||
"""三列 rows -> CSV 字符串(含 BOM,表头 zh,en,id)。"""
|
||||
buf = io.StringIO()
|
||||
buf.write("") # UTF-8 BOM,便于 Excel 正确识别
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["zh", "en", "id"])
|
||||
for raw in rows:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
writer.writerow([str(raw.get(c, "") or "") for c in _COLS])
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def csv_to_rows(parsed: list[dict] | list[list]) -> list[dict]:
|
||||
"""
|
||||
解析后的 CSV(PapaParse 结果)-> rows。
|
||||
|
||||
支持三种格式:
|
||||
- 3 列表头 zh/en/id
|
||||
- 旧 2 列表头 src/dst
|
||||
- 无表头的 2/3 列数组(兜底按 [zh,en] 或 [zh,en,id] 处理)
|
||||
"""
|
||||
rows: list[dict] = []
|
||||
for item in parsed:
|
||||
if isinstance(item, dict):
|
||||
# 有表头
|
||||
if any(c in item for c in _COLS):
|
||||
rows.append({c: str(item.get(c, "") or "").strip() for c in _COLS})
|
||||
elif "src" in item and "dst" in item:
|
||||
# 旧 2 列格式:无法判断语言,放入 en 列作 src、zh 列作 dst(最常见的英译中场景)
|
||||
rows.append({"zh": str(item.get("dst", "") or "").strip(),
|
||||
"en": str(item.get("src", "") or "").strip(),
|
||||
"id": ""})
|
||||
else:
|
||||
continue
|
||||
elif isinstance(item, list):
|
||||
cells = [str(x or "").strip() for x in item]
|
||||
if len(cells) >= 3:
|
||||
rows.append({"zh": cells[0], "en": cells[1], "id": cells[2]})
|
||||
elif len(cells) == 2:
|
||||
rows.append({"zh": cells[0], "en": cells[1], "id": ""})
|
||||
# 过滤全空行
|
||||
return [r for r in rows if any(r[c] for c in _COLS)]
|
||||
@@ -107,7 +107,7 @@
|
||||
"tpmLabel": "每分钟Token数(估算)",
|
||||
"unlimitedPlaceholder": "留空为无限制",
|
||||
"glossaryGenTitle": "术语表",
|
||||
"glossaryLabel": "术语表 (可选)",
|
||||
"glossaryLabel": "术语表(全公司共享)",
|
||||
"glossaryHelp": "选择一个或多个CSV文件。文件需包含'src'和'dst'两列标题,分别代表原文和译文。",
|
||||
"viewGlossaryBtn": "查看术语表",
|
||||
"clearGlossaryBtn": "清空",
|
||||
@@ -174,7 +174,18 @@
|
||||
"preview_translatedOnly": "仅译文预览",
|
||||
"configImportSuccess": "配置导入成功!",
|
||||
"configImportError": "配置文件解析失败,请检查文件格式。",
|
||||
"providerLabel": "接口供应商"
|
||||
"providerLabel": "接口供应商",
|
||||
"glossarySharedHint": "此术语表全公司共享,保存后对所有用户生效。翻译时按目标语言自动取对应列。",
|
||||
"glossaryColZh": "中文",
|
||||
"glossaryColEn": "English",
|
||||
"glossaryColId": "Bahasa Indonesia",
|
||||
"glossaryDeleteRow": "删除此行",
|
||||
"glossaryAddRow": "添加术语",
|
||||
"glossarySave": "保存",
|
||||
"glossaryExportCsv": "导出CSV",
|
||||
"glossaryUnsaved": "未保存",
|
||||
"glossarySaved": "术语表已保存,对所有用户生效。",
|
||||
"glossarySaveFailed": "保存失败"
|
||||
},
|
||||
"en": {
|
||||
"pageTitle": "DocuTranslate - Interactive Document Translation",
|
||||
@@ -284,7 +295,7 @@
|
||||
"tpmLabel": "Tokens Per Minute(Estimated)",
|
||||
"unlimitedPlaceholder": "Leave empty for unlimited",
|
||||
"glossaryGenTitle": "Glossary",
|
||||
"glossaryLabel": "Glossary (Optional)",
|
||||
"glossaryLabel": "Glossary (shared company-wide)",
|
||||
"glossaryHelp": "Select one or more CSV files. Files must contain 'src' and 'dst' headers, representing source and destination terms.",
|
||||
"viewGlossaryBtn": "View Glossary",
|
||||
"clearGlossaryBtn": "Clear",
|
||||
@@ -351,7 +362,18 @@
|
||||
"preview_translatedOnly": "Translation Only Preview",
|
||||
"configImportSuccess": "Configuration imported successfully!",
|
||||
"configImportError": "Failed to parse config file, please check the file format.",
|
||||
"providerLabel": "Provider"
|
||||
"providerLabel": "Provider",
|
||||
"glossarySharedHint": "This glossary is shared company-wide. Saved changes apply to all users. The target language column is used automatically during translation.",
|
||||
"glossaryColZh": "中文",
|
||||
"glossaryColEn": "English",
|
||||
"glossaryColId": "Bahasa Indonesia",
|
||||
"glossaryDeleteRow": "Delete row",
|
||||
"glossaryAddRow": "Add term",
|
||||
"glossarySave": "Save",
|
||||
"glossaryExportCsv": "Export CSV",
|
||||
"glossaryUnsaved": "Unsaved",
|
||||
"glossarySaved": "Glossary saved and applied to all users.",
|
||||
"glossarySaveFailed": "Save failed"
|
||||
},
|
||||
"id": {
|
||||
"pageTitle": "DocuTranslate - Terjemahan Dokumen Interaktif",
|
||||
@@ -461,7 +483,7 @@
|
||||
"tpmLabel": "Token per Menit (TPM - Estimasi)",
|
||||
"unlimitedPlaceholder": "Kosongkan untuk tanpa batas",
|
||||
"glossaryGenTitle": "Glosarium",
|
||||
"glossaryLabel": "Glosarium (Opsional)",
|
||||
"glossaryLabel": "Glosarium (dibagikan ke seluruh perusahaan)",
|
||||
"glossaryHelp": "Pilih file CSV dengan kolom src dan dst.",
|
||||
"viewGlossaryBtn": "Lihat Glosarium",
|
||||
"clearGlossaryBtn": "Hapus",
|
||||
@@ -528,6 +550,17 @@
|
||||
"preview_translatedOnly": "Pratinjau Hanya Terjemahan",
|
||||
"configImportSuccess": "Konfigurasi berhasil diimpor!",
|
||||
"configImportError": "Gagal memproses file konfigurasi.",
|
||||
"providerLabel": "Provider"
|
||||
"providerLabel": "Provider",
|
||||
"glossarySharedHint": "Glosarium ini dibagikan ke seluruh perusahaan. Perubahan tersimpan berlaku untuk semua pengguna. Kolom bahasa target digunakan otomatis saat menerjemahkan.",
|
||||
"glossaryColZh": "中文",
|
||||
"glossaryColEn": "English",
|
||||
"glossaryColId": "Bahasa Indonesia",
|
||||
"glossaryDeleteRow": "Hapus baris",
|
||||
"glossaryAddRow": "Tambah istilah",
|
||||
"glossarySave": "Simpan",
|
||||
"glossaryExportCsv": "Ekspor CSV",
|
||||
"glossaryUnsaved": "Belum disimpan",
|
||||
"glossarySaved": "Glosarium disimpan, berlaku untuk semua pengguna.",
|
||||
"glossarySaveFailed": "Gagal menyimpan"
|
||||
}
|
||||
}
|
||||
@@ -848,31 +848,50 @@
|
||||
|
||||
<!-- Modals -->
|
||||
<div class="modal fade" id="glossaryModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h5 class="modal-title">{{ t('glossaryModalTitle') }}</h5>
|
||||
<div class="modal-header"><h5 class="modal-title">{{ t('glossaryModalTitle') }}
|
||||
<span v-if="glossaryDirty" class="badge bg-warning ms-2">{{ t('glossaryUnsaved') }}</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<table class="table table-striped table-hover">
|
||||
<div class="alert alert-info py-2 small mb-2">{{ t('glossarySharedHint') }}</div>
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('glossaryTableSource') }}</th>
|
||||
<th>{{ t('glossaryTableDestination') }}</th>
|
||||
<th style="width:32%">{{ t('glossaryColZh') }}</th>
|
||||
<th style="width:32%">{{ t('glossaryColEn') }}</th>
|
||||
<th style="width:32%">{{ t('glossaryColId') }}</th>
|
||||
<th style="width:4%"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(dst, src) in glossaryData" :key="src">
|
||||
<td>{{ src }}</td>
|
||||
<td>{{ dst }}</td>
|
||||
<tr v-for="(row, i) in glossaryRows" :key="i">
|
||||
<td><input class="form-control form-control-sm" v-model="row.zh" @input="glossaryDirty=true"></td>
|
||||
<td><input class="form-control form-control-sm" v-model="row.en" @input="glossaryDirty=true"></td>
|
||||
<td><input class="form-control form-control-sm" v-model="row.id" @input="glossaryDirty=true"></td>
|
||||
<td class="text-center">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger py-0 px-1"
|
||||
@click="deleteGlossaryRow(i)" :title="t('glossaryDeleteRow')"><i class="bi bi-x-lg"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="Object.keys(glossaryData).length === 0">
|
||||
<td colspan="2" class="text-center text-muted">{{ t('glossaryEmpty') }}</td>
|
||||
<tr v-if="glossaryRows.length === 0">
|
||||
<td colspan="4" class="text-center text-muted">{{ t('glossaryEmpty') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="exportGlossaryCsv">
|
||||
<i class="bi bi-download me-1"></i>{{ t('glossaryExportCsv') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary" @click="addGlossaryRow">
|
||||
<i class="bi bi-plus-lg me-1"></i>{{ t('glossaryAddRow') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" @click="saveGlossary" :disabled="!glossaryDirty">
|
||||
<i class="bi bi-save me-1"></i>{{ t('glossarySave') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('closeBtn') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1041,6 +1060,9 @@
|
||||
const currentLang = ref(localStorage.getItem('ui_language') || detectBrowserLang());
|
||||
const i18nData = ref({});
|
||||
const glossaryData = ref({});
|
||||
const glossaryRows = ref([]); // 共享术语表三列行 [{zh,en,id}]
|
||||
const glossaryDirty = ref(false); // 是否有未保存的编辑
|
||||
const glossarySaving = ref(false);
|
||||
const tasks = ref([]);
|
||||
const enginList = ref([]);
|
||||
const defaultParams = reactive({});
|
||||
@@ -1330,7 +1352,95 @@
|
||||
showFormula: ['mineru', 'docling'].includes(form.convert_engine),
|
||||
showCode: form.convert_engine === 'docling'
|
||||
}));
|
||||
const glossaryCount = computed(() => Object.keys(glossaryData.value).length);
|
||||
const glossaryCount = computed(() => glossaryRows.value.length);
|
||||
|
||||
// --- 三列术语表(中文/English/Bahasa)本地工具函数 ---
|
||||
const LANG_COL_ALIASES = {
|
||||
'zh':'zh','zh-cn':'zh','zh-hans':'zh','zh-hant':'zh','chinese':'zh',
|
||||
'simplified chinese':'zh','traditional chinese':'zh','中文':'zh','简体中文':'zh','繁体中文':'zh','汉语':'zh',
|
||||
'en':'en','en-us':'en','en-gb':'en','english':'en','英文':'en','英语':'en',
|
||||
'id':'id','id-id':'id','indonesian':'id','bahasa indonesia':'id','印尼文':'id','印尼语':'id','印度尼西亚语':'id'
|
||||
};
|
||||
const langToCol = (lang) => {
|
||||
if (!lang) return null;
|
||||
return LANG_COL_ALIASES[String(lang).trim().toLowerCase()] || null;
|
||||
};
|
||||
// 根据当前目标语言,把三列行编译成 {src: dst}(供翻译引擎消费,契约与旧 glossaryData 一致)
|
||||
const buildGlossaryDict = () => {
|
||||
const targetCol = langToCol(form.to_lang === 'custom' ? form.custom_to_lang : form.to_lang);
|
||||
if (!targetCol || !glossaryRows.value.length) return null;
|
||||
const result = {};
|
||||
for (const row of glossaryRows.value) {
|
||||
const target = (row[targetCol] || '').trim();
|
||||
if (!target) continue;
|
||||
for (const col of ['zh','en','id']) {
|
||||
if (col === targetCol) continue;
|
||||
const src = (row[col] || '').trim();
|
||||
if (src && !(src in result)) result[src] = target;
|
||||
}
|
||||
}
|
||||
return Object.keys(result).length ? result : null;
|
||||
};
|
||||
|
||||
const addGlossaryRow = () => {
|
||||
glossaryRows.value.push({zh:'', en:'', id:''});
|
||||
glossaryDirty.value = true;
|
||||
};
|
||||
const deleteGlossaryRow = (i) => {
|
||||
glossaryRows.value.splice(i, 1);
|
||||
glossaryDirty.value = true;
|
||||
};
|
||||
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||'')
|
||||
})) : [];
|
||||
glossaryDirty.value = false;
|
||||
} catch (e) {
|
||||
console.error('Failed to load shared glossary', e);
|
||||
}
|
||||
};
|
||||
const saveGlossary = async () => {
|
||||
if (glossarySaving.value) return;
|
||||
glossarySaving.value = true;
|
||||
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'},
|
||||
body: JSON.stringify({rows})
|
||||
});
|
||||
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||'')
|
||||
}));
|
||||
glossaryDirty.value = false;
|
||||
alert(t('glossarySaved'));
|
||||
} catch (e) {
|
||||
alert(t('glossarySaveFailed') + ': ' + e.message);
|
||||
} finally {
|
||||
glossarySaving.value = false;
|
||||
}
|
||||
};
|
||||
const exportGlossaryCsv = () => {
|
||||
const rows = glossaryRows.value;
|
||||
const esc = (s) => {
|
||||
s = String(s||'');
|
||||
return /[",\n]/.test(s) ? '"' + s.replace(/"/g,'""') + '"' : s;
|
||||
};
|
||||
let csv = 'zh,en,id\n';
|
||||
rows.forEach(r => { csv += [esc(r.zh), esc(r.en), esc(r.id)].join(',') + '\n'; });
|
||||
const blob = new Blob([csv], {type: 'text/csv;charset=utf-8'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = 'glossary.csv'; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleGlossaryFiles = (e) => {
|
||||
const files = e.target.files;
|
||||
@@ -1339,17 +1449,29 @@
|
||||
Papa.parse(f, {
|
||||
header: true, skipEmptyLines: true,
|
||||
complete: (res) => {
|
||||
if (res.data) res.data.forEach(r => {
|
||||
if (r.src && r.dst) glossaryData.value[r.src.trim()] = r.dst.trim();
|
||||
if (!res.data) return;
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
const clearGlossary = () => {
|
||||
glossaryData.value = {};
|
||||
if (glossaryInput.value) glossaryInput.value.value = '';
|
||||
};
|
||||
const clearGlossary = async () => {
|
||||
// 清空当前编辑并从服务端重新拉取(避免误删共享数据)
|
||||
await loadGlossaryFromServer();
|
||||
};
|
||||
const openGlossaryModal = () => new bootstrap.Modal(document.getElementById('glossaryModal')).show();
|
||||
|
||||
// Task Management
|
||||
@@ -1435,7 +1557,7 @@
|
||||
temperature: Number(form.temperature),
|
||||
retry: Number(form.retry),
|
||||
custom_prompt: emptyToNull(form.custom_prompt),
|
||||
glossary_dict: Object.keys(glossaryData.value).length ? glossaryData.value : null,
|
||||
glossary_dict: buildGlossaryDict(),
|
||||
system_proxy_enable: form.system_proxy_enable,
|
||||
force_json: form.force_json,
|
||||
workflow_type: form.workflow_type,
|
||||
@@ -1985,6 +2107,9 @@
|
||||
loadConfig();
|
||||
setTheme(localStorage.getItem('theme') || 'auto');
|
||||
|
||||
// Load shared trilingual glossary (全公司通用,默认引用)
|
||||
await loadGlossaryFromServer();
|
||||
|
||||
// Restore tasks
|
||||
if (window.location.pathname.includes('/admin')) {
|
||||
document.title = "DocuTranslate - Admin Panel";
|
||||
@@ -2011,12 +2136,13 @@
|
||||
});
|
||||
|
||||
return {
|
||||
version, currentLang, i18nData, glossaryData, glossaryCount, tasks, enginList, defaultParams,
|
||||
version, currentLang, i18nData, glossaryData, glossaryCount, glossaryRows, glossaryDirty, glossarySaving, tasks, enginList, defaultParams,
|
||||
modelPresets,
|
||||
form, workflowParams, showMineruToken, previewMode, syncScrollEnabled, showIdentityOption,
|
||||
errors, clearError,
|
||||
t, createNewTask, removeTask, handleTaskFileSelect, handleTaskFileDrop, toggleTaskState,
|
||||
handleGlossaryFiles, clearGlossary, openGlossaryModal,
|
||||
addGlossaryRow, deleteGlossaryRow, saveGlossary, exportGlossaryCsv,
|
||||
openPreview, setPreviewMode, toggleSyncScroll, printPdf, copyLog, getFileIcon,
|
||||
exportConfig, importConfig, setLang, setTheme, capitalize,
|
||||
currentWorkflowConfig, ocrOptions, stepMap,
|
||||
|
||||
Reference in New Issue
Block a user