feat(editor): add live glossary editing

This commit is contained in:
xin61
2026-07-07 17:38:47 +08:00
parent 9b5c55688a
commit 8080a4ced9
7 changed files with 752 additions and 56 deletions
+195 -4
View File
@@ -16,6 +16,9 @@ let sidePanelMode = null;
let searchScope = "current";
let gptConfigLoaded = false;
let gptCandidateHtml = "";
let glossaryLoaded = false;
let glossaryEntries = [];
let glossaryDirty = false;
const $ = (id) => document.getElementById(id);
@@ -92,7 +95,18 @@ const dom = {
gptBaseUrlInput: $("gptBaseUrlInput"),
gptModelInput: $("gptModelInput"),
gptApiKeyInput: $("gptApiKeyInput"),
glossaryPathInput: $("glossaryPathInput"),
gptTranslationPromptInput: $("gptTranslationPromptInput"),
gptFormatPromptInput: $("gptFormatPromptInput"),
gptCharacterPromptInput: $("gptCharacterPromptInput"),
glossaryMeta: $("glossaryMeta"),
glossarySearchInput: $("glossarySearchInput"),
reloadGlossaryBtn: $("reloadGlossaryBtn"),
addGlossaryEntryBtn: $("addGlossaryEntryBtn"),
saveGlossaryBtn: $("saveGlossaryBtn"),
glossaryList: $("glossaryList"),
saveGptConfigBtn: $("saveGptConfigBtn"),
resetGptPromptsBtn: $("resetGptPromptsBtn"),
gptInstructionInput: $("gptInstructionInput"),
retranslateBtn: $("retranslateBtn"),
applyRetranslationBtn: $("applyRetranslationBtn"),
@@ -251,10 +265,16 @@ function resetReviewState() {
searchScope = "current";
gptCandidateHtml = "";
gptConfigLoaded = false;
glossaryLoaded = false;
glossaryEntries = [];
glossaryDirty = false;
dom.showTouchedBtn.classList.remove("primary");
dom.searchScopeCurrentBtn.classList.add("active");
dom.searchScopeBookBtn.classList.remove("active");
dom.searchInput.value = "";
if (dom.glossarySearchInput) dom.glossarySearchInput.value = "";
if (dom.glossaryList) dom.glossaryList.innerHTML = "";
if (dom.glossaryMeta) dom.glossaryMeta.textContent = "未读取术语表";
dom.sidebar.hidden = true;
dom.tocPanel.hidden = true;
dom.searchPanel.hidden = true;
@@ -1360,16 +1380,29 @@ function renderGptConfig(config) {
dom.gptBaseUrlInput.value = config.base_url || "https://api.openai.com/v1";
dom.gptModelInput.value = config.model || "gpt-4o-mini";
dom.gptApiKeyInput.value = "";
dom.glossaryPathInput.value = config.glossary_path || "";
dom.gptTranslationPromptInput.value = config.translation_prompt || (config.prompt_defaults && config.prompt_defaults.translation_prompt) || "";
dom.gptFormatPromptInput.value = config.format_prompt || (config.prompt_defaults && config.prompt_defaults.format_prompt) || "";
dom.gptCharacterPromptInput.value = config.character_prompt || (config.prompt_defaults && config.prompt_defaults.character_prompt) || "";
dom.resetGptPromptsBtn.dataset.translationPrompt = (config.prompt_defaults && config.prompt_defaults.translation_prompt) || "";
dom.resetGptPromptsBtn.dataset.formatPrompt = (config.prompt_defaults && config.prompt_defaults.format_prompt) || "";
dom.resetGptPromptsBtn.dataset.characterPrompt = (config.prompt_defaults && config.prompt_defaults.character_prompt) || "";
const source = config.key_source === "env" ? "环境变量" : config.key_source === "config" ? "本地配置" : "未配置";
dom.gptStatus.textContent = config.configured ? `已配置 · ${source} · ${config.model}` : "未配置 API Key,保存设置后可重翻。";
}
async function loadGptConfig(force = false) {
if (gptConfigLoaded && !force) return;
if (gptConfigLoaded && !force) {
if (!glossaryLoaded) loadGlossary(false).catch(() => {});
return;
}
try {
const config = await api("/api/gpt/config");
renderGptConfig(config);
gptConfigLoaded = true;
loadGlossary(force).catch((error) => {
dom.glossaryMeta.textContent = `读取术语表失败:${error.message}`;
});
} catch (error) {
dom.gptStatus.textContent = `读取 GPT 配置失败:${error.message}`;
}
@@ -1383,17 +1416,149 @@ async function saveGptConfig() {
base_url: dom.gptBaseUrlInput.value,
model: dom.gptModelInput.value,
api_key: dom.gptApiKeyInput.value,
glossary_path: dom.glossaryPathInput.value,
translation_prompt: dom.gptTranslationPromptInput.value,
format_prompt: dom.gptFormatPromptInput.value,
character_prompt: dom.gptCharacterPromptInput.value,
keep_existing: true,
}),
});
renderGptConfig(config);
gptConfigLoaded = true;
toast("GPT API 设置已保存。");
glossaryLoaded = false;
await loadGlossary(true);
toast("GPT 提示词与 API 设置已保存。");
} catch (error) {
toast(`保存 GPT 设置失败:${error.message}`, 7000);
}
}
function glossaryFilteredEntries() {
const q = (dom.glossarySearchInput.value || "").trim().toLowerCase();
if (!q) return glossaryEntries.map((entry, index) => ({ entry, index }));
return glossaryEntries
.map((entry, index) => ({ entry, index }))
.filter(({ entry }) => {
return [entry.source, entry.target, entry.clean_target]
.join("\n")
.toLowerCase()
.includes(q);
});
}
function updateGlossaryMeta(shownCount = null, matchCount = null) {
const matches = shownCount === null || matchCount === null ? glossaryFilteredEntries() : null;
const shown = shownCount === null ? Math.min(80, matches.length) : shownCount;
const matched = matchCount === null ? matches.length : matchCount;
const path = dom.glossaryPathInput.value || "未设置";
const dirtyText = glossaryDirty ? " · 有未保存修改" : "";
dom.glossaryMeta.textContent = `当前术语表:${path} · ${glossaryEntries.length} 条 · 显示 ${shown}/${matched}${dirtyText}`;
}
function setGlossaryDirty(value) {
glossaryDirty = value;
dom.saveGlossaryBtn.disabled = !glossaryDirty;
if (glossaryLoaded) updateGlossaryMeta();
}
function renderGlossary(payload = null) {
if (payload) {
glossaryEntries = (payload.entries || []).map((entry) => ({
source: entry.source || "",
target: entry.target || "",
clean_target: entry.clean_target || "",
}));
glossaryLoaded = true;
setGlossaryDirty(false);
if (payload.path) dom.glossaryPathInput.value = payload.path;
}
const matches = glossaryFilteredEntries();
const shown = matches.slice(0, 80);
updateGlossaryMeta(shown.length, matches.length);
dom.glossaryList.innerHTML = "";
if (!glossaryLoaded) {
dom.glossaryList.innerHTML = '<div class="glossaryEmpty">展开后会读取术语表。</div>';
return;
}
if (!shown.length) {
dom.glossaryList.innerHTML = '<div class="glossaryEmpty">没有匹配的术语。</div>';
return;
}
const frag = document.createDocumentFragment();
shown.forEach(({ entry, index }) => {
const row = document.createElement("div");
row.className = "glossaryRow";
row.dataset.index = String(index);
row.innerHTML = `
<input class="glossarySource" type="text" value="${escapeHtml(entry.source)}" aria-label="术语原文">
<input class="glossaryTarget" type="text" value="${escapeHtml(entry.target)}" aria-label="术语译名">
<button class="glossaryDelete" type="button" aria-label="删除术语">删除</button>
`;
row.querySelector(".glossarySource").addEventListener("input", (event) => {
glossaryEntries[index].source = event.target.value;
setGlossaryDirty(true);
});
row.querySelector(".glossaryTarget").addEventListener("input", (event) => {
glossaryEntries[index].target = event.target.value;
glossaryEntries[index].clean_target = event.target.value.split("#", 1)[0].trim();
setGlossaryDirty(true);
});
row.querySelector(".glossaryDelete").addEventListener("click", () => {
glossaryEntries.splice(index, 1);
setGlossaryDirty(true);
renderGlossary();
});
frag.appendChild(row);
});
dom.glossaryList.appendChild(frag);
}
async function loadGlossary(force = false) {
if (glossaryLoaded && !force) return;
const payload = await api("/api/glossary");
renderGlossary(payload);
}
function addGlossaryEntry() {
glossaryEntries.unshift({ source: "", target: "", clean_target: "" });
dom.glossarySearchInput.value = "";
glossaryLoaded = true;
setGlossaryDirty(true);
renderGlossary();
requestAnimationFrame(() => dom.glossaryList.querySelector(".glossarySource")?.focus());
}
async function saveGlossary() {
try {
const payload = await api("/api/glossary", {
method: "POST",
body: JSON.stringify({
path: dom.glossaryPathInput.value,
entries: glossaryEntries,
}),
});
renderGlossary(payload);
toast("术语表已保存,下一次重翻会实时使用最新术语。");
} catch (error) {
toast(`保存术语表失败:${error.message}`, 9000);
}
}
function glossaryMatchedText(data) {
const matches = data.glossary_matches || [];
if (!matches.length) return "";
return `<div class="gptCandidateMeta">术语命中:${escapeHtml(matches.slice(0, 10).join(" "))}${matches.length > 10 ? `${matches.length}` : ""}</div>`;
}
function resetGptPrompts() {
dom.gptTranslationPromptInput.value = dom.resetGptPromptsBtn.dataset.translationPrompt || "";
dom.gptFormatPromptInput.value = dom.resetGptPromptsBtn.dataset.formatPrompt || "";
dom.gptCharacterPromptInput.value = dom.resetGptPromptsBtn.dataset.characterPrompt || "";
toast("已恢复预设提示词,保存后生效。");
}
async function retranslateCurrentRow() {
if (!currentRow) return;
if (quickDirty) {
@@ -1413,7 +1578,7 @@ async function retranslateCurrentRow() {
}),
});
gptCandidateHtml = data.candidate_html || "";
dom.gptCandidate.innerHTML = gptCandidateHtml || escapeHtml(data.candidate_text || "");
dom.gptCandidate.innerHTML = `${glossaryMatchedText(data)}${gptCandidateHtml || escapeHtml(data.candidate_text || "")}`;
dom.applyRetranslationBtn.disabled = !gptCandidateHtml;
toast("已生成重翻候选,可确认后应用。");
} catch (error) {
@@ -1610,9 +1775,35 @@ function initEvents() {
dom.quickMarkSelectionBtn.addEventListener("click", markQuickSelection);
dom.toggleGptConfigBtn.addEventListener("click", () => {
dom.gptConfig.hidden = !dom.gptConfig.hidden;
if (!dom.gptConfig.hidden) loadGptConfig(true).catch(() => {});
if (!dom.gptConfig.hidden) {
loadGptConfig(true).catch(() => {});
}
});
dom.saveGptConfigBtn.addEventListener("click", saveGptConfig);
dom.resetGptPromptsBtn.addEventListener("click", resetGptPrompts);
dom.reloadGlossaryBtn.addEventListener("click", () => {
if (glossaryDirty && !window.confirm("术语表有未保存修改,重新读取会丢失这些修改。继续吗?")) return;
loadGlossary(true)
.then(() => toast("术语表已重新读取。"))
.catch((error) => toast(`读取术语表失败:${error.message}`, 9000));
});
dom.addGlossaryEntryBtn.addEventListener("click", addGlossaryEntry);
dom.saveGlossaryBtn.addEventListener("click", saveGlossary);
dom.glossarySearchInput.addEventListener("input", () => renderGlossary());
dom.glossaryPathInput.addEventListener("change", async () => {
if (glossaryDirty && !window.confirm("术语表有未保存修改,切换路径会丢失这些修改。继续吗?")) return;
try {
await api("/api/glossary", {
method: "POST",
body: JSON.stringify({ path: dom.glossaryPathInput.value }),
});
glossaryLoaded = false;
await loadGlossary(true);
toast("已切换术语表路径。");
} catch (error) {
toast(`切换术语表失败:${error.message}`, 9000);
}
});
dom.retranslateBtn.addEventListener("click", retranslateCurrentRow);
dom.applyRetranslationBtn.addEventListener("click", applyRetranslation);
dom.quickOpenFullBtn.addEventListener("click", async () => {