feat(editor): add live glossary editing
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user