Files
docutranslate/docutranslate/static/index.html
2025-10-13 16:39:24 +08:00

1 line
212 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<html lang="zh-CN" data-bs-theme="auto">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title data-i18n-title="pageTitle">DocuTranslate - 交互式文档翻译</title>
<link rel="icon" href="/static/favicon.ico" type="image/x-icon">
<!-- Bootstrap CSS -->
<link href="/static/bootstrap.css" rel="stylesheet" crossorigin="anonymous">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="/static/bootstrap-icons.css">
<style>
body {
background-color: var(--bs-body-bg);
}
button:focus {
outline: none !important;
box-shadow: none !important;
}
.main-container {
display: flex;
flex-direction: column;
height: 100vh;
padding-top: 1rem;
padding-bottom: 1rem;
}
.settings-panel {
height: calc(100vh - 2rem);
overflow-y: auto;
padding-right: 15px; /* for scrollbar */
}
.task-area {
height: calc(100vh - 2rem);
overflow-y: auto;
}
.task-card {
transition: all 0.3s ease-in-out;
}
.task-id-placeholder {
color: var(--bs-secondary-color);
font-style: italic;
}
.log-area {
height: 150px;
background-color: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: .375rem;
font-family: monospace;
font-size: 0.8rem;
padding: 10px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
.file-drop-area {
border: 2px dashed var(--bs-secondary-bg);
border-radius: .375rem;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.2s ease-in-out;
background-color: var(--bs-body-bg);
}
.file-drop-area.drag-over {
border-color: var(--bs-primary);
background-color: var(--bs-secondary-bg);
}
.file-drop-area.file-selected {
border-style: solid;
border-color: var(--bs-success);
background-color: var(--bs-success-bg-subtle);
}
.file-drop-area.input-error {
border-color: var(--bs-danger);
}
.file-name-display.input-error-text {
color: var(--bs-danger);
font-weight: bold;
}
#printFrame, #translatedPreviewFrame {
border: none;
width: 100%;
}
#previewOffcanvas {
--bs-offcanvas-width: 95vw;
max-width: 1600px;
}
.preview-split-container {
display: flex;
flex-direction: row;
height: 90%;
}
.preview-pane-wrapper {
display: flex;
flex-direction: column;
overflow: hidden; /* Important for split.js */
}
.preview-pane-wrapper h6 {
flex-shrink: 0;
padding: 0.25rem;
}
.preview-pane-wrapper .preview-pane {
flex-grow: 1; /* Make the inner pane grow */
border: 1px solid var(--bs-border-color);
border-radius: .375rem;
overflow: auto;
}
.gutter {
background-color: var(--bs-tertiary-bg);
background-repeat: no-repeat;
background-position: 50%;
}
.gutter.gutter-horizontal {
cursor: col-resize;
border-left: 1px solid var(--bs-border-color);
border-right: 1px solid var(--bs-border-color);
}
.gutter.gutter-vertical {
cursor: row-resize;
border-top: 1px solid var(--bs-border-color);
border-bottom: 1px solid var(--bs-border-color);
}
.preview-pane iframe, .preview-pane pre {
width: 100%;
height: 95%;
border: none;
margin: 0;
padding: 0;
overflow: auto;
background-color: var(--bs-body-bg);
}
.slider-reset-btn {
visibility: hidden;
}
.bottom-left-controls {
position: fixed;
bottom: 1rem;
left: 1rem;
z-index: 1050;
display: flex;
gap: 0.5rem;
}
.project-info p {
letter-spacing: 0.8px;
line-height: 1.6;
}
/* [NEW] Style for the step number */
.step-number {
margin-right: 0.25rem;
}
@media (max-width: 991.98px) {
.main-container {
height: auto;
padding-bottom: 6rem;
}
.settings-panel, .task-area {
height: auto;
overflow-y: visible;
}
.settings-panel {
padding-right: 0;
margin-bottom: 2rem;
}
}
@media (max-width: 767.98px) {
.task-card .col-md-7 {
margin-top: 1.5rem;
}
#previewOffcanvas {
--bs-offcanvas-width: 100vw;
}
}
</style>
</head>
<body>
<div class="container-fluid main-container">
<div class="row gx-4">
<!-- Left: Settings Panel -->
<div class="col-lg-4">
<div class="settings-panel">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center">
<h4 class="mb-0 me-3 fw-bold">DocuTranslate</h4>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-info" data-bs-toggle="modal"
data-bs-target="#tutorialModal">
<i class="bi bi-question-circle-fill me-1"></i><span data-i18n="tutorialBtn">教程</span>
</button>
<button type="button" class="btn btn-sm btn-outline-warning" data-bs-toggle="modal"
data-bs-target="#contributorsModal">
<i class="bi bi-people-fill me-1"></i><span
data-i18n="projectContributeBtn">项目协作</span>
</button>
</div>
</div>
</div>
<form id="translateForm">
<div class="accordion" id="settingsAccordion">
<!-- Workflow Selection -->
<div class="accordion-item" id="workflowSettingsContainer">
<h2 class="accordion-header" id="headingZero">
<button class="accordion-button" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseZero" aria-expanded="true"
aria-controls="collapseZero">
<!-- MODIFIED HERE -->
<strong><i class="bi bi-diagram-3 me-2"></i><span
data-i18n="workflowTitle">选择工作流</span></strong>
</button>
</h2>
<div id="collapseZero" class="accordion-collapse collapse show"
aria-labelledby="headingZero">
<div class="accordion-body">
<select class="form-select" id="workflowTypeSelect">
<option value="markdown_based" data-i18n="workflowOptionMarkdown">转Markdown再翻译
(.pdf/.md/.png等)
</option>
<option value="txt" data-i18n="workflowOptionTxt">纯文本翻译 (.txt)</option>
<option value="epub" data-i18n="workflowOptionEpub">EPUB翻译 (.epub)</option>
<option value="docx" data-i18n="workflowOptionDocx">DOCX翻译 (.docx)</option>
<option value="xlsx" data-i18n="workflowOptionXlsx">XLSX翻译 (.xlsx/.csv)
</option>
<option value="srt" data-i18n="workflowOptionSrt">SRT字幕翻译 (.srt)</option>
<option value="ass" data-i18n="workflowOptionAss">ASS字幕翻译 (.ass)</option>
<option value="json" data-i18n="workflowOptionJson">JSON翻译 (.json)</option>
<option value="html" data-i18n="workflowOptionHtml">HTML翻译 (.html)</option>
</select>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" role="switch"
id="autoWorkflowSwitch">
<label class="form-check-label" for="autoWorkflowSwitch"
data-i18n="autoWorkflowLabel">自动选择工作流</label>
</div>
</div>
</div>
</div>
<!-- [NEW] TXT Settings Container -->
<div class="accordion-item" id="txtSettingsContainer" style="display: none;">
<h2 class="accordion-header" id="headingTxt">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseTxt" aria-expanded="false"
aria-controls="collapseTxt">
<!-- MODIFIED HERE -->
<strong id="txtSettingsTitle"><i class="bi bi-filetype-txt me-2"></i><span
data-i18n="txtSettingsTitleText">TXT翻译选项</span></strong>
</button>
</h2>
<div id="collapseTxt" class="accordion-collapse collapse" aria-labelledby="headingTxt">
<div class="accordion-body">
<div class="mb-3">
<label for="txt_insert_mode" class="form-label"
data-i18n="insertModeLabel">插入模式</label>
<select class="form-select" id="txt_insert_mode" name="insert_mode">
<option value="replace" data-i18n="insertModeReplace">替换原文 (Replace)
</option>
<option value="append" data-i18n="insertModeAppend">附加到原文后 (Append)
</option>
<option value="prepend" data-i18n="insertModePrepend">附加到原文前
(Prepend)
</option>
</select>
<div class="form-text" data-i18n="insertModeHelpTxt">
选择如何将翻译后的文本插入。
</div>
</div>
<div class="mb-3" id="txtSeparatorGroup" style="display: none;">
<label for="txt_separator" class="form-label"
data-i18n="separatorLabel">分隔符</label>
<input type="text" class="form-control" id="txt_separator" name="separator"
data-i18n-placeholder="separatorPlaceholderSimple"
placeholder="例如: \n---\n">
<div class="form-text" data-i18n="separatorHelp">
当插入模式为附加或前置时,用于分隔原文和译文的字符。<code>\n</code> 代表换行。
</div>
</div>
</div>
</div>
</div>
<!-- [NEW] DOCX Settings Container -->
<div class="accordion-item" id="docxSettingsContainer" style="display: none;">
<h2 class="accordion-header" id="headingDocx">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseDocx" aria-expanded="false"
aria-controls="collapseDocx">
<!-- MODIFIED HERE -->
<strong id="docxSettingsTitle"><i class="bi bi-file-earmark-word me-2"></i><span
data-i18n="docxSettingsTitleText">DOCX翻译选项</span></strong>
</button>
</h2>
<div id="collapseDocx" class="accordion-collapse collapse" aria-labelledby="headingDocx">
<div class="accordion-body">
<div class="mb-3">
<label for="docx_insert_mode" class="form-label" data-i18n="insertModeLabel">插入模式</label>
<select class="form-select" id="docx_insert_mode" name="insert_mode">
<option value="replace" data-i18n="insertModeReplace">替换原文 (Replace)
</option>
<option value="append" data-i18n="insertModeAppend">附加到原文后 (Append)
</option>
<option value="prepend" data-i18n="insertModePrepend">附加到原文前
(Prepend)
</option>
</select>
<div class="form-text" data-i18n="insertModeHelpDocx">
选择如何将翻译后的文本插入。
</div>
</div>
<div class="mb-3" id="docxSeparatorGroup" style="display: none;">
<label for="docx_separator" class="form-label"
data-i18n="separatorLabel">分隔符</label>
<input type="text" class="form-control" id="docx_separator" name="separator"
data-i18n-placeholder="separatorPlaceholder"
placeholder="例如: \n---翻译---\n">
<div class="form-text" data-i18n="separatorHelp">
当插入模式为附加或前置时,用于分隔原文和译文的字符。<code>\n</code> 代表换行。
</div>
</div>
</div>
</div>
</div>
<!-- XLSX Settings Container -->
<div class="accordion-item" id="xlsxSettingsContainer" style="display: none;">
<h2 class="accordion-header" id="headingXlsx">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseXlsx" aria-expanded="false"
aria-controls="collapseXlsx">
<!-- MODIFIED HERE -->
<strong id="xlsxSettingsTitle"><i
class="bi bi-file-earmark-spreadsheet me-2"></i><span
data-i18n="xlsxSettingsTitleText">XLSX翻译选项</span></strong>
</button>
</h2>
<div id="collapseXlsx" class="accordion-collapse collapse" aria-labelledby="headingXlsx">
<div class="accordion-body">
<div class="mb-3">
<label for="xlsx_insert_mode" class="form-label" data-i18n="insertModeLabel">插入模式</label>
<select class="form-select" id="xlsx_insert_mode" name="insert_mode">
<option value="replace" data-i18n="insertModeReplace">替换原文 (Replace)
</option>
<option value="append" data-i18n="insertModeAppend">附加到原文后 (Append)
</option>
<option value="prepend" data-i18n="insertModePrepend">附加到原文前
(Prepend)
</option>
</select>
<div class="form-text" data-i18n="insertModeHelpXlsx">
选择如何将翻译后的文本插入到单元格中。
</div>
</div>
<div class="mb-3" id="xlsxSeparatorGroup" style="display: none;">
<label for="xlsx_separator" class="form-label"
data-i18n="separatorLabel">分隔符</label>
<input type="text" class="form-control" id="xlsx_separator" name="separator"
data-i18n-placeholder="separatorPlaceholderSimple"
placeholder="例如: \n---\n">
<div class="form-text" data-i18n="separatorHelp">
当插入模式为附加或前置时,用于分隔原文和译文的字符。<code>\n</code> 代表换行。
</div>
</div>
<div class="mb-3">
<label for="xlsx_translate_regions" class="form-label"
data-i18n="xlsxTranslateRegionsLabel">翻译区域 (可选)</label>
<textarea class="form-control" id="xlsx_translate_regions"
name="translate_regions" rows="3"
data-i18n-placeholder="xlsxTranslateRegionsPlaceholder"
placeholder="每行一个区域, 例如:Sheet1!A1:B10不指定表名则对所有表生效"></textarea>
</div>
</div>
</div>
</div>
<!-- [NEW] SRT Settings Container -->
<div class="accordion-item" id="srtSettingsContainer" style="display: none;">
<h2 class="accordion-header" id="headingSrt">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseSrt" aria-expanded="false"
aria-controls="collapseSrt">
<!-- MODIFIED HERE -->
<strong id="srtSettingsTitle"><i class="bi bi-file-text me-2"></i><span
data-i18n="srtSettingsTitleText">SRT翻译选项</span></strong>
</button>
</h2>
<div id="collapseSrt" class="accordion-collapse collapse" aria-labelledby="headingSrt">
<div class="accordion-body">
<div class="mb-3">
<label for="srt_insert_mode" class="form-label"
data-i18n="insertModeLabel">插入模式</label>
<select class="form-select" id="srt_insert_mode" name="insert_mode">
<option value="replace" data-i18n="insertModeReplace">替换原文 (Replace)
</option>
<option value="append" data-i18n="insertModeAppend">附加到原文后 (Append)
</option>
<option value="prepend" data-i18n="insertModePrepend">附加到原文前
(Prepend)
</option>
</select>
<div class="form-text" data-i18n="insertModeHelpSrt">
选择如何将翻译后的文本插入。
</div>
</div>
<div class="mb-3" id="srtSeparatorGroup" style="display: none;">
<label for="srt_separator" class="form-label"
data-i18n="separatorLabel">分隔符</label>
<input type="text" class="form-control" id="srt_separator" name="separator"
data-i18n-placeholder="separatorPlaceholderSimple"
placeholder="例如: \n---\n">
<div class="form-text" data-i18n="separatorHelp">
当插入模式为附加或前置时,用于分隔原文和译文的字符。<code>\n</code> 代表换行。
</div>
</div>
</div>
</div>
</div>
<!-- [NEW] EPUB Settings Container -->
<div class="accordion-item" id="epubSettingsContainer" style="display: none;">
<h2 class="accordion-header" id="headingEpub">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseEpub" aria-expanded="false"
aria-controls="collapseEpub">
<!-- MODIFIED HERE -->
<strong id="epubSettingsTitle"><i class="bi bi-book me-2"></i><span
data-i18n="epubSettingsTitleText">EPUB翻译选项</span></strong>
</button>
</h2>
<div id="collapseEpub" class="accordion-collapse collapse" aria-labelledby="headingEpub">
<div class="accordion-body">
<div class="mb-3">
<label for="epub_insert_mode" class="form-label" data-i18n="insertModeLabel">插入模式</label>
<select class="form-select" id="epub_insert_mode" name="insert_mode">
<option value="replace" data-i18n="insertModeReplace">替换原文 (Replace)
</option>
<option value="append" data-i18n="insertModeAppend">附加到原文后 (Append)
</option>
<option value="prepend" data-i18n="insertModePrepend">附加到原文前
(Prepend)
</option>
</select>
<div class="form-text" data-i18n="insertModeHelpEpub">
选择如何将翻译后的文本插入。
</div>
</div>
<div class="mb-3" id="epubSeparatorGroup" style="display: none;">
<label for="epub_separator" class="form-label"
data-i18n="separatorLabel">分隔符</label>
<input type="text" class="form-control" id="epub_separator" name="separator"
data-i18n-placeholder="separatorPlaceholderSimple"
placeholder="例如: \n---\n">
<div class="form-text" data-i18n="separatorHelp">
当插入模式为附加或前置时,用于分隔原文和译文的字符。<code>\n</code> 代表换行。
</div>
</div>
</div>
</div>
</div>
<!-- [NEW] HTML Settings Container -->
<div class="accordion-item" id="htmlSettingsContainer" style="display: none;">
<h2 class="accordion-header" id="headingHtml">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseHtml" aria-expanded="false"
aria-controls="collapseHtml">
<!-- MODIFIED HERE -->
<strong id="htmlSettingsTitle"><i class="bi bi-filetype-html me-2"></i><span
data-i18n="htmlSettingsTitleText">HTML翻译选项</span></strong>
</button>
</h2>
<div id="collapseHtml" class="accordion-collapse collapse" aria-labelledby="headingHtml">
<div class="accordion-body">
<div class="mb-3">
<label for="html_insert_mode" class="form-label" data-i18n="insertModeLabel">插入模式</label>
<select class="form-select" id="html_insert_mode" name="insert_mode">
<option value="replace" data-i18n="insertModeReplace">替换原文 (Replace)
</option>
<option value="append" data-i18n="insertModeAppend">附加到原文后 (Append)
</option>
<option value="prepend" data-i18n="insertModePrepend">附加到原文前
(Prepend)
</option>
</select>
<div class="form-text" data-i18n="insertModeHelpHtml">
选择如何将翻译后的文本插入。
</div>
</div>
<div class="mb-3" id="htmlSeparatorGroup" style="display: none;">
<label for="html_separator" class="form-label"
data-i18n="separatorLabel">分隔符</label>
<input type="text" class="form-control" id="html_separator" name="separator"
data-i18n-placeholder="separatorPlaceholderSimple"
placeholder="例如: <!-- translated -->">
<div class="form-text" data-i18n="separatorHelp">
当插入模式为附加或前置时,用于分隔原文和译文的字符。<code>\n</code> 代表换行。
</div>
</div>
</div>
</div>
</div>
<div class="accordion-item" id="assSettingsContainer" style="display: none;">
<h2 class="accordion-header" id="headingAss">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseAss" aria-expanded="false"
aria-controls="collapseAss">
<strong id="assSettingsTitle"><i class="bi bi-file-easel me-2"></i><span
data-i18n="assSettingsTitleText">ASS翻译选项</span></strong>
</button>
</h2>
<div id="collapseAss" class="accordion-collapse collapse" aria-labelledby="headingAss">
<div class="accordion-body">
<div class="mb-3">
<label for="ass_insert_mode" class="form-label"
data-i18n="insertModeLabel">插入模式</label>
<select class="form-select" id="ass_insert_mode" name="insert_mode">
<option value="replace" data-i18n="insertModeReplace">替换原文 (Replace)
</option>
<option value="append" data-i18n="insertModeAppend">附加到原文后 (Append)
</option>
<option value="prepend" data-i18n="insertModePrepend">附加到原文前
(Prepend)
</option>
</select>
<div class="form-text" data-i18n="insertModeHelpAss">
选择如何将翻译后的文本插入。
</div>
</div>
<div class="mb-3" id="assSeparatorGroup" style="display: none;">
<label for="ass_separator" class="form-label"
data-i18n="separatorLabel">分隔符</label>
<input type="text" class="form-control" id="ass_separator" name="separator"
data-i18n-placeholder="separatorPlaceholderAss"
placeholder="例如: \N (换行符)">
<div class="form-text" data-i18n="separatorHelpAss">
当插入模式为附加或前置时,用于分隔原文和译文的字符。<code>\N</code>
是ASS格式的换行符。
</div>
</div>
</div>
</div>
</div>
<!-- JSON Paths Settings Container -->
<div class="accordion-item" id="jsonSettingsContainer" style="display: none;">
<h2 class="accordion-header" id="headingJson">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseJson" aria-expanded="false"
aria-controls="collapseJson">
<strong id="jsonSettingsTitle"><i class="bi bi-signpost-split me-2"></i><span
data-i18n="jsonSettingsTitleText">JSON路径配置</span></strong>
</button>
</h2>
<div id="collapseJson" class="accordion-collapse collapse" aria-labelledby="headingJson">
<div class="accordion-body">
<div class="mb-3">
<label for="json_paths_textarea" class="form-label" data-i18n="jsonPathLabel">需要翻译的JSON路径</label>
<textarea class="form-control" id="json_paths_textarea" name="json_paths"
rows="4" required
data-i18n-placeholder="jsonPathPlaceholder" placeholder="每行一个路径, 例如:
$.name
$.*"></textarea>
<div class="form-text" data-i18n="jsonPathHelp">
采用<code>jsonpath-ng</code>的路径选择语法每一行表示一个json路径。
将翻译路径匹配对象内的所有字符串
</div>
</div>
</div>
</div>
</div>
<!-- Parsing Engine Settings Container -->
<div class="accordion-item" id="parsingSettingsContainer">
<h2 class="accordion-header" id="headingOne">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseOne" aria-expanded="false" aria-controls="collapseOne">
<!-- MODIFIED HERE -->
<strong id="parsingSettingsTitle"><i
class="bi bi-file-earmark-binary me-2"></i><span
data-i18n="parsingSettingsTitleText">解析配置</span></strong>
</button>
</h2>
<div id="collapseOne" class="accordion-collapse collapse" aria-labelledby="headingOne">
<div class="accordion-body">
<div class="mb-3">
<label for="convert_engine" class="form-label" data-i18n="parsingEngineLabel">解析引擎</label>
<select class="form-select" id="convert_engine" name="convert_engine">
<!-- Options will be populated by JS -->
</select>
<div class="form-text" data-i18n="parsingEngineHelp">
如果上传的文件本身是.md格式此项可不选。
</div>
</div>
<div class="mb-3" id="mineruTokenGroup">
<label for="mineru_token" class="form-label">
Mineru Token
<a href="https://mineru.net/apiManage/token" target="_blank" class="ms-1"
data-i18n-title="getMineruTokenTitle" title="获取Mineru Token"><i
class="bi bi-box-arrow-up-right"></i></a>
</label>
<div class="input-group">
<input type="password" class="form-control" id="mineru_token"
name="mineru_token" data-i18n-placeholder="mineruTokenPlaceholder"
placeholder="使用Mineru引擎时需要">
<button class="btn btn-outline-secondary toggle-password" type="button"
data-target="mineru_token">
<i class="bi bi-eye-slash"></i>
</button>
</div>
</div>
<!-- NEW: Mineru Model Version -->
<div class="mb-3" id="mineruModelVersionGroup">
<label for="model_version" class="form-label" data-i18n="modelVersionLabel">Mineru
模型版本</label>
<select class="form-select" id="model_version" name="model_version">
<option value="vlm" data-i18n="modelVersionVlm">VLM</option>
<option value="pipeline" data-i18n="modelVersionPipline">Pipeline</option>
</select>
<div class="form-text" data-i18n="modelVersionHelp">
mineru VLM是更新的内测模型。
</div>
</div>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" role="switch"
id="formula_ocr"
name="formula_ocr" checked>
<label class="form-check-label" for="formula_ocr" data-i18n="formulaOcrLabel">公式识别</label>
</div>
<div class="form-check form-switch mb-2" id="codeOcrSwitch">
<input class="form-check-input" type="checkbox" role="switch" id="code_ocr"
name="code_ocr" checked>
<label class="form-check-label" for="code_ocr"
data-i18n="codeOcrLabel">代码识别</label>
</div>
</div>
</div>
</div>
<!-- AI Settings -->
<div class="accordion-item">
<h2 class="accordion-header" id="headingTwo">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo">
<!-- MODIFIED HERE -->
<strong id="aiSettingsTitle"><i class="bi bi-robot me-2"></i><span
data-i18n="aiSettingsTitleText">翻译模型</span></strong>
</button>
</h2>
<div id="collapseTwo" class="accordion-collapse collapse" aria-labelledby="headingTwo">
<div class="accordion-body">
<!-- [MODIFIED] Translation Mode to Checkbox -->
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch"
id="skipTranslationSwitch">
<label class="form-check-label" for="skipTranslationSwitch"
data-i18n="skipTranslationLabel">跳过翻译</label>
</div>
<!-- Wrapper for AI Model settings -->
<div id="aiModelSettingsContainer">
<div>
<label for="platform_select" class="form-label"
data-i18n="platformLabel">选择平台</label>
<select class="form-select" id="platform_select">
<option value="custom" data-i18n="platformCustom">自定义接口</option>
<option value="https://api.302.ai/v1">302.AI</option>
<option value="https://api.openai.com/v1">OpenAI</option>
<option value="https://generativelanguage.googleapis.com/v1beta/openai/">
Gemini
</option>
<option value="https://api.deepseek.com/v1">DeepSeek</option>
<option value="https://dashscope.aliyuncs.com/compatible-mode/v1">
阿里云百炼(DashScope)
</option>
<option value="https://ark.cn-beijing.volces.com/api/v3">火山引擎(volces)
</option>
<option value="https://api.siliconflow.cn/v1">硅基流动(siliconflow CN)
</option>
<option value="https://open.bigmodel.cn/api/paas/v4">智谱AI(bigmodel
CN)
</option>
<option value="https://www.dmxapi.cn/v1">DMXAPI_CN
</option>
<option value="https://www.dmxapi.com/v1">DMXAPI_GLOBAL
</option>
<option value="https://ai.juguang.chat/v1">聚光AI(juguang CN)
</option>
<option value="https://openrouter.ai/api/v1">OpenRouter</option>
<option value="http://127.0.0.1:1234/v1">LM Studio</option>
<option value="http://127.0.0.1:11434/v1">Ollama</option>
</select>
</div>
<div class="form-text mb-3" id="main_base_url_display_container">
Base URL: <code id="main_base_url_display"></code>
</div>
<div class="mb-3" id="baseUrlGroup">
<label for="base_url" class="form-label" data-i18n="baseUrlLabel">API 地址
(Base
URL)</label>
<input type="url" class="form-control" id="base_url" name="base_url"
required
data-i18n-placeholder="baseUrlPlaceholder"
placeholder="OpenAi兼容地址">
</div>
<div class="mb-3">
<label for="api_key" class="form-label">
API Key
<a href="#" target="_blank" class="ms-1" id="api_href"
data-i18n-title="getApiKeyTitle" title="获取API Key"><i
class="bi bi-box-arrow-up-right"></i></a>
<span class="ms-2 text-muted small" id="api_href_info"></span>
</label>
<div class="input-group">
<input type="password" class="form-control" id="api_key" name="api_key"
data-i18n-placeholder="apiKeyPlaceholder"
placeholder="请输入您的API Key">
<button class="btn btn-outline-secondary toggle-password" type="button"
data-target="api_key">
<i class="bi bi-eye-slash"></i>
</button>
</div>
</div>
<div class="mb-3">
<label for="model_id" class="form-label"
data-i18n="modelIdLabel">模型ID</label>
<input type="text" class="form-control" id="model_id" name="model_id"
required
data-i18n-placeholder="modelIdPlaceholder"
placeholder="例如: gpt-4o, glm-4">
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch"
id="system_proxy_enable" name="system_proxy_enable">
<label class="form-check-label" for="system_proxy_enable"
data-i18n="systemProxyLabel">启用系统代理</label>
</div>
</div>
</div>
</div>
</div>
<!-- Translation Settings -->
<div class="accordion-item" id="translationSettingsAccordionItem">
<h2 class="accordion-header" id="headingThree">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseThree" aria-expanded="false"
aria-controls="collapseThree">
<!-- MODIFIED HERE -->
<strong id="translationSettingsTitle"><i class="bi bi-translate me-2"></i><span
data-i18n="translationSettingsTitleText">翻译配置</span></strong>
</button>
</h2>
<div id="collapseThree" class="accordion-collapse collapse" aria-labelledby="headingThree">
<div class="accordion-body">
<div class="mb-3">
<label for="to_lang" class="form-label"
data-i18n="targetLanguageLabel">目标语言</label>
<select class="form-select" id="to_lang" name="to_lang">
<option value="中文">中文(简体中文)</option>
<option value="英文">英文(English)</option>
<option value="西班牙文">西班牙文(Español)</option>
<option value="法文">法文(Français)</option>
<option value="德文">德文(Deutsch)</option>
<option value="日文">日文(日本語)</option>
<option value="韩文">韩文(한국어)</option>
<option value="俄文">俄文(Русский)</option>
<option value="葡萄牙文">葡萄牙文(Português)</option>
<option value="阿拉伯文">阿拉伯文(العَرَبِيَّة)</option>
<option value="越南文">越南文(tiếng Việt)</option>
<option value="custom" data-i18n="targetLanguageCustom">其它 (自定义)
</option>
</select>
<div class="mt-2" id="customLangGroup" style="display: none;">
<input type="text" class="form-control" id="custom_to_lang"
name="custom_to_lang" data-i18n-placeholder="customLangPlaceholder"
placeholder="请输入目标语言, 例如: Italian">
</div>
</div>
<div class="mb-3">
<label class="form-label" data-i18n="thinkingModeLabel">思考模式</label><i
class="bi bi-question-circle ms-2 tooltip-icon"
data-bs-toggle="tooltip"
data-bs-placement="top"
data-i18n-title="thinkingModeTooltip"
data-bs-title="设置混合推理模型是否进行思考目前支持智谱的glm4.5系列、火山引擎的seed1.6系列、硅基流动平台、google的gemini系列、302AI(部分),建议选择禁用思考">
</i>
<div id="thinkingModeBtnGroup" class="btn-group w-100" role="group"
aria-label="Thinking Mode Toggle">
<input type="radio" class="btn-check" name="thinking" id="thinkingEnable"
value="enable" autocomplete="off">
<label class="btn btn-outline-primary" for="thinkingEnable"
data-i18n="thinkingModeEnable">启用</label>
<input type="radio" class="btn-check" name="thinking" id="thinkingDisable"
value="disable" autocomplete="off">
<label class="btn btn-outline-primary" for="thinkingDisable"
data-i18n="thinkingModeDisable">禁用(推荐)</label>
<input type="radio" class="btn-check" name="thinking" id="thinkingDefault"
value="default" autocomplete="off">
<label class="btn btn-outline-primary" for="thinkingDefault"
data-i18n="thinkingModeDefault">默认</label>
</div>
</div>
<div class="mb-3">
<label for="custom_prompt" class="form-label" data-i18n="customPromptLabel">自定义Prompt</label>
<textarea class="form-control" id="custom_prompt"
name="custom_prompt" rows="3"
data-i18n-placeholder="customPromptPlaceholder"
placeholder="可选,如“人名保持原文不翻译”"></textarea>
</div>
<!-- MOVED FROM ADVANCED SETTINGS -->
<div class="mb-3 border-top pt-3">
<label for="chunk-size-slider"
class="form-label d-flex justify-content-between">
<span><span data-i18n="chunkSizeLabel">分块大小</span>: <span
id="chunk-size-display"></span></span>
<button type="button"
class="btn btn-sm btn-outline-secondary py-0 px-1 slider-reset-btn"
id="chunk-size-reset" data-i18n="resetBtn">重置
</button>
</label>
<input type="range" class="form-range" min="1000" max="8000" step="100"
id="chunk-size-slider" name="chunk_size">
</div>
<div class="mb-3">
<label for="concurrent-slider"
class="form-label d-flex justify-content-between">
<span><span data-i18n="concurrentLabel">并发数</span>: <span
id="concurrent-display"></span></span>
<button type="button"
class="btn btn-sm btn-outline-secondary py-0 px-1 slider-reset-btn"
id="concurrent-reset" data-i18n="resetBtn">重置
</button>
</label>
<input type="range" class="form-range" min="1" max="60" step="1"
id="concurrent-slider" name="concurrent">
</div>
<div class="mb-3">
<label for="temperature-slider"
class="form-label d-flex justify-content-between">
<span>Temperature: <span id="temperature-display"></span></span>
<button type="button"
class="btn btn-sm btn-outline-secondary py-0 px-1 slider-reset-btn"
id="temperature-reset" data-i18n="resetBtn">重置
</button>
</label>
<input type="range" class="form-range" min="0" max="2" step="0.1"
id="temperature-slider" name="temperature">
</div>
<div class="mb-3">
<label for="retry-slider" class="form-label d-flex justify-content-between">
<span><span data-i18n="retryLabel">重试次数</span>: <span
id="retry-display"></span></span>
<button type="button"
class="btn btn-sm btn-outline-secondary py-0 px-1 slider-reset-btn"
id="retry-reset" data-i18n="resetBtn">重置
</button>
</label>
<input type="range" class="form-range" min="1" max="6" step="1"
id="retry-slider" name="retry">
</div>
</div>
</div>
</div>
<!-- [MODIFIED] Glossary Generation Settings -->
<div class="accordion-item" id="glossaryGenerationContainer">
<h2 class="accordion-header" id="headingGlossaryGen">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseGlossaryGen" aria-expanded="false"
aria-controls="collapseGlossaryGen">
<!-- MODIFIED HERE -->
<strong id="glossaryGenSettingsTitle"><i
class="bi bi-journal-bookmark me-2"></i><span data-i18n="glossaryGenTitle">术语表</span></strong>
</button>
</h2>
<div id="collapseGlossaryGen" class="accordion-collapse collapse"
aria-labelledby="headingGlossaryGen">
<div class="accordion-body">
<!-- [MOVED] Glossary Section -->
<div class="mb-3">
<label for="glossary_files" class="form-label" data-i18n="glossaryLabel">术语表
(可选)</label>
<input class="form-control" type="file" id="glossary_files" multiple
accept=".csv">
<div class="form-text" data-i18n="glossaryHelp">
选择一个或多个CSV文件。文件需包含'src'和'dst'两列标题,分别代表原文和译文。
</div>
<div class="btn-group mt-2" role="group">
<button type="button" class="btn btn-sm btn-outline-info"
id="viewGlossaryBtn">
<i class="bi bi-card-list me-1"></i><span data-i18n="viewGlossaryBtn">查看术语表</span>
</button>
<button type="button" class="btn btn-sm btn-outline-danger"
id="clearGlossaryBtn">
<i class="bi bi-trash me-1"></i><span
data-i18n="clearGlossaryBtn">清空</span>
</button>
</div>
</div>
<!-- Enable/Disable Switch -->
<div class="form-check form-switch mb-3 border-top pt-3">
<input class="form-check-input" type="checkbox" role="switch"
id="glossary_generate_enable" name="glossary_generate_enable">
<label class="form-check-label" for="glossary_generate_enable"
data-i18n="glossaryGenEnableLabel">自动生成术语表</label>
</div>
<!-- Agent Config Options (conditionally displayed) -->
<div id="glossaryAgentOptionsContainer" style="display: none;">
<!-- [MODIFIED] Custom Prompt for glossary is now here, available for both "same" and "custom" modes -->
<div class="mb-3">
<label for="glossary_agent_custom_prompt" class="form-label"
data-i18n="glossaryCustomPromptLabel">自定义Prompt</label>
<textarea class="form-control" id="glossary_agent_custom_prompt"
name="glossary_agent_custom_prompt" rows="3"
data-i18n-placeholder="glossaryCustomPromptPlaceholder"
placeholder="术语表生成提示词"></textarea>
</div>
<div class="mb-3">
<label class="form-label"
data-i18n="glossaryGenConfigLabel">生成术语表配置</label>
<div class="btn-group w-100" role="group">
<input type="radio" class="btn-check"
name="glossary_agent_config_choice" id="glossaryAgentSame"
value="same" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="glossaryAgentSame"
data-i18n="glossaryGenConfigSame">与翻译配置相同</label>
<input type="radio" class="btn-check"
name="glossary_agent_config_choice" id="glossaryAgentCustom"
value="custom" autocomplete="off">
<label class="btn btn-outline-primary" for="glossaryAgentCustom"
data-i18n="glossaryGenConfigCustom">自定义</label>
</div>
</div>
<!-- Custom Agent Config Form (conditionally displayed) -->
<div id="glossaryAgentCustomConfigContainer" class="border p-3 rounded"
style="display: none;">
<div>
<label for="glossary_agent_platform_select" class="form-label"
data-i18n="platformLabel">选择平台</label>
<select class="form-select" id="glossary_agent_platform_select">
<!-- Options will be copied from main select -->
</select>
</div>
<div class="form-text mb-3" id="glossary_base_url_display_container">
Base URL: <code id="glossary_base_url_display"></code>
</div>
<div class="mb-3" id="glossaryAgentBaseUrlGroup" style="display: none;">
<label for="glossary_agent_baseurl" class="form-label"
data-i18n="baseUrlLabel">API 地址 (Base URL)</label>
<input type="url" class="form-control" id="glossary_agent_baseurl"
name="glossary_agent_baseurl"
data-i18n-placeholder="baseUrlPlaceholder"
placeholder="OpenAi兼容地址">
</div>
<div class="mb-3">
<label for="glossary_agent_key" class="form-label">API Key</label>
<div class="input-group">
<input type="password" class="form-control" id="glossary_agent_key"
name="glossary_agent_key"
data-i18n-placeholder="apiKeyPlaceholder"
placeholder="请输入您的API Key">
<button class="btn btn-outline-secondary toggle-password"
type="button" data-target="glossary_agent_key"><i
class="bi bi-eye-slash"></i></button>
</div>
</div>
<div class="mb-3">
<label for="glossary_agent_model_id" class="form-label"
data-i18n="modelIdLabel">模型ID</label>
<input type="text" class="form-control" id="glossary_agent_model_id"
name="glossary_agent_model_id"
data-i18n-placeholder="modelIdPlaceholder"
placeholder="例如: gpt-4-turbo, glm-4">
</div>
<!-- START: Added Target Language for Glossary Agent -->
<div class="mb-3">
<label for="glossary_agent_to_lang" class="form-label"
data-i18n="targetLanguageLabel">目标语言</label>
<select class="form-select" id="glossary_agent_to_lang"
name="glossary_agent_to_lang">
<option value="中文">中文(简体中文)</option>
<option value="英文">英文(English)</option>
<option value="西班牙文">西班牙文(Español)</option>
<option value="法文">法文(Français)</option>
<option value="德文">德文(Deutsch)</option>
<option value="日文">日文(日本語)</option>
<option value="韩文">韩文(한국어)</option>
<option value="俄文">俄文(Русский)</option>
<option value="葡萄牙文">葡萄牙文(Português)</option>
<option value="阿拉伯文">阿拉伯文(العَرَبِيَّة)</option>
<option value="越南文">越南文(tiếng Việt)</option>
<option value="custom" data-i18n="targetLanguageCustom">其它
(自定义)
</option>
</select>
<div class="mt-2" id="glossaryAgentCustomLangGroup"
style="display: none;">
<input type="text" class="form-control"
id="glossary_agent_custom_to_lang"
name="glossary_agent_custom_to_lang"
data-i18n-placeholder="customLangPlaceholder"
placeholder="请输入目标语言, 例如: Italian">
</div>
</div>
<!-- END: Added Target Language for Glossary Agent -->
<div class="mb-3">
<label for="glossary-agent-chunk-size-slider"
class="form-label d-flex justify-content-between">
<span><span data-i18n="chunkSizeLabel">分块大小</span>: <span
id="glossary-agent-chunk-size-display"></span></span>
<button type="button"
class="btn btn-sm btn-outline-secondary py-0 px-1 slider-reset-btn"
id="glossary-agent-chunk-size-reset" data-i18n="resetBtn">重置
</button>
</label>
<input type="range" class="form-range" min="1000" max="8000" step="100"
id="glossary-agent-chunk-size-slider"
name="glossary_agent_chunk_size">
</div>
<div class="mb-3">
<label for="glossary-agent-concurrent-slider"
class="form-label d-flex justify-content-between">
<span><span data-i18n="concurrentLabel">并发数</span>: <span
id="glossary-agent-concurrent-display"></span></span>
<button type="button"
class="btn btn-sm btn-outline-secondary py-0 px-1 slider-reset-btn"
id="glossary-agent-concurrent-reset" data-i18n="resetBtn">重置
</button>
</label>
<input type="range" class="form-range" min="1" max="60" step="1"
id="glossary-agent-concurrent-slider"
name="glossary_agent_concurrent">
</div>
<div class="mb-3">
<label for="glossary-agent-temperature-slider"
class="form-label d-flex justify-content-between">
<span>Temperature: <span
id="glossary-agent-temperature-display">0.7</span></span>
<button type="button"
class="btn btn-sm btn-outline-secondary py-0 px-1 slider-reset-btn"
id="glossary-agent-temperature-reset" data-i18n="resetBtn">
重置
</button>
</label>
<input type="range" class="form-range" min="0" max="2" step="0.1"
id="glossary-agent-temperature-slider"
name="glossary_agent_temperature">
</div>
<div class="mb-3">
<label for="glossary-agent-retry-slider"
class="form-label d-flex justify-content-between">
<span><span data-i18n="retryLabel">重试次数</span>: <span
id="glossary-agent-retry-display"></span></span>
<button type="button"
class="btn btn-sm btn-outline-secondary py-0 px-1 slider-reset-btn"
id="glossary-agent-retry-reset" data-i18n="resetBtn">重置
</button>
</label>
<input type="range" class="form-range" min="1" max="6" step="1"
id="glossary-agent-retry-slider" name="glossary_agent_retry">
</div>
<div class="mb-3">
<label class="form-label" data-i18n="thinkingModeLabel">思考模式</label>
<div id="glossaryAgentThinkingModeBtnGroup" class="btn-group w-100"
role="group">
<input type="radio" class="btn-check" name="glossary_agent_thinking"
id="glossaryAgentThinkingEnable" value="enable"
autocomplete="off">
<label class="btn btn-outline-primary"
for="glossaryAgentThinkingEnable"
data-i18n="thinkingModeEnable">启用</label>
<input type="radio" class="btn-check" name="glossary_agent_thinking"
id="glossaryAgentThinkingDisable" value="disable"
autocomplete="off">
<label class="btn btn-outline-primary"
for="glossaryAgentThinkingDisable"
data-i18n="thinkingModeDisable">禁用(推荐)</label>
<input type="radio" class="btn-check" name="glossary_agent_thinking"
id="glossaryAgentThinkingDefault" value="default"
autocomplete="off" checked>
<label class="btn btn-outline-primary"
for="glossaryAgentThinkingDefault"
data-i18n="thinkingModeDefault">默认</label>
</div>
</div>
<!-- START: ADDED GLOSSARY AGENT SYSTEM PROXY SWITCH -->
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" role="switch"
id="glossary_agent_system_proxy_enable"
name="glossary_agent_system_proxy_enable">
<label class="form-check-label" for="glossary_agent_system_proxy_enable"
data-i18n="systemProxyLabel">启用系统代理</label>
</div>
<!-- END: ADDED GLOSSARY AGENT SYSTEM PROXY SWITCH -->
</div>
</div>
</div>
</div>
</div>
</div>
</form>
<!-- [NEW] Config Import/Export Buttons -->
<div class="d-flex justify-content-center gap-2 mt-4">
<button type="button" class="btn btn-outline-primary" id="importConfigBtn">
<i class="bi bi-box-arrow-in-down me-1"></i><span data-i18n="importConfigBtn">导入配置</span>
</button>
<button type="button" class="btn btn-outline-secondary" id="exportConfigBtn">
<i class="bi bi-box-arrow-up me-1"></i><span data-i18n="exportConfigBtn">导出配置</span>
</button>
</div>
<input type="file" id="configFileInput" class="d-none" accept=".json">
<!-- 项目信息 -->
<div class="mt-4 text-center text-muted small project-info">
<p class="bi bi-github mb-2" data-i18n="githubInfo">
GitHub主页(欢迎star❤): <br/>
<a href="https://github.com/xunbu/docutranslate" target="_blank" rel="noopener noreferrer">https://github.com/xunbu/docutranslate</a>
</p>
<p class="bi bi-tencent-qq mb-2" data-i18n="qqGroupInfo">
交流QQ群: 1047781902
</p>
<p class="bi mb-0">version:<span id="versionDisplay"></span></p>
</div>
</div>
</div>
<!-- Right: Task Area -->
<div class="col-lg-8">
<div class="task-area" id="task-area-container">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0"><i class="bi bi-list-task me-2"></i><span data-i18n="taskListTitle">任务列表</span>
</h4>
<button class="btn btn-primary" id="addNewTaskBtn"><i class="bi bi-plus-circle-fill me-2"></i><span
data-i18n="newTaskBtn">新建任务</span>
</button>
</div>
<div id="task-container">
<!-- Task cards will be injected here -->
</div>
<div id="no-task-placeholder" class="text-center text-muted mt-5">
<img src="/static/favicon.ico" alt="LOGO" style="width:10%;min-width: 55px; height: auto;">
<p class="mt-3" data-i18n="noTaskPlaceholder">当前没有任务,点击“新建任务”开始吧!</p>
</div>
</div>
</div>
</div>
</div>
<!-- Task Card Template -->
<template id="taskCardTemplate">
<div class="card mb-3 task-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-bold"><span data-i18n="taskCardIdLabel">任务 ID</span>: <code class="task-id-display"><span
class="task-id-placeholder" data-i18n="taskCardIdPlaceholder">等待提交...</span></code></span>
<button type="button" class="btn-close remove-task-btn" aria-label="Close"></button>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-5">
<input type="file" class="d-none file-input">
<div class="file-drop-area">
<div class="file-drop-default">
<i class="bi bi-cloud-arrow-up fs-1"></i>
<p class="file-drop-prompt mb-0" data-i18n="taskCardFileDrop">点击或拖拽文件到此处</p>
</div>
<div class="file-drop-selected" style="display: none;">
<i class="bi bi-check-circle-fill fs-1 text-success"></i>
<p class="mb-0 mt-2 fw-bold text-success" data-i18n="taskCardFileSelected">文件已选择</p>
</div>
</div>
<div class="file-name-display-wrapper mt-2" style="display: none;">
<span class="fw-bold" data-i18n="taskCardFilenameLabel">文件名: </span>
<span class="file-name-display text-success"></span>
</div>
</div>
<div class="col-md-7">
<h6><i class="bi bi-terminal me-2"></i><span data-i18n="taskCardLogLabel">日志</span></h6>
<div class="log-area"></div>
<div class="mt-2">
<div class="status-message-container">
<span class="status-message small text-muted"
data-i18n="taskCardStatusWaiting">等待上传文件...</span>
</div>
</div>
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-between align-items-center">
<div class="download-buttons" style="display: none;">
<button class="btn btn-sm btn-success preview-html-btn" style="display: none;"><i
class="bi bi-eye-fill me-1"></i><span data-i18n="taskCardPreviewBtn">预览</span>
</button>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-secondary dropdown-toggle" data-bs-toggle="dropdown"
aria-expanded="false">
<i class="bi bi-download me-1"></i><span data-i18n="taskCardDownloadBtn">下载</span>
</button>
<ul class="dropdown-menu download-menu-container">
<!-- Populated by JavaScript -->
</ul>
</div>
<!-- [NEW] Attachment Button Group -->
<div class="btn-group attachment-btn-group" style="display: none;">
<button type="button" class="btn btn-sm btn-info dropdown-toggle" data-bs-toggle="dropdown"
aria-expanded="false">
<i class="bi bi-paperclip me-1"></i><span data-i18n="taskCardAttachmentBtn">附件</span>
</button>
<ul class="dropdown-menu attachment-menu-container">
<!-- Populated by JavaScript -->
</ul>
</div>
</div>
<button class="btn btn-primary start-translate-btn ms-auto"><i class="bi bi-play-fill me-1"></i><span
data-i18n="taskCardStartBtn">开始翻译</span>
</button>
</div>
</div>
</template>
<!-- Reusable Download Menu Template -->
<template id="downloadMenuTemplate">
<li class="download-item-md"><a class="dropdown-item" href="#"><i
class="bi bi-markdown me-2"></i><span data-i18n="downloadMdEmbedded">Markdown(嵌图)</span></a></li>
<li class="download-item-md-zip"><a class="dropdown-item" href="#"><i
class="bi bi-file-zip me-2"></i><span data-i18n="downloadMdZip">Markdown压缩包</span></a></li>
<li class="download-item-txt"><a class="dropdown-item" href="#"><i
class="bi bi-filetype-txt me-2"></i>TXT</a></li>
<li class="download-item-json"><a class="dropdown-item" href="#"><i
class="bi bi-filetype-json me-2"></i>JSON</a></li>
<li class="download-item-docx"><a class="dropdown-item" href="#"><i
class="bi bi-filetype-docx me-2"></i>DOCX</a></li>
<li class="download-item-xlsx"><a class="dropdown-item" href="#"><i
class="bi bi-filetype-xlsx me-2"></i>XLSX</a></li>
<li class="download-item-csv"><a class="dropdown-item" href="#"><i
class="bi bi-filetype-csv me-2"></i>CSV</a></li>
<li class="download-item-srt"><a class="dropdown-item" href="#"><i
class="bi bi-file-text me-2"></i>SRT</a></li>
<li class="download-item-epub"><a class="dropdown-item" href="#"><i
class="bi bi-book me-2"></i>EPUB</a></li>
<li class="download-item-ass"><a class="dropdown-item" href="#"><i
class="bi bi-file-easel me-2"></i><span data-i18n="downloadAss">ASS</span></a></li>
<li class="download-item-html"><a class="dropdown-item" href="#"><i
class="bi bi-filetype-html me-2"></i>HTML</a></li>
<li class="download-item-pdf"><a class="dropdown-item" href="#"><i
class="bi bi-file-earmark-pdf me-2"></i>PDF</a></li>
</template>
<!-- Preview Offcanvas with Resizable Panes -->
<div class="offcanvas offcanvas-end" data-bs-scroll="true" tabindex="-1" id="previewOffcanvas"
aria-labelledby="previewOffcanvasLabel">
<div class="offcanvas-header border-bottom">
<h5 class="offcanvas-title" id="previewOffcanvasLabel" data-i18n="previewTitle">预览</h5>
<div class="btn-group me-auto ms-4" role="group">
<button type="button" class="btn btn-sm btn-primary" id="setBilingualViewBtn"
data-i18n="previewBilingualBtn">双语
</button>
<button type="button" class="btn btn-sm btn-outline-primary" id="setTranslatedOnlyViewBtn"
data-i18n="previewTranslatedOnlyBtn">仅译文
</button>
</div>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body d-flex flex-column p-2">
<div class="preview-split-container flex-grow-1">
<div id="originalPreviewContainer" class="preview-pane-wrapper">
<h6 class="text-center text-muted small" data-i18n="previewOriginal">原文</h6>
<div class="preview-pane" id="originalPreviewPane"></div>
</div>
<div id="translatedPreviewContainer" class="preview-pane-wrapper">
<h6 class="text-center text-muted small" data-i18n="previewTranslated">译文</h6>
<div class="preview-pane">
<iframe id="translatedPreviewFrame" src="about:blank"></iframe>
</div>
</div>
</div>
<div class="offcanvas-footer mt-2 pt-3 border-top d-flex justify-content-end align-items-center">
<button type="button" class="btn btn-secondary" data-bs-dismiss="offcanvas" data-i18n="closeBtn">关闭
</button>
<div class="btn-group ms-2">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown"
aria-expanded="false" id="previewDownloadBtn">
<i class="bi bi-download me-1"></i><span data-i18n="downloadBtn">下载</span>
</button>
<ul class="dropdown-menu dropdown-menu-end" id="previewDownloadMenu">
<!-- Populated by JavaScript -->
</ul>
</div>
</div>
</div>
</div>
<!-- Hidden iframe for direct PDF printing -->
<iframe id="printFrame" style="display: none;"></iframe>
<!-- Bottom Left Controls -->
<div class="bottom-left-controls">
<!-- Language Switcher -->
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" id="language-switcher" data-bs-toggle="dropdown"
aria-expanded="false" title="切换语言 / Switch Language">
<i class="bi bi-translate"></i>
</button>
<ul class="dropdown-menu" aria-labelledby="language-switcher" id="languageMenu">
<li><a class="dropdown-item" href="#" data-lang="zh">中文</a></li>
<li><a class="dropdown-item" href="#" data-lang="en">English</a></li>
</ul>
</div>
<!-- Theme Switcher -->
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" id="theme-switcher" data-bs-toggle="dropdown"
aria-expanded="false">
<i class="bi bi-circle-half"></i>
</button>
<ul class="dropdown-menu" aria-labelledby="theme-switcher">
<li>
<button class="dropdown-item" type="button" data-bs-theme-value="light"><i
class="bi bi-sun-fill me-2"></i>
Light
</button>
</li>
<li>
<button class="dropdown-item" type="button" data-bs-theme-value="dark"><i
class="bi bi-moon-stars-fill me-2"></i> Dark
</button>
</li>
<li>
<button class="dropdown-item active" type="button" data-bs-theme-value="auto"><i
class="bi bi-circle-half me-2"></i> Auto
</button>
</li>
</ul>
</div>
</div>
<!-- Tutorial Modal -->
<!-- Tutorial Modal -->
<div class="modal fade" id="tutorialModal" tabindex="-1" aria-labelledby="tutorialModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="tutorialModalLabel"><i class="bi bi-book-half me-2"></i><span
data-i18n="tutorialModalTitle">使用教程</span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" data-i18n="tutorialModalBody">
<p><i class="bi bi-camera-video me-2"></i>视频教程可以在B站搜索 <a
href="https://search.bilibili.com/all?keyword=docutranslate" target="_blank">docutranslate</a>
获取。</p>
<p>欢迎使用 DocuTranslate请按照以下步骤完成文档翻译</p>
<ol>
<li>
<strong><i class="bi bi-diagram-3 me-2"></i>第一步:选择工作流</strong>
<p class="mt-2">
在左侧配置面板的顶部,首先选择最适合您文件类型的处理流程。
<div class="alert alert-info mt-2" role="alert">
<i class="bi bi-lightbulb-fill me-2"></i>提示:
默认已开启“自动选择工作流”。您只需上传文件,系统会自动为您匹配合适的工作流,简化操作。
</div>
</p>
<ul>
<li><b>转Markdown再翻译</b>: 适用于翻译PDF、markdown、图片等文件。这是最通用和强大的模式。</li>
<li><b>纯文本翻译</b>: 用于翻译 <code>.txt</code> 纯文本文件。</li>
<li><b>EPUB翻译</b>: 用于翻译 <code>.epub</code> 电子书文件。</li>
<li><b>DOCX翻译</b>: 用于翻译 <code>.docx</code> Word文档。</li>
<li><b>XLSX翻译</b>: 用于翻译 <code>.xlsx</code><code>.csv</code> 电子表格文件。</li>
<li><b>SRT字幕翻译</b>: 用于翻译 <code>.srt</code> 字幕文件。</li>
<li><b>ASS字幕翻译</b>: 用于翻译 <code>.ass</code> 特效字幕文件。</li>
<li><b>JSON翻译</b>: 用于翻译 <code>.json</code> 文件中的特定字段。</li>
<li><b>HTML翻译</b>: 用于翻译 <code>.html</code> 网页文件。</li>
</ul>
</li>
<li>
<strong><i class="bi bi-gear-fill me-2"></i>第二步:配置参数</strong>
<p class="mt-2">
选择工作流后,下方会显示相关的配置选项。请依次完成设置(所有配置都会自动保存在您的浏览器中):</p>
<p class="mb-2"><strong>A. 工作流特定选项</strong> (根据您第一步的选择出现):</p>
<ul class="ms-4">
<li><u>如果选择“转Markdown再翻译”</u>,请配置 <strong>解析配置</strong>
<ul>
<li><strong>解析引擎</strong>:
选择一个引擎将您的文件如PDF转换为适合翻译的Markdown格式。如果您的文件已经是Markdown格式则无需选择。
</li>
<li><strong>Mineru Token</strong>: 如果您选择 <code>minerU</code> 引擎需要在此处填入您的Token。
</li>
</ul>
</li>
<li><u>如果选择“纯文本/DOCX/XLSX/SRT/ASS/EPUB/HTML”</u>,请配置其 <strong>翻译选项</strong>
<ul>
<li><strong>插入模式</strong>: 定义翻译结果如何放入文档。您可以选择直接“替换”原文,或是在原文之后“附加”,或是在原文之前“前置”。
</li>
<li><strong>分隔符</strong>: 当选择“附加”或“前置”模式时此项用于在原文和译文之间插入分隔符例如ASS格式中常用
<code>\N</code> 作为换行分隔符)。
</li>
</ul>
</li>
<li><u>如果选择“JSON翻译”</u>,请配置 <strong>JSON路径</strong>
<ul>
<li><strong>需要翻译的JSON路径</strong>: 每行输入一个 <a
href="https://goessner.net/articles/JsonPath/" target="_blank">JSONPath</a>
表达式,指定需要翻译的字段。例如:<code>$..description</code>
</li>
</ul>
</li>
</ul>
<p class="mb-2 mt-3"><strong>B. 通用选项</strong> (适用于所有工作流):</p>
<ul class="ms-4">
<li><strong>翻译模型</strong>:
<ul>
<li><strong>选择平台/API 地址/API Key/模型ID</strong>: 配置您希望使用的AI翻译服务。模型能力指令遵循越强<strong>出错</strong><strong>漏翻</strong>的概率越低。
</li>
<li><strong>跳过翻译</strong>: 勾选此项后将只执行文档解析和格式转换不调用AI进行翻译。
</li>
</ul>
</li>
<li><strong>翻译配置</strong>:
<ul>
<li><strong>目标语言</strong>: 指定翻译的目标语言。</li>
<li><strong>自定义Prompt</strong>: 可选,添加额外指令,如“人名保持原文不翻译”。</li>
<li><strong>思考模式</strong>: 针对部分支持混合推理的模型进行设置,建议选择“禁用(推荐)”。
</li>
<li><strong>分块大小/并发数等</strong>: 高级参数用于调整性能和API请求行为通常保持默认即可。
</li>
</ul>
</li>
<li><strong>术语表</strong>:
<ul>
<li><strong>上传术语表 (可选)</strong>: 上传CSV文件需包含'src'和'dst'列)来保证特定术语翻译的统一性和准确性。
</li>
<li><strong>自动生成术语表</strong>: 启用后,程序会先从原文中提取术语并生成一个术语表,然后再进行翻译。
</li>
</ul>
</li>
</ul>
</li>
<li>
<strong><i class="bi bi-file-earmark-arrow-up-fill me-2"></i>第三步:上传文件</strong>
<p class="mt-2">在右侧的任务列表中,点击或拖拽您的文档到文件上传区域。</p>
</li>
<li>
<strong><i class="bi bi-play-circle-fill me-2"></i>第四步:开始翻译</strong>
<p class="mt-2">文件选择成功后,点击任务卡片右下角的 <span
class="badge bg-primary">开始翻译</span> 按钮。系统将开始处理任务,您可以在日志区域查看实时进度。
</p>
</li>
<li>
<strong><i class="bi bi-check-circle-fill me-2"></i>第五步:查看与下载</strong>
<p class="mt-2">翻译完成后,任务卡片下方会出现操作按钮:</p>
<ul>
<li><span class="badge bg-success"><i class="bi bi-eye-fill me-1"></i>预览</span>:
在右侧滑出的面板中进行原文和译文的对照预览。
</li>
<li><span class="badge bg-secondary"><i class="bi bi-download me-1"></i>下载</span>:
下载包括 PDF, DOCX, Markdown 等多种格式的译文。
</li>
<li><span class="badge bg-info"><i class="bi bi-paperclip me-1"></i>附件</span>:
如果翻译过程中生成了附加文件(如自动生成的术语表),可在此处下载。
</li>
</ul>
</li>
</ol>
<div class="alert alert-warning mt-3" role="alert">
<i class="bi bi-info-circle-fill me-2"></i><strong>重要提示</strong>:
所有配置都会自动保存在您的浏览器本地,方便下次使用。您也可以使用新增的“导出配置”和“导入配置”按钮来备份和恢复您的设置。
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" data-i18n="tutorialUnderstandBtn">
我明白了
</button>
</div>
</div>
</div>
</div>
<!-- Contributors Modal -->
<div class="modal fade" id="contributorsModal" tabindex="-1" aria-labelledby="contributorsModalLabel"
aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="contributorsModalLabel"><i
class="bi bi-heart-fill me-2 text-danger"></i><span
data-i18n="contributorsModalTitle">感谢贡献</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p data-i18n="contributorsPara1">DocuTranslate是一个开源项目大家的需求与使用是项目进步的动力。</p>
<p data-i18n="contributorsPara2">感谢所有资助项目、提交代码与宝贵建议及给项目star的朋友们</p>
<div class="alert alert-success mt-4" role="alert">
<p data-i18n="contributorsWelcome">欢迎通过以下方式参与贡献:</p>
<hr>
<p class="mb-0">
<a href="https://github.com/xunbu/docutranslate" target="_blank"
class="btn btn-info btn-sm ms-2">
<i class="bi bi-github me-1"></i><span data-i18n="contributorsGithub">github 主页</span>
</a>
<a href="https://github.com/xunbu/docutranslate/pulls" target="_blank"
class="btn btn-success btn-sm ms-2">
<i class="bi bi-git me-1"></i><span data-i18n="contributorsPR">提交 Pull Request</span>
</a>
<a href="https://github.com/xunbu/docutranslate/issues" target="_blank"
class="btn btn-warning btn-sm ms-2">
<i class="bi bi-bug-fill me-1"></i><span data-i18n="contributorsIssue">报告 Issue</span>
</a>
</p>
<hr>
<p data-i18n="contributorsQQ">或者通过QQ群联系作者<span>1047781902</span></p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="closeBtn">关闭
</button>
</div>
</div>
</div>
</div>
<!-- [NEW] Glossary Modal -->
<div class="modal fade" id="glossaryModal" tabindex="-1" aria-labelledby="glossaryModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="glossaryModalLabel" data-i18n="glossaryModalTitle">当前术语表</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<table class="table table-striped table-hover">
<thead>
<tr>
<th scope="col" data-i18n="glossaryTableSource">原文 (src)</th>
<th scope="col" data-i18n="glossaryTableDestination">译文 (dst)</th>
</tr>
</thead>
<tbody id="glossaryTableBody">
<!-- Content will be populated by JS -->
</tbody>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="closeBtn">关闭
</button>
</div>
</div>
</div>
</div>
<!-- Bootstrap JS -->
<script src="/static/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
<!-- Split.js for resizable panes -->
<script src="/static/split.min.js"></script>
<!-- [NEW] PapaParse for CSV parsing -->
<script src="/static/papaparse.min.js"></script>
<script type="module">
// --- I18N Data ---
// Initialize with a minimal fallback for critical error messages.
// The full dataset will be loaded asynchronously in the init() function.
let i18nData = {
zh: {
init_i18n_failed_alert: '加载界面翻译资源失败,请检查网络连接或联系管理员。',
init_failed_alert: '初始化失败,无法连接到后端服务。请检查服务是否运行或刷新页面。',
},
en: {
init_i18n_failed_alert: 'Failed to load interface translations. Please check your network connection or contact an administrator.',
init_failed_alert: 'Initialization failed, could not connect to the backend service. Please ensure the service is running and refresh the page.',
}
};
let currentLang = 'zh'; // Global language state
// --- I18N Helper Functions ---
const getText = (key, fallback = '') => {
const translations = i18nData[currentLang] || i18nData.zh;
return translations[key] || fallback || key;
};
/**
* Updates the UI language based on the selected language.
* @param {string} lang - The language code (e.g., 'zh', 'en').
*/
function setLanguage(lang) {
if (!i18nData[lang]) return;
currentLang = lang;
const translations = i18nData[lang];
document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en';
// Define attributes and corresponding properties to update
const i18nTargets = {
'data-i18n': (el, text) => el.innerHTML = text,
'data-i18n-placeholder': (el, text) => el.placeholder = text,
'data-i18n-title': (el, text) => {
if (el.tagName === 'TITLE') {
el.textContent = text;
} else {
const tooltipInstance = bootstrap.Tooltip.getInstance(el);
if (tooltipInstance) {
tooltipInstance.setContent({'.tooltip-inner': text});
} else {
// 如果某个元素不是 Bootstrap tooltip 但也用了这个属性,可以保留设置 title 的逻辑作为备用
el.title = text;
}
}
}
};
// Update all elements with i18n attributes in a single pass
Object.entries(i18nTargets).forEach(([attribute, updater]) => {
document.querySelectorAll(`[${attribute}]`).forEach(el => {
const key = el.getAttribute(attribute);
if (translations[key] !== undefined) {
updater(el, translations[key]);
}
});
});
// Update dynamic UI parts that are built with JS
// --- MODIFIED HERE ---
// This function now correctly re-applies numbering after text has been translated.
updateWorkflowUI();
updateConvertEnginUI(true);
mainPlatformUpdater(); // 新增: 重新运行平台UI更新器以应用翻译
// Update language switcher active state
const langMenu = document.getElementById('languageMenu');
langMenu.querySelectorAll('a.active').forEach(a => a.classList.remove('active'));
const activeLink = langMenu.querySelector(`a[data-lang="${lang}"]`);
if (activeLink) activeLink.classList.add('active');
// Save preference
saveToStorage('ui_language', lang);
}
function initI18n() {
const langMenu = document.getElementById('languageMenu');
langMenu.querySelectorAll('a[data-lang]').forEach(a => {
a.addEventListener('click', (e) => {
e.preventDefault();
setLanguage(a.dataset.lang);
});
});
const savedLang = getFromStorage('ui_language') || (navigator.language.toLowerCase().startsWith('en') ? 'en' : 'zh');
setLanguage(savedLang);
}
// --- End I18N ---
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
// --- DOM Elements ---
const settingsForm = document.getElementById('translateForm');
// Workflow elements
const workflowTypeSelect = document.getElementById('workflowTypeSelect');
const autoWorkflowSwitch = document.getElementById('autoWorkflowSwitch');
const parsingSettingsContainer = document.getElementById('parsingSettingsContainer');
const parsingSettingsTitle = document.getElementById('parsingSettingsTitle');
const jsonSettingsContainer = document.getElementById('jsonSettingsContainer');
const jsonSettingsTitle = document.getElementById('jsonSettingsTitle');
const jsonPathsTextarea = document.getElementById('json_paths_textarea');
const txtSettingsContainer = document.getElementById('txtSettingsContainer');
const txtSettingsTitle = document.getElementById('txtSettingsTitle');
const txtInsertModeSelect = document.getElementById('txt_insert_mode');
const txtSeparatorGroup = document.getElementById('txtSeparatorGroup');
const txtSeparatorInput = document.getElementById('txt_separator');
const xlsxSettingsContainer = document.getElementById('xlsxSettingsContainer');
const xlsxSettingsTitle = document.getElementById('xlsxSettingsTitle');
const xlsxInsertModeSelect = document.getElementById('xlsx_insert_mode');
const xlsxSeparatorGroup = document.getElementById('xlsxSeparatorGroup');
const xlsxSeparatorInput = document.getElementById('xlsx_separator');
const xlsxTranslateRegionsTextarea = document.getElementById('xlsx_translate_regions');
const docxSettingsContainer = document.getElementById('docxSettingsContainer');
const docxSettingsTitle = document.getElementById('docxSettingsTitle');
const docxInsertModeSelect = document.getElementById('docx_insert_mode');
const docxSeparatorGroup = document.getElementById('docxSeparatorGroup');
const docxSeparatorInput = document.getElementById('docx_separator');
const srtSettingsContainer = document.getElementById('srtSettingsContainer');
const srtSettingsTitle = document.getElementById('srtSettingsTitle');
const srtInsertModeSelect = document.getElementById('srt_insert_mode');
const srtSeparatorGroup = document.getElementById('srtSeparatorGroup');
const srtSeparatorInput = document.getElementById('srt_separator');
const epubSettingsContainer = document.getElementById('epubSettingsContainer');
const epubSettingsTitle = document.getElementById('epubSettingsTitle');
const epubInsertModeSelect = document.getElementById('epub_insert_mode');
const epubSeparatorGroup = document.getElementById('epubSeparatorGroup');
const epubSeparatorInput = document.getElementById('epub_separator');
const htmlSettingsContainer = document.getElementById('htmlSettingsContainer');
const htmlSettingsTitle = document.getElementById('htmlSettingsTitle');
const htmlInsertModeSelect = document.getElementById('html_insert_mode');
const htmlSeparatorGroup = document.getElementById('htmlSeparatorGroup');
const htmlSeparatorInput = document.getElementById('html_separator');
const assSettingsContainer = document.getElementById('assSettingsContainer');
const assSettingsTitle = document.getElementById('assSettingsTitle');
const assInsertModeSelect = document.getElementById('ass_insert_mode');
const assSeparatorGroup = document.getElementById('assSeparatorGroup');
const assSeparatorInput = document.getElementById('ass_separator');
const aiSettingsTitle = document.getElementById('aiSettingsTitle');
const translationSettingsTitle = document.getElementById('translationSettingsTitle');
const glossaryGenSettingsTitle = document.getElementById('glossaryGenSettingsTitle');
// Parsing elements
const convertEnginSelect = document.getElementById('convert_engine');
const mineruTokenGroup = document.getElementById('mineruTokenGroup');
const mineruTokenInput = document.getElementById('mineru_token');
const mineruModelVersionGroup = document.getElementById('mineruModelVersionGroup');
const modelVersionSelect = document.getElementById('model_version');
const formulaCheckbox = document.getElementById('formula_ocr');
const codeCheckbox = document.getElementById('code_ocr');
const codeOcrSwitch = document.getElementById('codeOcrSwitch');
const formulaOcrSwitch = formulaCheckbox.parentElement;
// AI elements
const skipTranslationSwitch = document.getElementById('skipTranslationSwitch');
const aiModelSettingsContainer = document.getElementById('aiModelSettingsContainer');
const platformSelect = document.getElementById('platform_select');
const apiHref = document.getElementById('api_href');
const apiHrefInfo = document.getElementById('api_href_info');
const baseUrlGroup = document.getElementById('baseUrlGroup');
const baseUrlInput = document.getElementById('base_url');
const apikeyInput = document.getElementById('api_key');
const modelInput = document.getElementById('model_id');
const mainBaseUrlDisplay = document.getElementById('main_base_url_display');
const systemProxyEnableSwitch = document.getElementById('system_proxy_enable');
// Translation elements
const translationSettingsAccordionItem = document.getElementById('translationSettingsAccordionItem');
const toLangSelect = document.getElementById('to_lang');
const customLangGroup = document.getElementById('customLangGroup');
const customLangInput = document.getElementById('custom_to_lang');
const customPromptTranslateArea = document.getElementById("custom_prompt");
// [NEW] Glossary elements
const glossaryFilesInput = document.getElementById('glossary_files');
const viewGlossaryBtn = document.getElementById('viewGlossaryBtn');
const clearGlossaryBtn = document.getElementById('clearGlossaryBtn');
const glossaryModalEl = document.getElementById('glossaryModal');
const glossaryModal = new bootstrap.Modal(glossaryModalEl);
const glossaryTableBody = document.getElementById('glossaryTableBody');
// [MODIFIED] Glossary Generation elements
const glossaryGenerationContainer = document.getElementById('glossaryGenerationContainer');
const glossaryGenerateEnableSwitch = document.getElementById('glossary_generate_enable');
const glossaryAgentOptionsContainer = document.getElementById('glossaryAgentOptionsContainer');
const glossaryAgentConfigChoiceRadios = document.querySelectorAll('input[name="glossary_agent_config_choice"]');
const glossaryAgentCustomConfigContainer = document.getElementById('glossaryAgentCustomConfigContainer');
const glossaryAgentPlatformSelect = document.getElementById('glossary_agent_platform_select');
const glossaryAgentBaseUrlGroup = document.getElementById('glossaryAgentBaseUrlGroup');
const glossaryAgentBaseUrlInput = document.getElementById('glossary_agent_baseurl');
const glossaryAgentKeyInput = document.getElementById('glossary_agent_key');
const glossaryAgentModelIdInput = document.getElementById('glossary_agent_model_id');
const glossaryBaseUrlDisplay = document.getElementById('glossary_base_url_display');
const glossaryAgentToLangSelect = document.getElementById('glossary_agent_to_lang');
const glossaryAgentCustomLangGroup = document.getElementById('glossaryAgentCustomLangGroup');
const glossaryAgentCustomLangInput = document.getElementById('glossary_agent_custom_to_lang');
const glossaryAgentCustomPromptTextarea = document.getElementById('glossary_agent_custom_prompt');
const glossaryAgentTemperatureSlider = document.getElementById('glossary-agent-temperature-slider');
const glossaryAgentTemperatureDisplay = document.getElementById('glossary-agent-temperature-display');
const glossaryAgentTemperatureReset = document.getElementById('glossary-agent-temperature-reset');
const glossaryAgentChunkSizeSlider = document.getElementById('glossary-agent-chunk-size-slider');
const glossaryAgentChunkSizeDisplay = document.getElementById('glossary-agent-chunk-size-display');
const glossaryAgentChunkSizeReset = document.getElementById('glossary-agent-chunk-size-reset');
const glossaryAgentConcurrentSlider = document.getElementById('glossary-agent-concurrent-slider');
const glossaryAgentConcurrentDisplay = document.getElementById('glossary-agent-concurrent-display');
const glossaryAgentConcurrentReset = document.getElementById('glossary-agent-concurrent-reset');
const glossaryAgentRetrySlider = document.getElementById('glossary-agent-retry-slider');
const glossaryAgentRetryDisplay = document.getElementById('glossary-agent-retry-display');
const glossaryAgentRetryReset = document.getElementById('glossary-agent-retry-reset');
const glossaryAgentThinkingRadios = document.querySelectorAll('input[name="glossary_agent_thinking"]');
const glossaryAgentSystemProxyEnableSwitch = document.getElementById('glossary_agent_system_proxy_enable');
// Moved Advanced elements (now part of translation settings)
const chunkSizeSlider = document.getElementById('chunk-size-slider');
const chunkSizeDisplay = document.getElementById('chunk-size-display');
const chunkSizeReset = document.getElementById('chunk-size-reset');
const concurrentSlider = document.getElementById('concurrent-slider');
const concurrentDisplay = document.getElementById('concurrent-display');
const concurrentReset = document.getElementById("concurrent-reset");
const temperatureSlider = document.getElementById('temperature-slider');
const temperatureDisplay = document.getElementById('temperature-display');
const temperatureReset = document.getElementById("temperature-reset");
const retrySlider = document.getElementById('retry-slider');
const retryDisplay = document.getElementById('retry-display');
const retryReset = document.getElementById('retry-reset');
// General UI elements
const versionDisplay = document.getElementById("versionDisplay");
const addNewTaskBtn = document.getElementById('addNewTaskBtn');
const taskContainer = document.getElementById('task-container');
const noTaskPlaceholder = document.getElementById('no-task-placeholder');
const taskCardTemplate = document.getElementById('taskCardTemplate');
const downloadMenuTemplate = document.getElementById('downloadMenuTemplate');
const previewOffcanvasEl = document.getElementById('previewOffcanvas');
const previewOffcanvas = new bootstrap.Offcanvas(previewOffcanvasEl);
const previewOffcanvasLabel = document.getElementById('previewOffcanvasLabel');
const originalPreviewPane = document.getElementById('originalPreviewPane');
const translatedPreviewFrame = document.getElementById('translatedPreviewFrame');
const originalPreviewContainer = document.getElementById('originalPreviewContainer');
const translatedPreviewContainer = document.getElementById('translatedPreviewContainer');
const setBilingualViewBtn = document.getElementById('setBilingualViewBtn');
const setTranslatedOnlyViewBtn = document.getElementById('setTranslatedOnlyViewBtn');
const printFrameEl = document.getElementById('printFrame');
// --- Global State ---
let defaultParams = {};
let glossaryData = {}; // [NEW] For glossary_dict
const tasks = {}; // { cardId: { elements: {...}, state: {...}, intervals: {...} } }
let isAdminMode = false;
let previewSplitInstance = null;
const apiHrefMap = {
"https://api.302.ai/v1": ["https://share.302.ai/BgRLAe", "apiHrefInfo302ai"],
"https://openrouter.ai/api/v1": ["https://openrouter.ai/settings/keys", null],
"https://api.openai.com/v1": ["https://platform.openai.com/api-keys", null],
"https://api.deepseek.com/v1": ["https://platform.deepseek.com/api_keys", null],
"https://open.bigmodel.cn/api/paas/v4": ["https://open.bigmodel.cn/usercenter/apikeys", null],
"https://dashscope.aliyuncs.com/compatible-mode/v1": ["https://bailian.console.aliyun.com/?tab=model#/api-key", null],
"https://ark.cn-beijing.volces.com/api/v3": ["https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D", null],
"https://api.siliconflow.cn/v1": ["https://cloud.siliconflow.cn/account/ak", null],
"https://ai.juguang.chat/v1": ["https://ai.juguang.chat/console/token", null],
"https://www.dmxapi.cn/v1": ["https://www.dmxapi.cn/token", null],
"https://www.dmxapi.com/v1": ["https://www.dmxapi.com/console/token", null],
"https://generativelanguage.googleapis.com/v1beta/openai/": ["https://aistudio.google.com/u/0/apikey", null]
};
const workflowExtensionMap = {
'txt': 'txt',
'xlsx': 'xlsx',
'csv': 'xlsx', // Map csv input to xlsx workflow
'docx': 'docx',
'json': 'json',
'srt': 'srt',
'epub': 'epub',
'html': 'html',
'htm': 'html',
'ass': 'ass',
};
const defaultAutoWorkflow = 'markdown_based';
// --- Utility Functions ---
const generateCardId = () => `card_${Math.random().toString(36).substring(2, 10)}`;
const saveToStorage = (key, value) => {
try {
localStorage.setItem(key, value);
} catch (e) {
console.warn("Save to storage failed:", e);
}
};
const getFromStorage = (key, defaultValue = '') => {
try {
return localStorage.getItem(key) || defaultValue;
} catch (e) {
console.warn("Read from storage failed:", e);
return defaultValue;
}
};
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
const base64String = reader.result.split(',')[1];
resolve(base64String);
};
reader.onerror = error => reject(error);
});
}
// --- [NEW] Glossary Functions ---
async function handleGlossaryFiles(event) {
const files = event.target.files;
if (!files.length) return;
let newGlossary = {};
let loadedCount = 0;
const parsePromises = Array.from(files).map(file => {
return new Promise((resolve) => {
Papa.parse(file, {
header: true,
skipEmptyLines: true,
complete: (results) => {
if (results.data) {
results.data.forEach(row => {
const src = row.src ? row.src.trim() : null;
const dst = row.dst ? row.dst.trim() : null;
// Add only if src is valid and not already in the dictionary
if (src && dst && !newGlossary.hasOwnProperty(src)) {
newGlossary[src] = dst;
}
});
}
resolve();
},
error: (err) => {
console.error(`Error parsing ${file.name}:`, err);
resolve(); // Resolve anyway to not block other files
}
});
});
});
await Promise.all(parsePromises);
glossaryData = newGlossary;
loadedCount = Object.keys(glossaryData).length;
// Simple feedback
const btnText = viewGlossaryBtn.querySelector('span');
if (btnText) {
btnText.textContent = `${getText('viewGlossaryBtn')} (${loadedCount})`;
}
console.log("Glossary loaded:", glossaryData);
}
function populateGlossaryModal() {
glossaryTableBody.innerHTML = ''; // Clear previous content
const terms = Object.entries(glossaryData);
if (terms.length === 0) {
const tr = document.createElement('tr');
const td = document.createElement('td');
td.colSpan = 2;
td.textContent = getText('glossaryEmpty');
td.className = 'text-center text-muted';
tr.appendChild(td);
glossaryTableBody.appendChild(tr);
} else {
terms.forEach(([src, dst]) => {
const tr = document.createElement('tr');
const tdSrc = document.createElement('td');
const tdDst = document.createElement('td');
tdSrc.textContent = src;
tdDst.textContent = dst;
tr.appendChild(tdSrc);
tr.appendChild(tdDst);
glossaryTableBody.appendChild(tr);
});
}
}
function clearGlossary() {
// Reset the data
glossaryData = {};
// Clear the file input so the user can re-select the same file if they want
glossaryFilesInput.value = '';
// Update the view button text to remove the count
const btnText = viewGlossaryBtn.querySelector('span');
if (btnText) {
btnText.textContent = getText('viewGlossaryBtn');
}
console.log("Glossary cleared.");
// If the modal is open, re-populate it to show it's empty
if (glossaryModalEl.classList.contains('show')) {
populateGlossaryModal();
}
}
// --- UI Update Functions based on Workflow ---
const workflowConfigs = {
'markdown_based': {
container: parsingSettingsContainer,
titleEl: parsingSettingsTitle
},
'txt': {
container: txtSettingsContainer,
titleEl: txtSettingsTitle,
modeSelect: txtInsertModeSelect,
separatorGroup: txtSeparatorGroup
},
'json': {
container: jsonSettingsContainer,
titleEl: jsonSettingsTitle
},
'xlsx': {
container: xlsxSettingsContainer,
titleEl: xlsxSettingsTitle,
modeSelect: xlsxInsertModeSelect,
separatorGroup: xlsxSeparatorGroup
},
'docx': {
container: docxSettingsContainer,
titleEl: docxSettingsTitle,
modeSelect: docxInsertModeSelect,
separatorGroup: docxSeparatorGroup
},
'srt': {
container: srtSettingsContainer,
titleEl: srtSettingsTitle,
modeSelect: srtInsertModeSelect,
separatorGroup: srtSeparatorGroup
},
'epub': {
container: epubSettingsContainer,
titleEl: epubSettingsTitle,
modeSelect: epubInsertModeSelect,
separatorGroup: epubSeparatorGroup
},
'html': {
container: htmlSettingsContainer,
titleEl: htmlSettingsTitle,
modeSelect: htmlInsertModeSelect,
separatorGroup: htmlSeparatorGroup
},
'ass': {
container: assSettingsContainer,
titleEl: assSettingsTitle,
modeSelect: assInsertModeSelect,
separatorGroup: assSeparatorGroup
}
};
function updateWorkflowUI() {
const selectedWorkflow = workflowTypeSelect.value;
// Hide all workflow-specific containers
Object.values(workflowConfigs).forEach(config => {
if (config.container) config.container.style.display = 'none';
});
// Helper to update titles with a step number
const updateTitleWithNumber = (titleElement, stepNumber) => {
if (!titleElement) return;
// First, remove any existing number span to prevent duplicates on re-renders
const existingNumber = titleElement.querySelector('.step-number');
if (existingNumber) existingNumber.remove();
// Create a new span for the number
const numberSpan = document.createElement('span');
numberSpan.className = 'step-number';
numberSpan.textContent = `${stepNumber}. `;
// Insert the number span right after the icon
const icon = titleElement.querySelector('i');
if (icon) {
icon.insertAdjacentElement('afterend', numberSpan);
} else { // Fallback if no icon
titleElement.prepend(numberSpan);
}
};
// --- Dynamic Numbering ---
let currentStep = 1;
const getStep = () => currentStep++;
// 1. Workflow Selection (Always visible)
updateTitleWithNumber(document.querySelector('#headingZero button strong'), getStep());
// 2. Show and configure the selected workflow's panel
const activeConfig = workflowConfigs[selectedWorkflow];
if (activeConfig) {
activeConfig.container.style.display = 'block';
updateTitleWithNumber(activeConfig.titleEl, getStep());
if (activeConfig.modeSelect) {
updateSeparatorVisibility(activeConfig.modeSelect, activeConfig.separatorGroup);
}
}
// 3. Renumber common sections
updateTitleWithNumber(aiSettingsTitle, getStep());
if (translationSettingsAccordionItem.style.display !== 'none') {
updateTitleWithNumber(translationSettingsTitle, getStep());
}
updateTitleWithNumber(glossaryGenSettingsTitle, getStep());
saveToStorage('translator_last_workflow', selectedWorkflow);
}
function updateSeparatorVisibility(modeSelect, separatorGroup) {
const selectedMode = modeSelect.value;
separatorGroup.style.display = (selectedMode === 'append' || selectedMode === 'prepend') ? 'block' : 'none';
}
/**
* [REFACTORED] Generic function to update custom language input UI.
* @param {HTMLSelectElement} selectEl - The language select element.
* @param {HTMLElement} groupEl - The container for the custom input.
* @param {HTMLInputElement} inputEl - The custom language text input.
*/
function updateCustomLangUI(selectEl, groupEl, inputEl) {
const isCustom = selectEl.value === 'custom';
groupEl.style.display = isCustom ? 'block' : 'none';
inputEl.required = isCustom;
}
// --- Other UI Update Functions ---
function updateTranslationModeUI() {
const skipTranslate = skipTranslationSwitch.checked;
aiModelSettingsContainer.style.display = skipTranslate ? 'none' : 'block';
translationSettingsAccordionItem.style.display = skipTranslate ? 'none' : 'block';
// Make inputs not required when skipping translation
baseUrlInput.required = !skipTranslate && platformSelect.value === 'custom';
apikeyInput.required = false; // Always optional
modelInput.required = !skipTranslate;
if (!skipTranslate) {
mainPlatformUpdater();
}
updateWorkflowUI(); // Call to update numbering
}
/**
* [REFACTORED] Creates a reusable function to manage platform selection UI.
* @param {object} elements - An object containing the DOM elements for this UI section.
* @param {string} storagePrefix - The prefix for localStorage keys.
* @returns {function} An updater function to be called on change.
*/
function createPlatformUIUpdater(elements, storagePrefix) {
const {
platformSelect,
apikeyInput,
modelInput,
baseUrlGroup,
baseUrlInput,
apiHref: platformApiHref,
apiHrefInfo: platformApiHrefInfo,
baseUrlDisplay
} = elements;
return () => {
const selectedPlatformValue = platformSelect.value;
apikeyInput.value = getFromStorage(`${storagePrefix}_${selectedPlatformValue}_apikey`);
modelInput.value = getFromStorage(`${storagePrefix}_${selectedPlatformValue}_model_id`);
const isCustom = selectedPlatformValue === 'custom';
baseUrlGroup.style.display = isCustom ? 'block' : 'none';
baseUrlInput.required = isCustom && !skipTranslationSwitch.checked;
baseUrlInput.value = isCustom ? getFromStorage(`${storagePrefix}_custom_base_url`) : selectedPlatformValue;
if (baseUrlDisplay) {
baseUrlDisplay.textContent = baseUrlInput.value;
}
if (platformApiHref) {
const hrefInfo = apiHrefMap[baseUrlInput.value];
if (!isCustom && hrefInfo) {
platformApiHref.href = hrefInfo[0];
platformApiHref.style.display = 'inline-block';
if (platformApiHrefInfo) {
// 使用 getText() 函数通过键来获取翻译后的文本
platformApiHrefInfo.textContent = hrefInfo[1] ? getText(hrefInfo[1]) : '';
}
} else {
platformApiHref.style.display = 'none';
if (platformApiHrefInfo) {
platformApiHrefInfo.textContent = '';
}
}
}
saveToStorage(`${storagePrefix}_last_platform`, selectedPlatformValue);
};
}
const mainPlatformUpdater = createPlatformUIUpdater({
platformSelect, apikeyInput, modelInput, baseUrlGroup, baseUrlInput, apiHref, apiHrefInfo,
baseUrlDisplay: mainBaseUrlDisplay
}, 'translator_platform');
/**
* Updates the visibility of parsing options (formula/code OCR) based on the selected engine.
* This acts as a factory for UI configuration.
* @param {string} engine The selected parsing engine ('identity', 'mineru', 'docling').
*/
function updateParsingOptionsVisibility(engine) {
const optionsConfig = {
identity: {showFormula: false, showCode: false},
mineru: {showFormula: true, showCode: false},// 显示Mineru推荐
docling: {showFormula: true, showCode: true}// 显示Docling(本地解析)
};
// Default to hiding options if engine is not in config
const config = optionsConfig[engine] || {showFormula: false, showCode: false};
formulaOcrSwitch.style.display = config.showFormula ? 'block' : 'none';
codeOcrSwitch.style.display = config.showCode ? 'block' : 'none';
}
function updateConvertEnginUI(isLanguageChange = false) {
if (!isLanguageChange) {
const selectedEngin = convertEnginSelect.value;
const isMineru = selectedEngin === 'mineru';
mineruTokenGroup.style.display = isMineru ? 'block' : 'none';
mineruModelVersionGroup.style.display = isMineru ? 'block' : 'none';
mineruTokenInput.required = isMineru;
updateParsingOptionsVisibility(selectedEngin);
if (isMineru) {
mineruTokenInput.value = getFromStorage('translator_mineru_token');
}
saveToStorage('translator_convert_engin', selectedEngin);
} else {
// Repopulate options with translated text
const savedValue = convertEnginSelect.value;
Array.from(convertEnginSelect.options).forEach(option => {
const keyMap = {
'identity': 'engineOptionIdentity',
'mineru': 'engineOptionMineru',
'docling': 'engineOptionDocling'
};
option.textContent = getText(keyMap[option.value] || option.value, option.value);
});
convertEnginSelect.value = savedValue;
}
}
// [MODIFIED] Glossary Generation UI Functions
function updateGlossaryGenUI() {
const isEnabled = glossaryGenerateEnableSwitch.checked;
glossaryAgentOptionsContainer.style.display = isEnabled ? 'block' : 'none';
if (isEnabled) {
updateGlossaryCustomConfigUI();
}
}
function updateGlossaryCustomConfigUI() {
const choice = document.querySelector('input[name="glossary_agent_config_choice"]:checked').value;
glossaryAgentCustomConfigContainer.style.display = choice === 'custom' ? 'block' : 'none';
}
const glossaryAgentPlatformUpdater = createPlatformUIUpdater({
platformSelect: glossaryAgentPlatformSelect,
apikeyInput: glossaryAgentKeyInput,
modelInput: glossaryAgentModelIdInput,
baseUrlGroup: glossaryAgentBaseUrlGroup,
baseUrlInput: glossaryAgentBaseUrlInput,
baseUrlDisplay: glossaryBaseUrlDisplay
}, 'glossary_agent_platform');
function setupGlossaryAgentPlatformUI() {
glossaryAgentPlatformSelect.innerHTML = platformSelect.innerHTML;
glossaryAgentPlatformSelect.value = getFromStorage('glossary_agent_platform_last_platform', 'https://api.302.ai/v1');
glossaryAgentPlatformUpdater();
}
function createSliderUpdater(slider, display, resetBtn, key, params) {
return () => {
const value = slider.value;
display.textContent = value;
resetBtn.style.visibility = value !== String(params[key]) ? 'visible' : 'hidden';
saveToStorage(key, value);
};
}
function setupSlider(slider, display, resetBtn, key, params) {
slider.value = getFromStorage(key, params[key]);
const updater = createSliderUpdater(slider, display, resetBtn, key, params);
slider.addEventListener('input', updater);
resetBtn.addEventListener('click', () => {
slider.value = params[key];
updater();
});
updater(); // Initial call
}
function updateTaskPlaceholderVisibility() {
noTaskPlaceholder.style.display = Object.keys(tasks).length === 0 ? 'block' : 'none';
}
function saveTaskIds() {
if (isAdminMode) return;
const submittedTaskIds = Object.values(tasks)
.map(task => task.state.backendTaskId)
.filter(id => id);
saveToStorage('active_task_ids', JSON.stringify(submittedTaskIds));
}
// --- Task Card Management ---
function createTaskCard(backendTaskId = null, restoreState = false) {
const cardId = backendTaskId || generateCardId();
if (tasks[cardId]) return; // Avoid duplicate cards
const cardFragment = taskCardTemplate.content.cloneNode(true);
const cardElement = cardFragment.querySelector('.task-card');
cardElement.dataset.cardId = cardId;
// Translate the new card immediately
const translations = i18nData[currentLang] || i18nData.zh;
cardElement.querySelectorAll('[data-i18n]').forEach(el => el.innerHTML = translations[el.dataset.i18n] || el.innerHTML);
cardElement.querySelectorAll('[data-i18n-placeholder]').forEach(el => el.placeholder = translations[el.dataset.i18nPlaceholder] || el.placeholder);
const elements = {
card: cardElement,
taskIdDisplay: cardElement.querySelector('.task-id-display'),
removeBtn: cardElement.querySelector('.remove-task-btn'),
fileInput: cardElement.querySelector('.file-input'),
fileDropArea: cardElement.querySelector('.file-drop-area'),
fileDropDefault: cardElement.querySelector('.file-drop-default'),
fileDropSelected: cardElement.querySelector('.file-drop-selected'),
fileNameDisplayWrapper: cardElement.querySelector('.file-name-display-wrapper'),
fileNameDisplay: cardElement.querySelector('.file-name-display'),
logArea: cardElement.querySelector('.log-area'),
statusMessage: cardElement.querySelector('.status-message'),
downloadButtons: cardElement.querySelector('.download-buttons'),
downloadMenuContainer: cardElement.querySelector('.download-menu-container'),
previewBtn: cardElement.querySelector('.preview-html-btn'),
startBtn: cardElement.querySelector('.start-translate-btn'),
// [NEW] Attachment elements
attachmentBtnGroup: cardElement.querySelector('.attachment-btn-group'),
attachmentMenuContainer: cardElement.querySelector('.attachment-menu-container'),
};
if (restoreState && backendTaskId) {
elements.taskIdDisplay.innerHTML = `<code>${backendTaskId}</code>`;
}
tasks[cardId] = {
elements,
state: {
backendTaskId: backendTaskId,
isTranslating: false,
file: null,
htmlUrl: null,
fileNameStem: null,
isSubmitted: restoreState,
downloads: {},
attachment: {},
workflow: null
},
intervals: {
log: null,
status: null
}
};
addEventListenersToCard(cardId);
taskContainer.prepend(cardElement);
updateTaskPlaceholderVisibility();
if (restoreState && backendTaskId) {
pollStatus(backendTaskId, true);
}
}
async function releaseTask(backendTaskId) {
try {
await fetch(`/service/release/${backendTaskId}`, {method: 'POST'});
console.log(`[${backendTaskId}] Release request sent to backend.`);
} catch (error) {
console.error(`[${backendTaskId}] Failed to send release request:`, error);
}
}
async function removeTask(cardId) {
const task = tasks[cardId];
if (!task) return;
const backendTaskId = task.state.backendTaskId;
if (backendTaskId) {
stopPolling(backendTaskId);
await releaseTask(backendTaskId);
}
task.elements.card.remove();
delete tasks[cardId];
saveTaskIds();
updateTaskPlaceholderVisibility();
}
function addEventListenersToCard(cardId) {
const {elements} = tasks[cardId];
elements.card.addEventListener('click', () => {
const task = tasks[cardId];
if (task && task.state.workflow) {
workflowTypeSelect.value = task.state.workflow;
updateWorkflowUI();
}
});
elements.removeBtn.addEventListener('click', () => removeTask(cardId));
elements.fileDropArea.addEventListener('click', () => elements.fileInput.click());
elements.fileInput.addEventListener('change', () => handleFileSelect(cardId));
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
elements.fileDropArea.addEventListener(eventName, e => {
e.preventDefault();
e.stopPropagation();
}, false);
});
['dragenter', 'dragover'].forEach(eventName => {
elements.fileDropArea.addEventListener(eventName, () => elements.fileDropArea.classList.add('drag-over'), false);
});
['dragleave', 'drop'].forEach(eventName => {
elements.fileDropArea.addEventListener(eventName, () => elements.fileDropArea.classList.remove('drag-over'), false);
});
elements.fileDropArea.addEventListener('drop', e => {
if (e.dataTransfer.files.length > 0) {
elements.fileInput.files = e.dataTransfer.files;
handleFileSelect(cardId);
}
}, false);
elements.startBtn.addEventListener('click', () => {
if (tasks[cardId].state.isTranslating) {
cancelTranslation(cardId);
} else {
startTranslation(cardId);
}
});
}
function handleFileSelect(cardId) {
const {elements, state} = tasks[cardId];
const file = elements.fileInput.files[0];
if (file) {
state.file = file;
elements.fileNameDisplay.textContent = file.name;
elements.fileNameDisplayWrapper.style.display = 'block';
elements.fileDropArea.classList.add('file-selected');
elements.fileDropDefault.style.display = 'none';
elements.fileDropSelected.style.display = 'block';
elements.fileDropArea.classList.remove('input-error');
elements.fileNameDisplay.classList.remove('input-error-text');
if (autoWorkflowSwitch.checked) {
const fileExtension = file.name.split('.').pop().toLowerCase();
const targetWorkflow = workflowExtensionMap[fileExtension] || defaultAutoWorkflow;
workflowTypeSelect.value = targetWorkflow;
updateWorkflowUI();
}
}
}
function buildGlossaryAgentConfig() {
if (!glossaryGenerateEnableSwitch.checked) {
return {config: null, isValid: true};
}
const agentChoice = document.querySelector('input[name="glossary_agent_config_choice"]:checked').value;
// The custom prompt for the glossary agent is now always visible and should always be read.
const glossaryCustomPrompt = glossaryAgentCustomPromptTextarea.value || null;
if (agentChoice === 'same') {
// [MODIFIED LOGIC]
// Construct the agent config by mirroring the main translation settings,
// but using the dedicated glossary custom prompt.
const targetLanguage = toLangSelect.value === 'custom' ? customLangInput.value.trim() : toLangSelect.value;
const agentConfig = {
base_url: baseUrlInput.value,
api_key: apikeyInput.value,
model_id: modelInput.value,
to_lang: targetLanguage,
custom_prompt: glossaryCustomPrompt, // This is the only override from the dedicated field
temperature: parseFloat(temperatureSlider.value),
concurrent: parseInt(concurrentSlider.value, 10),
retry: parseInt(retrySlider.value, 10),
thinking: document.querySelector('input[name="thinking"]:checked')?.value || 'default',
system_proxy_enable: systemProxyEnableSwitch.checked,
chunk_size: parseInt(chunkSizeSlider.value, 10) // Use main chunk size
};
// No validation needed here, as the main form validation will catch any issues.
return {config: agentConfig, isValid: true};
}
// Handle 'custom' config
let isValid = true;
const requiredAgentInputs = [glossaryAgentModelIdInput];
if (glossaryAgentPlatformSelect.value === 'custom') {
requiredAgentInputs.push(glossaryAgentBaseUrlInput);
}
requiredAgentInputs.forEach(input => {
input.classList.remove('is-invalid');
if (!input.value.trim()) {
input.classList.add('is-invalid');
isValid = false;
}
});
let glossaryTargetLanguage = glossaryAgentToLangSelect.value;
if (glossaryTargetLanguage === 'custom') {
glossaryTargetLanguage = glossaryAgentCustomLangInput.value.trim();
glossaryAgentCustomLangInput.classList.remove('is-invalid');
if (!glossaryTargetLanguage) {
glossaryAgentCustomLangInput.classList.add('is-invalid');
isValid = false;
}
}
if (!isValid) {
return {config: null, isValid: false};
}
// Build the agentConfig object from the custom form.
const agentConfig = {
base_url: glossaryAgentBaseUrlInput.value,
api_key: glossaryAgentKeyInput.value,
model_id: glossaryAgentModelIdInput.value,
to_lang: glossaryTargetLanguage,
custom_prompt: glossaryCustomPrompt, // Use value from the now-independent prompt field
temperature: parseFloat(glossaryAgentTemperatureSlider.value),
concurrent: parseInt(glossaryAgentConcurrentSlider.value, 10),
retry: parseInt(glossaryAgentRetrySlider.value, 10),
thinking: document.querySelector('input[name="glossary_agent_thinking"]:checked')?.value || 'default',
system_proxy_enable: glossaryAgentSystemProxyEnableSwitch.checked,
chunk_size: parseInt(glossaryAgentChunkSizeSlider.value, 10) // Use custom chunk size
};
return {config: agentConfig, isValid: true};
}
function validateAndBuildPayload() {
const skipTranslate = skipTranslationSwitch.checked;
let isValid = true;
// --- Common settings validation ---
if (!skipTranslate) {
// API key is not required anymore, so it's removed from validation.
const requiredCommonInputs = [modelInput];
if (platformSelect.value === 'custom') requiredCommonInputs.push(baseUrlInput);
requiredCommonInputs.forEach(input => {
input.classList.remove('is-invalid');
if (!input.value.trim()) {
input.classList.add('is-invalid');
isValid = false;
}
});
if (toLangSelect.value === 'custom') {
customLangInput.classList.remove('is-invalid');
if (!customLangInput.value.trim()) {
customLangInput.classList.add('is-invalid');
isValid = false;
}
}
}
// --- Base Payload Construction ---
const targetLanguage = toLangSelect.value === 'custom' ? customLangInput.value.trim() : toLangSelect.value;
const basePayload = {
skip_translate: skipTranslate,
base_url: baseUrlInput.value,
api_key: apikeyInput.value,
model_id: modelInput.value,
to_lang: targetLanguage,
thinking: document.querySelector('input[name="thinking"]:checked')?.value || 'disable',
chunk_size: parseInt(chunkSizeSlider.value, 10),
concurrent: parseInt(concurrentSlider.value, 10),
temperature: parseFloat(temperatureSlider.value),
retry: parseInt(retrySlider.value, 10),
custom_prompt: customPromptTranslateArea.value || null,
glossary_dict: Object.keys(glossaryData).length > 0 ? glossaryData : null,
// START: ADDED PROXY VALUE
system_proxy_enable: systemProxyEnableSwitch.checked,
// END: ADDED PROXY VALUE
};
// --- Glossary Generation Config ---
const glossaryResult = buildGlossaryAgentConfig();
if (!glossaryResult.isValid) isValid = false;
basePayload.glossary_generate_enable = glossaryGenerateEnableSwitch.checked;
basePayload.glossary_agent_config = glossaryResult.config;
// --- Workflow-specific validation and payload building ---
const workflowType = workflowTypeSelect.value;
let workflowPayload = {...basePayload, workflow_type: workflowType};
switch (workflowType) {
case 'markdown_based':
if (convertEnginSelect.value === 'mineru' && !mineruTokenInput.value.trim()) {
mineruTokenInput.classList.add('is-invalid');
isValid = false;
} else {
mineruTokenInput.classList.remove('is-invalid');
}
Object.assign(workflowPayload, {
convert_engine: convertEnginSelect.value || null,
mineru_token: mineruTokenInput.value || null,
formula_ocr: formulaCheckbox.checked,
code_ocr: codeCheckbox.checked,
model_version: modelVersionSelect.value,
});
break;
case 'json':
const jsonPaths = jsonPathsTextarea.value.trim().split('\n').map(p => p.trim()).filter(p => p);
if (jsonPaths.length === 0) {
jsonPathsTextarea.classList.add('is-invalid');
isValid = false;
} else {
jsonPathsTextarea.classList.remove('is-invalid');
}
workflowPayload.json_paths = jsonPaths;
break;
case 'xlsx':
const translateRegions = xlsxTranslateRegionsTextarea.value.trim().split('\n').map(p => p.trim()).filter(p => p);
Object.assign(workflowPayload, {
insert_mode: xlsxInsertModeSelect.value,
separator: xlsxSeparatorInput.value.replace(/\\n/g, '\n'),
translate_regions: translateRegions.length > 0 ? translateRegions : null
});
break;
case 'txt':
case 'docx':
case 'srt':
case 'epub':
case 'html':
case 'ass':
const controls = {
txt: {mode: txtInsertModeSelect, sep: txtSeparatorInput},
docx: {mode: docxInsertModeSelect, sep: docxSeparatorInput},
srt: {mode: srtInsertModeSelect, sep: srtSeparatorInput},
epub: {mode: epubInsertModeSelect, sep: epubSeparatorInput},
html: {mode: htmlInsertModeSelect, sep: htmlSeparatorInput},
ass: {mode: assInsertModeSelect, sep: assSeparatorInput},
}[workflowType];
let separatorValue = controls.sep.value;
if (workflowType !== 'ass') {
separatorValue = separatorValue.replace(/\\n/g, '\n');
}
Object.assign(workflowPayload, {
insert_mode: controls.mode.value,
separator: separatorValue
});
break;
}
return {payload: isValid ? workflowPayload : null, isValid};
}
async function startTranslation(cardId) {
const {elements, state} = tasks[cardId];
if (!state.file) {
elements.statusMessage.textContent = getText('status_selectFileFirst');
elements.statusMessage.className = 'status-message small text-danger';
elements.fileDropArea.classList.add('input-error');
return;
}
const {payload, isValid} = validateAndBuildPayload();
state.workflow = workflowTypeSelect.value; // Store the workflow type for the task
if (!isValid) {
elements.statusMessage.textContent = getText('status_fillRequired');
elements.statusMessage.className = 'status-message small text-danger';
// Find the first invalid input and scroll to it
const firstInvalid = document.querySelector('.is-invalid, .input-error');
if (firstInvalid) {
firstInvalid.scrollIntoView({behavior: 'smooth', block: 'center'});
}
return;
}
// --- Release old task and start new one ---
const oldBackendTaskId = state.backendTaskId;
if (oldBackendTaskId) {
await releaseTask(oldBackendTaskId);
state.backendTaskId = null;
}
state.isTranslating = true;
elements.startBtn.disabled = true;
elements.startBtn.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> <span data-i18n="btn_initializing">${getText('btn_initializing')}</span>`;
elements.logArea.innerHTML = '';
elements.statusMessage.textContent = getText('status_encodingAndSubmitting');
elements.statusMessage.className = 'status-message small text-muted';
elements.downloadButtons.style.display = 'none';
elements.card.querySelector('.progress')?.remove(); // Make sure no old progress bar exists
// Temporarily add a spinner next to the status message
elements.statusMessage.insertAdjacentHTML('afterend', '<div class="spinner-border spinner-border-sm ms-2" role="status" id="temp-spinner-' + cardId + '"><span class="visually-hidden">Loading...</span></div>');
try {
const fileContentBase64 = await fileToBase64(state.file);
const finalRequest = {
file_name: state.file.name,
file_content: fileContentBase64,
payload: payload
};
const response = await fetch('/service/translate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(finalRequest)
});
const result = await response.json();
if (response.ok && result.task_started) {
const backendTaskId = result.task_id;
state.backendTaskId = backendTaskId;
state.isSubmitted = true;
elements.taskIdDisplay.innerHTML = `<code>${backendTaskId}</code>`;
elements.taskIdDisplay.classList.remove('task-id-placeholder');
saveTaskIds();
elements.statusMessage.textContent = result.message || getText('status_requestOk');
elements.statusMessage.className = 'status-message small text-info';
elements.startBtn.innerHTML = `<i class="bi bi-stop-circle-fill me-1"></i><span data-i18n="btn_cancelTranslation">${getText('btn_cancelTranslation')}</span>`;
elements.startBtn.classList.replace('btn-primary', 'btn-danger');
elements.startBtn.disabled = false;
startPolling(backendTaskId);
} else {
let errorMessage = result.detail || result.message || `${getText('status_requestFail')} (${response.status})`;
if (typeof errorMessage === 'object') errorMessage = JSON.stringify(errorMessage);
throw new Error(errorMessage);
}
} catch (error) {
state.isSubmitted = false;
console.error('Request failed:', error);
elements.statusMessage.textContent = `${getText('status_initFail')}: ${error.message}`;
elements.statusMessage.className = 'status-message small text-danger';
elements.startBtn.innerHTML = `<i class="bi bi-play-fill me-1"></i><span data-i8n="taskCardStartBtn">${getText('taskCardStartBtn')}</span>`;
elements.startBtn.classList.replace('btn-danger', 'btn-primary');
elements.startBtn.disabled = false;
document.getElementById('temp-spinner-' + cardId)?.remove();
state.isTranslating = false;
}
}
async function cancelTranslation(cardId) {
const task = tasks[cardId];
if (!task || !task.state.backendTaskId) return;
const {elements} = task;
const backendTaskId = task.state.backendTaskId;
elements.startBtn.disabled = true;
elements.startBtn.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> <span data-i18n="status_cancelling">${getText('status_cancelling')}</span>`;
try {
const response = await fetch(`/service/cancel/${backendTaskId}`, {method: 'POST'});
const result = await response.json();
if (response.ok && result.cancelled) {
elements.statusMessage.textContent = result.message || getText('status_cancelSent');
elements.statusMessage.className = 'status-message small text-warning';
} else {
throw new Error(result.message || getText('status_cancelFail'));
}
} catch (error) {
elements.statusMessage.textContent = `${getText('status_cancelFail')}: ${error.message}`;
elements.statusMessage.className = 'status-message small text-danger';
elements.startBtn.disabled = false;
elements.startBtn.innerHTML = `<i class="bi bi-stop-circle-fill me-1"></i><span data-i18n="btn_cancelTranslation">${getText('btn_cancelTranslation')}</span>`;
}
}
// --- Polling ---
function startPolling(backendTaskId) {
stopPolling(backendTaskId);
const card = Object.values(tasks).find(t => t.state.backendTaskId === backendTaskId);
if (!card) return;
card.intervals.log = setInterval(() => pollLogs(backendTaskId), 2000);
card.intervals.status = setInterval(() => pollStatus(backendTaskId), 1500);
pollLogs(backendTaskId);
pollStatus(backendTaskId);
}
function stopPolling(backendTaskId) {
const card = Object.values(tasks).find(t => t.state.backendTaskId === backendTaskId);
if (!card) return;
const {intervals} = card;
if (intervals.log) clearInterval(intervals.log);
if (intervals.status) clearInterval(intervals.status);
intervals.log = null;
intervals.status = null;
}
async function pollLogs(backendTaskId) {
const card = Object.values(tasks).find(t => t.state.backendTaskId === backendTaskId);
if (!card) return;
const {elements} = card;
try {
const response = await fetch(`/service/logs/${backendTaskId}`);
if (!response.ok) return;
const data = await response.json();
if (data.logs && data.logs.length > 0) {
elements.logArea.innerHTML += data.logs.map(log => log.replace(/</g, "<").replace(/>/g, ">") + '<br>').join('');
elements.logArea.scrollTop = elements.logArea.scrollHeight;
}
} catch (error) {
console.warn(`[${backendTaskId}] Error polling logs:`, error);
}
}
async function pollStatus(backendTaskId, isRestore = false) {
const card = Object.values(tasks).find(t => t.state.backendTaskId === backendTaskId);
if (!card) {
if (isRestore) {
console.warn(`Restored task ${backendTaskId} not found in UI, removing from storage.`);
const savedTaskIds = JSON.parse(getFromStorage('active_task_ids', '[]'));
const newIds = savedTaskIds.filter(id => id !== backendTaskId);
saveToStorage('active_task_ids', JSON.stringify(newIds));
}
return;
}
const {elements, state} = card;
const cardId = elements.card.dataset.cardId;
try {
const response = await fetch(`/service/status/${backendTaskId}`);
if (!response.ok) {
if (response.status === 404 && isRestore) {
console.warn(`Task ${backendTaskId} not found on server (404). Removing from UI.`);
await removeTask(cardId);
}
return;
}
const status = await response.json();
if (status.original_filename && (!state.file || isRestore)) {
elements.fileNameDisplay.textContent = status.original_filename;
elements.fileNameDisplayWrapper.style.display = 'block';
}
elements.statusMessage.textContent = status.status_message || getText('status_gettingStatus');
elements.statusMessage.className = `status-message small ${status.error_flag ? 'text-danger' : 'text-info'}`;
if (!status.is_processing) {
pollLogs(backendTaskId); // Get final logs
stopPolling(backendTaskId);
state.isTranslating = false;
elements.startBtn.disabled = false;
elements.startBtn.innerHTML = `<i class="bi bi-arrow-clockwise me-1"></i><span data-i18n="btn_reTranslate">${getText('btn_reTranslate')}</span>`;
elements.startBtn.classList.replace('btn-danger', 'btn-primary');
document.getElementById('temp-spinner-' + cardId)?.remove();
if (status.download_ready && !status.error_flag) {
elements.statusMessage.className = 'status-message small text-success';
updateResultButtons(cardId, status);
} else {
elements.downloadButtons.style.display = 'none';
}
} else {
state.isTranslating = true;
elements.startBtn.innerHTML = `<i class="bi bi-stop-circle-fill me-1"></i><span data-i18n="btn_cancelTranslation">${getText('btn_cancelTranslation')}</span>`;
elements.startBtn.classList.replace('btn-primary', 'btn-danger');
elements.startBtn.disabled = false;
elements.downloadButtons.style.display = 'none';
if (isRestore && !card.intervals.status) {
startPolling(backendTaskId);
}
}
} catch (error) {
console.error(`[${backendTaskId}] Error polling status:`, error);
elements.statusMessage.textContent = getText('status_updateError');
elements.statusMessage.className = 'status-message small text-danger';
}
}
// --- Download and Preview ---
function populateDownloadMenu(menuElement, downloads, cardId) {
menuElement.innerHTML = ''; // Clear existing items
if (!downloadMenuTemplate) return false;
const content = downloadMenuTemplate.content.cloneNode(true);
// Translate the template before using
content.querySelectorAll('[data-i18n]').forEach(el => el.innerHTML = getText(el.dataset.i18n));
let anyLinkAdded = false;
const setupLink = (selector, key) => {
const li = content.querySelector(selector);
if (li && downloads[key]) {
li.querySelector('a').href = downloads[key];
menuElement.appendChild(li);
anyLinkAdded = true;
}
};
setupLink('.download-item-md', 'markdown');
setupLink('.download-item-md-zip', 'markdown_zip');
setupLink('.download-item-txt', 'txt');
setupLink('.download-item-json', 'json');
setupLink('.download-item-docx', 'docx');
setupLink('.download-item-xlsx', 'xlsx');
setupLink('.download-item-csv', 'csv');
setupLink('.download-item-srt', 'srt');
setupLink('.download-item-epub', 'epub');
setupLink('.download-item-ass', 'ass');
setupLink('.download-item-html', 'html');
// Special handler for PDF, which is generated on the fly
const pdfLi = content.querySelector('.download-item-pdf');
if (pdfLi && downloads.html) {
const pdfLink = pdfLi.querySelector('a');
pdfLink.href = '#';
pdfLink.onclick = (e) => {
e.preventDefault();
downloadPdf(cardId);
};
menuElement.appendChild(pdfLi);
anyLinkAdded = true;
}
return anyLinkAdded;
}
function updateResultButtons(cardId, status) {
const {elements, state} = tasks[cardId];
const {downloads, attachment} = status;
state.downloads = downloads;
state.attachment = attachment;
const {
previewBtn,
downloadButtons,
downloadMenuContainer,
attachmentBtnGroup,
attachmentMenuContainer
} = elements;
// Reset visibility of individual components first
previewBtn.style.display = 'none';
attachmentBtnGroup.style.display = 'none';
let anyDownloadAvailable = false;
let anyAttachmentAvailable = false;
let anyPreviewAvailable = false;
// Handle preview
if (downloads && downloads.html) {
state.htmlUrl = downloads.html;
state.fileNameStem = status.original_filename_stem;
previewBtn.style.display = 'inline-block';
previewBtn.onclick = () => setupPreview(cardId);
anyPreviewAvailable = true;
}
// Handle standard downloads
if (downloads) {
anyDownloadAvailable = populateDownloadMenu(downloadMenuContainer, downloads, cardId);
}
// Handle attachments
if (attachment && Object.keys(attachment).length > 0) {
attachmentMenuContainer.innerHTML = ''; // Clear previous items
Object.entries(attachment).forEach(([identifier, url]) => {
const li = document.createElement('li');
const a = document.createElement('a');
a.className = 'dropdown-item';
a.href = url;
// A simple icon and the identifier text
a.innerHTML = `<i class="bi bi-file-earmark-arrow-down me-2"></i>${identifier}`;
li.appendChild(a);
attachmentMenuContainer.appendChild(li);
});
attachmentBtnGroup.style.display = 'inline-block';
anyAttachmentAvailable = true;
}
// Set visibility of the main container
if (anyPreviewAvailable || anyDownloadAvailable || anyAttachmentAvailable) {
downloadButtons.style.display = 'flex'; // Use flex for proper alignment
} else {
downloadButtons.style.display = 'none';
}
}
function setupPreview(cardId) {
const {state} = tasks[cardId];
if (!state.htmlUrl) return;
const existingOriginalContent = originalPreviewPane.querySelector('iframe, pre, p');
if (existingOriginalContent) existingOriginalContent.remove();
translatedPreviewFrame.src = 'about:blank';
translatedPreviewFrame.srcdoc = getText('preview_loading');
setPreviewDisplayMode('bilingual');
previewOffcanvas.show();
const menu = document.getElementById('previewDownloadMenu');
populateDownloadMenu(menu, state.downloads, cardId);
if (state.file) {
const fileType = state.file.type;
const fileExtension = state.file.name.split('.').pop().toLowerCase();
const textLikeExtensions = ['md', 'json', 'xml', 'log', 'py', 'js', 'css', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'rb', 'php', 'swift', 'kt', 'go', 'rs', 'ts', 'txt', 'srt', 'ass'];
if (fileType.startsWith('text/') || textLikeExtensions.includes(fileExtension)) {
const pre = document.createElement('pre');
state.file.text()
.then(text => pre.textContent = text)
.catch(() => pre.textContent = getText('preview_cantReadOriginal'));
originalPreviewPane.appendChild(pre);
} else if (['application/pdf', 'text/html'].includes(fileType) || ['html', 'htm'].includes(fileExtension)) {
const iframe = document.createElement('iframe');
iframe.src = URL.createObjectURL(state.file);
originalPreviewPane.appendChild(iframe);
} else {
const p = document.createElement('p');
p.className = 'p-3 text-muted';
p.textContent = `${getText('preview_cantPreviewType')} (${fileType || 'unknown: ' + fileExtension}).`;
originalPreviewPane.appendChild(p);
}
} else {
const p = document.createElement('p');
p.className = 'p-3 text-muted';
p.textContent = getText('preview_noOriginalCache');
originalPreviewPane.appendChild(p);
}
fetch(state.htmlUrl)
.then(resp => resp.ok ? resp.text() : Promise.reject(`HTTP error ${resp.status}`))
.then(html => {
translatedPreviewFrame.srcdoc = html;
})
.catch(err => {
console.error('Preview fetch failed:', err);
translatedPreviewFrame.srcdoc = `${getText('preview_loadFailed')}<p>${err.message}</p>`;
});
}
function downloadPdf(cardId) {
const {elements, state} = tasks[cardId];
if (!state.htmlUrl) return;
const toast = new bootstrap.Toast(document.createElement('div'), {
autohide: true,
delay: 3000
});
toast._element.classList.add('toast', 'position-fixed', 'top-0', 'end-0', 'p-3', 'bg-info', 'text-white');
toast._element.innerHTML = `<div class="toast-body">${getText('pdf_preparing')}</div>`;
document.body.appendChild(toast._element);
toast.show();
fetch(state.htmlUrl)
.then(resp => resp.ok ? resp.text() : Promise.reject(`HTTP error ${resp.status}`))
.then(html => {
printFrameEl.onload = () => {
setTimeout(() => {
try {
printFrameEl.contentWindow.focus();
printFrameEl.contentWindow.print();
} catch (err) {
alert(getText('pdf_print_failed'));
} finally {
printFrameEl.onload = null;
printFrameEl.srcdoc = ''; // Clear content
}
}, 500);
};
printFrameEl.srcdoc = html;
})
.catch(err => {
alert(getText('pdf_fetch_failed'));
});
}
function setPreviewDisplayMode(mode) {
if (previewSplitInstance) {
previewSplitInstance.destroy();
previewSplitInstance = null;
}
const isMobileView = window.innerWidth < 992;
const splitContainer = document.querySelector('.preview-split-container');
originalPreviewContainer.style.display = 'flex';
translatedPreviewContainer.style.display = 'flex';
[originalPreviewContainer, translatedPreviewContainer].forEach(el => {
el.style.width = '';
el.style.height = '';
});
splitContainer.style.flexDirection = isMobileView ? 'column' : 'row';
if (mode === 'bilingual') {
previewOffcanvasLabel.textContent = getText('preview_bilingual');
if (isMobileView) {
previewSplitInstance = Split(['#originalPreviewContainer', '#translatedPreviewContainer'], {
direction: 'vertical', sizes: [50, 50], minSize: 150, gutterSize: 10, cursor: 'row-resize',
});
} else {
previewSplitInstance = Split(['#originalPreviewContainer', '#translatedPreviewContainer'], {
direction: 'horizontal', sizes: [50, 50], minSize: 200, gutterSize: 10, cursor: 'col-resize',
});
}
setBilingualViewBtn.classList.add('btn-primary');
setBilingualViewBtn.classList.remove('btn-outline-primary');
setTranslatedOnlyViewBtn.classList.remove('btn-primary');
setTranslatedOnlyViewBtn.classList.add('btn-outline-primary');
} else { // mode === 'translatedOnly'
previewOffcanvasLabel.textContent = getText('preview_translatedOnly');
originalPreviewContainer.style.display = 'none';
if (isMobileView) {
translatedPreviewContainer.style.height = '100%';
} else {
translatedPreviewContainer.style.width = '100%';
}
setTranslatedOnlyViewBtn.classList.add('btn-primary');
setTranslatedOnlyViewBtn.classList.remove('btn-outline-primary');
setBilingualViewBtn.classList.remove('btn-primary');
setBilingualViewBtn.classList.add('btn-outline-primary');
}
}
// --- Password Toggle Functionality ---
function setupPasswordToggle(button) {
const targetId = button.dataset.target;
const passwordInput = document.getElementById(targetId);
const icon = button.querySelector('i');
button.addEventListener('click', () => {
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
icon.classList.remove('bi-eye-slash');
icon.classList.add('bi-eye');
} else {
passwordInput.type = 'password';
icon.classList.remove('bi-eye');
icon.classList.add('bi-eye-slash');
}
});
}
// --- Initialization ---
/**
* [REFACTORED] Restores user settings from localStorage using a configuration array.
*/
function restoreSettings(settingsConfig) {
settingsConfig.forEach(setting => {
const savedValue = getFromStorage(setting.key, setting.defaultValue);
if (setting.type === 'boolean') {
setting.element.checked = savedValue === 'true';
} else if (setting.type === 'radio') {
const radioToSelect = document.querySelector(`${setting.selector}[value="${savedValue}"]`);
if (radioToSelect) radioToSelect.checked = true;
} else {
setting.element.value = savedValue;
}
});
}
/**
* [REFACTORED] Sets up event listeners for saving settings using a configuration array.
*/
function setupEventListeners(listenersConfig) {
listenersConfig.forEach(config => {
const handler = config.handler || ((e) => {
const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
if (config.saveKey) saveToStorage(config.saveKey, value);
if (config.postHook) config.postHook();
});
config.element.addEventListener(config.event, handler);
});
}
async function init() {
// Step 1: Fetch i18n data
try {
const response = await fetch("/static/i18nData.json");
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
i18nData = await response.json();
} catch (error) {
console.error("Fatal: Failed to load i18n data.", error);
currentLang = getFromStorage('ui_language') || (navigator.language.toLowerCase().startsWith('en') ? 'en' : 'zh');
alert(getText('init_i18n_failed_alert'));
return;
}
// Step 2: Fetch other backend data
isAdminMode = window.location.pathname.includes('/admin');
try {
const [metaRes, enginRes, paramsRes] = await Promise.all([
fetch("/service/meta"), fetch('/service/engin-list'), fetch("/service/default-params")
]);
if (!metaRes.ok || !enginRes.ok || !paramsRes.ok) throw new Error("Failed to fetch initial data.");
const meta = await metaRes.json();
versionDisplay.textContent = `v${meta.version}`;
const enginList = await enginRes.json();
convertEnginSelect.innerHTML = '<option value="identity"></option>'; // Default option ,选项显示"已经是markdown格式"
enginList.forEach(engin => {
const option = document.createElement('option');
option.value = engin;
convertEnginSelect.appendChild(option);
});
defaultParams = await paramsRes.json();
} catch (error) {
console.error("Initialization failed:", error);
alert(getText('init_failed_alert'));
return;
}
const settingsToRestore = [
{element: workflowTypeSelect, key: 'translator_last_workflow', defaultValue: 'markdown_based'},
{
element: autoWorkflowSwitch,
key: 'translator_auto_workflow_enabled',
defaultValue: 'true',
type: 'boolean'
},
{element: txtInsertModeSelect, key: 'translator_txt_insert_mode', defaultValue: 'replace'},
{element: txtSeparatorInput, key: 'translator_txt_separator', defaultValue: '\\n'},
{element: xlsxInsertModeSelect, key: 'translator_xlsx_insert_mode', defaultValue: 'replace'},
{element: xlsxSeparatorInput, key: 'translator_xlsx_separator', defaultValue: '\\n'},
{element: xlsxTranslateRegionsTextarea, key: 'translator_xlsx_translate_regions', defaultValue: ''},
{element: docxInsertModeSelect, key: 'translator_docx_insert_mode', defaultValue: 'replace'},
{element: docxSeparatorInput, key: 'translator_docx_separator', defaultValue: '\\n'},
{element: srtInsertModeSelect, key: 'translator_srt_insert_mode', defaultValue: 'replace'},
{element: srtSeparatorInput, key: 'translator_srt_separator', defaultValue: '\\n'},
{element: epubInsertModeSelect, key: 'translator_epub_insert_mode', defaultValue: 'replace'},
{element: epubSeparatorInput, key: 'translator_epub_separator', defaultValue: '\\n'},
{element: htmlInsertModeSelect, key: 'translator_html_insert_mode', defaultValue: 'replace'},
{element: htmlSeparatorInput, key: 'translator_html_separator', defaultValue: ' '},
{element: assInsertModeSelect, key: 'translator_ass_insert_mode', defaultValue: 'replace'},
{element: assSeparatorInput, key: 'translator_ass_separator', defaultValue: '\\N'},
{element: jsonPathsTextarea, key: 'translator_json_paths', defaultValue: ''},
{element: skipTranslationSwitch, key: 'translator_skip_translate', defaultValue: 'false', type: 'boolean'},
{
element: systemProxyEnableSwitch,
key: 'translator_system_proxy_enable',
defaultValue: 'false',
type: 'boolean'
},
{
element: platformSelect,
key: 'translator_platform_last_platform',
defaultValue: 'https://api.302.ai/v1'
},
{element: convertEnginSelect, key: 'translator_convert_engin', defaultValue: 'mineru'},
{element: modelVersionSelect, key: 'translator_model_version', defaultValue: 'vlm'},
{element: toLangSelect, key: 'translator_to_lang', defaultValue: '中文'},
{element: customLangInput, key: 'translator_custom_to_lang', defaultValue: ''},
{element: formulaCheckbox, key: 'translator_formula_ocr', defaultValue: 'true', type: 'boolean'},
{element: codeCheckbox, key: 'translator_code_ocr', defaultValue: 'true', type: 'boolean'},
{element: customPromptTranslateArea, key: 'custom_prompt', defaultValue: ''},
{
type: 'radio',
selector: '#thinkingModeBtnGroup input[name="thinking"]',
key: 'translator_thinking_mode',
defaultValue: 'disable'
},
// Glossary Gen Settings
{
element: glossaryGenerateEnableSwitch,
key: 'glossary_generate_enable',
defaultValue: 'false',
type: 'boolean'
},
{
type: 'radio',
selector: 'input[name="glossary_agent_config_choice"]',
key: 'glossary_agent_config_choice',
defaultValue: 'same'
},
{
element: glossaryAgentSystemProxyEnableSwitch,
key: 'glossary_agent_system_proxy_enable',
defaultValue: 'false',
type: 'boolean'
},
{element: glossaryAgentToLangSelect, key: 'glossary_agent_to_lang', defaultValue: '中文'},
{element: glossaryAgentCustomLangInput, key: 'glossary_agent_custom_to_lang', defaultValue: ''},
{element: glossaryAgentCustomPromptTextarea, key: 'glossary_agent_custom_prompt', defaultValue: ''},
{
type: 'radio',
selector: 'input[name="glossary_agent_thinking"]',
key: 'glossary_agent_thinking_mode',
defaultValue: 'disable'
}
];
restoreSettings(settingsToRestore);
// Step 4: Initialize i18n system. Now it is safe to call.
initI18n();
// =================================================================
// END: FIXED CODE BLOCK
// =================================================================
// Initial UI updates
updateWorkflowUI();
mainPlatformUpdater();
updateConvertEnginUI();
updateCustomLangUI(toLangSelect, customLangGroup, customLangInput);
updateTranslationModeUI();
setupGlossaryAgentPlatformUI();
updateGlossaryGenUI();
updateCustomLangUI(glossaryAgentToLangSelect, glossaryAgentCustomLangGroup, glossaryAgentCustomLangInput);
// Setup sliders
setupSlider(chunkSizeSlider, chunkSizeDisplay, chunkSizeReset, 'chunk_size', defaultParams);
setupSlider(concurrentSlider, concurrentDisplay, concurrentReset, 'concurrent', defaultParams);
setupSlider(temperatureSlider, temperatureDisplay, temperatureReset, 'temperature', defaultParams);
setupSlider(retrySlider, retryDisplay, retryReset, 'retry', defaultParams);
const agentDefaultParams = { // Agent sliders default to main config defaults
'glossary_agent_temperature': defaultParams.temperature,
'glossary_agent_chunk_size': defaultParams.chunk_size,
'glossary_agent_concurrent': defaultParams.concurrent,
'glossary_agent_retry': defaultParams.retry
};
setupSlider(glossaryAgentTemperatureSlider, glossaryAgentTemperatureDisplay, glossaryAgentTemperatureReset, 'glossary_agent_temperature', agentDefaultParams);
setupSlider(glossaryAgentChunkSizeSlider, glossaryAgentChunkSizeDisplay, glossaryAgentChunkSizeReset, 'glossary_agent_chunk_size', agentDefaultParams);
setupSlider(glossaryAgentConcurrentSlider, glossaryAgentConcurrentDisplay, glossaryAgentConcurrentReset, 'glossary_agent_concurrent', agentDefaultParams);
setupSlider(glossaryAgentRetrySlider, glossaryAgentRetryDisplay, glossaryAgentRetryReset, 'glossary_agent_retry', agentDefaultParams);
// Setup event listeners
const eventListenersConfig = [
// Main settings with save logic and UI updates
{element: workflowTypeSelect, event: 'change', handler: updateWorkflowUI},
{element: autoWorkflowSwitch, event: 'change', saveKey: 'translator_auto_workflow_enabled'},
{
element: txtInsertModeSelect,
event: 'change',
saveKey: 'translator_txt_insert_mode',
postHook: () => updateSeparatorVisibility(txtInsertModeSelect, txtSeparatorGroup)
},
{
element: xlsxInsertModeSelect,
event: 'change',
saveKey: 'translator_xlsx_insert_mode',
postHook: () => updateSeparatorVisibility(xlsxInsertModeSelect, xlsxSeparatorGroup)
},
{
element: docxInsertModeSelect,
event: 'change',
saveKey: 'translator_docx_insert_mode',
postHook: () => updateSeparatorVisibility(docxInsertModeSelect, docxSeparatorGroup)
},
{
element: srtInsertModeSelect,
event: 'change',
saveKey: 'translator_srt_insert_mode',
postHook: () => updateSeparatorVisibility(srtInsertModeSelect, srtSeparatorGroup)
},
{
element: epubInsertModeSelect,
event: 'change',
saveKey: 'translator_epub_insert_mode',
postHook: () => updateSeparatorVisibility(epubInsertModeSelect, epubSeparatorGroup)
},
{
element: htmlInsertModeSelect,
event: 'change',
saveKey: 'translator_html_insert_mode',
postHook: () => updateSeparatorVisibility(htmlInsertModeSelect, htmlSeparatorGroup)
},
{
element: assInsertModeSelect,
event: 'change',
saveKey: 'translator_ass_insert_mode',
postHook: () => updateSeparatorVisibility(assInsertModeSelect, assSeparatorGroup)
},
{
element: skipTranslationSwitch,
event: 'change',
saveKey: 'translator_skip_translate',
postHook: updateTranslationModeUI
},
{element: systemProxyEnableSwitch, event: 'change', saveKey: 'translator_system_proxy_enable'},
{element: platformSelect, event: 'change', handler: mainPlatformUpdater},
{element: convertEnginSelect, event: 'change', handler: () => updateConvertEnginUI(false)},
{
element: toLangSelect,
event: 'change',
saveKey: 'translator_to_lang',
postHook: () => updateCustomLangUI(toLangSelect, customLangGroup, customLangInput)
},
// Simple value savers
{element: txtSeparatorInput, event: 'input', saveKey: 'translator_txt_separator'},
{element: xlsxSeparatorInput, event: 'input', saveKey: 'translator_xlsx_separator'},
{element: xlsxTranslateRegionsTextarea, event: 'input', saveKey: 'translator_xlsx_translate_regions'},
{element: docxSeparatorInput, event: 'input', saveKey: 'translator_docx_separator'},
{element: srtSeparatorInput, event: 'input', saveKey: 'translator_srt_separator'},
{element: epubSeparatorInput, event: 'input', saveKey: 'translator_epub_separator'},
{element: htmlSeparatorInput, event: 'input', saveKey: 'translator_html_separator'},
{element: assSeparatorInput, event: 'input', saveKey: 'translator_ass_separator'},
{element: jsonPathsTextarea, event: 'input', saveKey: 'translator_json_paths'},
{
element: apikeyInput,
event: 'input',
handler: e => saveToStorage(`translator_platform_${platformSelect.value}_apikey`, e.target.value)
},
{
element: modelInput,
event: 'input',
handler: e => saveToStorage(`translator_platform_${platformSelect.value}_model_id`, e.target.value)
},
{
element: baseUrlInput, event: 'input', handler: e => {
const newValue = e.target.value;
// 新增下面这行来更新显示区域
mainBaseUrlDisplay.textContent = newValue;
if (platformSelect.value === 'custom') {
saveToStorage('translator_platform_custom_base_url', newValue);
}
}
},
{element: mineruTokenInput, event: 'input', saveKey: 'translator_mineru_token'},
{element: modelVersionSelect, event: 'change', saveKey: 'translator_model_version'},
{element: customLangInput, event: 'input', saveKey: 'translator_custom_to_lang'},
{element: formulaCheckbox, event: 'change', saveKey: 'translator_formula_ocr'},
{element: codeCheckbox, event: 'change', saveKey: 'translator_code_ocr'},
{element: customPromptTranslateArea, event: 'input', saveKey: 'custom_prompt'},
// Glossary Gen event listeners
{
element: glossaryGenerateEnableSwitch,
event: 'change',
saveKey: 'glossary_generate_enable',
postHook: updateGlossaryGenUI
},
{
element: glossaryAgentSystemProxyEnableSwitch,
event: 'change',
saveKey: 'glossary_agent_system_proxy_enable'
},
{element: glossaryAgentPlatformSelect, event: 'change', handler: glossaryAgentPlatformUpdater},
{
element: glossaryAgentBaseUrlInput, event: 'input', handler: e => {
const newValue = e.target.value;
// 新增下面这行来更新术语表的显示区域
glossaryBaseUrlDisplay.textContent = newValue;
if (glossaryAgentPlatformSelect.value === 'custom') {
saveToStorage('glossary_agent_platform_custom_baseurl', newValue);
}
}
},
{
element: glossaryAgentKeyInput,
event: 'input',
handler: e => saveToStorage(`glossary_agent_platform_${glossaryAgentPlatformSelect.value}_apikey`, e.target.value)
},
{
element: glossaryAgentModelIdInput,
event: 'input',
handler: e => saveToStorage(`glossary_agent_platform_${glossaryAgentPlatformSelect.value}_model_id`, e.target.value)
},
{
element: glossaryAgentToLangSelect,
event: 'change',
saveKey: 'glossary_agent_to_lang',
postHook: () => updateCustomLangUI(glossaryAgentToLangSelect, glossaryAgentCustomLangGroup, glossaryAgentCustomLangInput)
},
{element: glossaryAgentCustomLangInput, event: 'input', saveKey: 'glossary_agent_custom_to_lang'},
{element: glossaryAgentCustomPromptTextarea, event: 'input', saveKey: 'glossary_agent_custom_prompt'},
// Others
{element: glossaryFilesInput, event: 'change', handler: handleGlossaryFiles},
{
element: viewGlossaryBtn, event: 'click', handler: () => {
populateGlossaryModal();
glossaryModal.show();
}
},
{element: clearGlossaryBtn, event: 'click', handler: clearGlossary},
{element: addNewTaskBtn, event: 'click', handler: () => createTaskCard()},
{element: setBilingualViewBtn, event: 'click', handler: () => setPreviewDisplayMode('bilingual')},
{element: setTranslatedOnlyViewBtn, event: 'click', handler: () => setPreviewDisplayMode('translatedOnly')},
];
setupEventListeners(eventListenersConfig);
// Setup radio button groups separately as they are collections
document.querySelectorAll('#thinkingModeBtnGroup input[name="thinking"]').forEach(radio => radio.addEventListener('change', e => saveToStorage('translator_thinking_mode', e.target.value)));
glossaryAgentConfigChoiceRadios.forEach(radio => radio.addEventListener('change', e => {
saveToStorage('glossary_agent_config_choice', e.target.value);
updateGlossaryCustomConfigUI();
}));
glossaryAgentThinkingRadios.forEach(radio => radio.addEventListener('change', e => saveToStorage('glossary_agent_thinking_mode', e.target.value)));
document.querySelectorAll('.toggle-password').forEach(setupPasswordToggle);
if (isAdminMode) {
document.title = "DocuTranslate - Admin Panel";
try {
const response = await fetch('/service/task-list');
if (!response.ok) throw new Error(`Failed to fetch task list: ${response.statusText}`);
const allTaskIds = await response.json();
if (allTaskIds && allTaskIds.length > 0) {
allTaskIds.reverse().forEach(taskId => createTaskCard(taskId, true));
}
} catch (error) {
console.error("Admin mode: Failed to load task list.", error);
alert(getText('admin_tasklist_failed'));
}
updateTaskPlaceholderVisibility();
} else {
const savedTaskIds = JSON.parse(getFromStorage('active_task_ids', '[]'));
if (savedTaskIds.length > 0) {
savedTaskIds.forEach(taskId => createTaskCard(taskId, true));
} else {
createTaskCard();
}
}
// Add resize listener for preview
window.addEventListener('resize', () => {
if (previewOffcanvasEl.classList.contains('show')) {
const currentMode = originalPreviewContainer.style.display !== 'none' ? 'bilingual' : 'translatedOnly';
setPreviewDisplayMode(currentMode);
}
});
}
// --- [NEW & CORRECTED] CONFIG IMPORT/EXPORT LOGIC ---
/**
* Gathers all settings from the UI and local storage into a single JSON object.
* @returns {object} The configuration object.
*/
function collectAllConfigs() {
const config = {};
// Helper to get API/Model maps from storage for a given platform select element
const getPlatformMaps = (selectElement, prefix) => {
const apiKeyMap = {};
const modelIdMap = {};
Array.from(selectElement.options).forEach(option => {
const platformValue = option.value;
if (platformValue) {
const apiKey = getFromStorage(`${prefix}_${platformValue}_apikey`, '');
const modelId = getFromStorage(`${prefix}_${platformValue}_model_id`, '');
if (apiKey) apiKeyMap[platformValue] = apiKey;
if (modelId) modelIdMap[platformValue] = modelId;
}
});
return {apiKeyMap, modelIdMap};
};
// 1. Workflow Settings
config.workflow = {
workflow_type: workflowTypeSelect.value,
auto_workflow_enabled: autoWorkflowSwitch.checked,
};
// 2. Parsing Settings
config.parser = {
convert_engine: convertEnginSelect.value,
// [FIXED] Read from localStorage directly for robustness.
mineru_token: getFromStorage('translator_mineru_token', ''),
model_version: modelVersionSelect.value,
formula_ocr: formulaCheckbox.checked,
code_ocr: codeCheckbox.checked,
};
// 3. Main Translator Settings
const mainPlatformMaps = getPlatformMaps(platformSelect, 'translator_platform');
config.translator = {
skip_translation: skipTranslationSwitch.checked,
system_proxy_enable: systemProxyEnableSwitch.checked,
platform: {
selected_platform: platformSelect.value,
custom_base_url: getFromStorage('translator_platform_custom_base_url', ''),
api_key_map: mainPlatformMaps.apiKeyMap,
model_id_map: mainPlatformMaps.modelIdMap,
},
target_language: toLangSelect.value,
custom_target_language: customLangInput.value,
thinking_mode: document.querySelector('input[name="thinking"]:checked')?.value || 'disable',
custom_prompt: customPromptTranslateArea.value,
chunk_size: parseInt(chunkSizeSlider.value, 10),
concurrent_requests: parseInt(concurrentSlider.value, 10),
temperature: parseFloat(temperatureSlider.value),
retries: parseInt(retrySlider.value, 10),
};
// 4. Glossary Settings
const glossaryPlatformMaps = getPlatformMaps(glossaryAgentPlatformSelect, 'glossary_agent_platform');
config.glossary = {
generate_enable: glossaryGenerateEnableSwitch.checked,
agent_config_choice: document.querySelector('input[name="glossary_agent_config_choice"]:checked')?.value || 'same',
agent_custom_prompt: glossaryAgentCustomPromptTextarea.value,
agent_config: {
system_proxy_enable: glossaryAgentSystemProxyEnableSwitch.checked,
target_language: glossaryAgentToLangSelect.value,
custom_target_language: glossaryAgentCustomLangInput.value,
thinking_mode: document.querySelector('input[name="glossary_agent_thinking"]:checked')?.value || 'default',
chunk_size: parseInt(glossaryAgentChunkSizeSlider.value, 10),
concurrent_requests: parseInt(glossaryAgentConcurrentSlider.value, 10),
temperature: parseFloat(glossaryAgentTemperatureSlider.value),
retries: parseInt(glossaryAgentRetrySlider.value, 10),
platform: {
selected_platform: glossaryAgentPlatformSelect.value,
custom_base_url: getFromStorage('glossary_agent_platform_custom_baseurl', ''),
api_key_map: glossaryPlatformMaps.apiKeyMap,
model_id_map: glossaryPlatformMaps.modelIdMap,
}
}
};
// 5. Workflow Specific Options
config.workflow_options = {
txt: {insert_mode: txtInsertModeSelect.value, separator: txtSeparatorInput.value},
docx: {insert_mode: docxInsertModeSelect.value, separator: docxSeparatorInput.value},
xlsx: {
insert_mode: xlsxInsertModeSelect.value,
separator: xlsxSeparatorInput.value,
translate_regions: xlsxTranslateRegionsTextarea.value
},
srt: {insert_mode: srtInsertModeSelect.value, separator: srtSeparatorInput.value},
epub: {insert_mode: epubInsertModeSelect.value, separator: epubSeparatorInput.value},
html: {insert_mode: htmlInsertModeSelect.value, separator: htmlSeparatorInput.value},
ass: {insert_mode: assInsertModeSelect.value, separator: assSeparatorInput.value},
json: {json_paths: jsonPathsTextarea.value},
};
return config;
}
/**
* Applies a configuration object to the UI.
* @param {object} config The configuration object to apply.
*/
function applyAllConfigs(config) {
// Helper for safe assignment to avoid errors on malformed config files
const apply = (value, func) => {
if (value !== undefined && value !== null) {
try {
func(value);
} catch (e) {
console.warn("Failed to apply setting value:", value, e);
}
}
};
// Helper to apply API/Model maps to local storage
const applyPlatformMaps = (platformData, prefix) => {
if (!platformData) return;
apply(platformData.api_key_map, map => Object.entries(map).forEach(([p, k]) => saveToStorage(`${prefix}_${p}_apikey`, k)));
apply(platformData.model_id_map, map => Object.entries(map).forEach(([p, m]) => saveToStorage(`${prefix}_${p}_model_id`, m)));
apply(platformData.custom_base_url, v => saveToStorage(`${prefix}_custom_base_url`, v));
};
// Helper for selecting radio buttons
const selectRadio = (name, value) => {
apply(value, v => {
const radio = document.querySelector(`input[name="${name}"][value="${v}"]`);
if (radio) radio.checked = true;
});
};
// Apply settings section by section
apply(config.workflow, s => {
apply(s.workflow_type, v => workflowTypeSelect.value = v);
apply(s.auto_workflow_enabled, v => autoWorkflowSwitch.checked = v);
});
apply(config.parser, s => {
apply(s.convert_engine, v => convertEnginSelect.value = v);
// [FIXED] Also save to storage directly upon import
apply(s.mineru_token, v => {
mineruTokenInput.value = v;
saveToStorage('translator_mineru_token', v);
});
apply(s.model_version, v => modelVersionSelect.value = v);
apply(s.formula_ocr, v => formulaCheckbox.checked = v);
apply(s.code_ocr, v => codeCheckbox.checked = v);
});
apply(config.translator, s => {
applyPlatformMaps(s.platform, 'translator_platform');
apply(s.platform?.selected_platform, v => platformSelect.value = v);
apply(s.skip_translation, v => skipTranslationSwitch.checked = v);
apply(s.system_proxy_enable, v => systemProxyEnableSwitch.checked = v);
apply(s.target_language, v => toLangSelect.value = v);
apply(s.custom_target_language, v => customLangInput.value = v);
selectRadio('thinking', s.thinking_mode);
apply(s.custom_prompt, v => customPromptTranslateArea.value = v);
apply(s.chunk_size, v => chunkSizeSlider.value = v);
apply(s.concurrent_requests, v => concurrentSlider.value = v);
apply(s.temperature, v => temperatureSlider.value = v);
apply(s.retries, v => retrySlider.value = v);
});
apply(config.glossary, s => {
apply(s.generate_enable, v => glossaryGenerateEnableSwitch.checked = v);
selectRadio('glossary_agent_config_choice', s.agent_config_choice);
apply(s.agent_custom_prompt, v => glossaryAgentCustomPromptTextarea.value = v);
apply(s.agent_config, ac => {
apply(ac.system_proxy_enable, v => glossaryAgentSystemProxyEnableSwitch.checked = v);
apply(ac.target_language, v => glossaryAgentToLangSelect.value = v);
apply(ac.custom_target_language, v => glossaryAgentCustomLangInput.value = v);
selectRadio('glossary_agent_thinking', ac.thinking_mode);
apply(ac.chunk_size, v => glossaryAgentChunkSizeSlider.value = v);
apply(ac.concurrent_requests, v => glossaryAgentConcurrentSlider.value = v);
apply(ac.temperature, v => glossaryAgentTemperatureSlider.value = v);
apply(ac.retries, v => glossaryAgentRetrySlider.value = v);
applyPlatformMaps(ac.platform, 'glossary_agent_platform');
apply(ac.platform?.selected_platform, v => glossaryAgentPlatformSelect.value = v);
});
});
apply(config.workflow_options, s => {
apply(s.txt, v => {
apply(v.insert_mode, m => txtInsertModeSelect.value = m);
apply(v.separator, sp => txtSeparatorInput.value = sp);
});
apply(s.docx, v => {
apply(v.insert_mode, m => docxInsertModeSelect.value = m);
apply(v.separator, sp => docxSeparatorInput.value = sp);
});
apply(s.xlsx, v => {
apply(v.insert_mode, m => xlsxInsertModeSelect.value = m);
apply(v.separator, sp => xlsxSeparatorInput.value = sp);
apply(v.translate_regions, tr => xlsxTranslateRegionsTextarea.value = tr);
});
apply(s.srt, v => {
apply(v.insert_mode, m => srtInsertModeSelect.value = m);
apply(v.separator, sp => srtSeparatorInput.value = sp);
});
apply(s.epub, v => {
apply(v.insert_mode, m => epubInsertModeSelect.value = m);
apply(v.separator, sp => epubSeparatorInput.value = sp);
});
apply(s.html, v => {
apply(v.insert_mode, m => htmlInsertModeSelect.value = m);
apply(v.separator, sp => htmlSeparatorInput.value = sp);
});
apply(s.ass, v => {
apply(v.insert_mode, m => assInsertModeSelect.value = m);
apply(v.separator, sp => assSeparatorInput.value = sp);
});
apply(s.json, v => {
apply(v.json_paths, jp => jsonPathsTextarea.value = jp);
});
});
// --- Trigger all UI update functions to reflect changes ---
updateWorkflowUI();
mainPlatformUpdater();
updateConvertEnginUI(false);
updateTranslationModeUI();
updateCustomLangUI(toLangSelect, customLangGroup, customLangInput);
updateGlossaryGenUI();
glossaryAgentPlatformUpdater();
updateCustomLangUI(glossaryAgentToLangSelect, glossaryAgentCustomLangGroup, glossaryAgentCustomLangInput);
// Trigger slider "input" events to update their display text and reset button visibility
[
chunkSizeSlider, concurrentSlider, temperatureSlider, retrySlider,
glossaryAgentChunkSizeSlider, glossaryAgentConcurrentSlider, glossaryAgentTemperatureSlider, glossaryAgentRetrySlider
].forEach(slider => slider.dispatchEvent(new Event('input')));
// Trigger change/input events on all form elements to ensure local storage is updated
settingsForm.querySelectorAll('input, select, textarea').forEach(el => {
el.dispatchEvent(new Event('change', {bubbles: true}));
el.dispatchEvent(new Event('input', {bubbles: true}));
});
}
/**
* Handles the click event for the Export button.
*/
function handleExportConfig() {
const config = collectAllConfigs();
const configString = JSON.stringify(config, null, 2);
const blob = new Blob([configString], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'config.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Handles the file selection for the Import button.
* @param {Event} event The file input change event.
*/
function handleImportConfig(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const config = JSON.parse(e.target.result);
applyAllConfigs(config);
alert(getText('configImportSuccess'));
} catch (error) {
console.error("Failed to parse config file:", error);
alert(getText('configImportError'));
}
// Reset file input to allow re-importing the same file again
event.target.value = '';
};
reader.readAsText(file);
}
// Add event listeners for the new buttons in the init function
const importConfigBtn = document.getElementById('importConfigBtn');
const exportConfigBtn = document.getElementById('exportConfigBtn');
const configFileInput = document.getElementById('configFileInput');
importConfigBtn.addEventListener('click', () => configFileInput.click());
exportConfigBtn.addEventListener('click', handleExportConfig);
configFileInput.addEventListener('change', handleImportConfig);
// --- END OF NEW CODE BLOCK ---
// --- Theme switcher logic ---
const getPreferredTheme = () => {
const storedTheme = localStorage.getItem('theme');
if (storedTheme) {
return storedTheme;
}
return 'auto';
};
const setTheme = theme => {
if (theme === 'auto') {
document.documentElement.setAttribute('data-bs-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
} else {
document.documentElement.setAttribute('data-bs-theme', theme);
}
};
const showActiveTheme = (theme) => {
document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
element.classList.remove('active');
});
const activeButton = document.querySelector(`[data-bs-theme-value="${theme}"]`);
if (activeButton) {
activeButton.classList.add('active');
}
};
const preferredTheme = getPreferredTheme();
setTheme(preferredTheme);
showActiveTheme(preferredTheme);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const storedTheme = localStorage.getItem('theme');
if (storedTheme === 'auto' || !storedTheme) {
setTheme('auto');
}
});
document.querySelectorAll('[data-bs-theme-value]').forEach(toggle => {
toggle.addEventListener('click', () => {
const theme = toggle.getAttribute('data-bs-theme-value');
localStorage.setItem('theme', theme);
setTheme(theme);
showActiveTheme(theme);
});
});
// --- Start the application ---
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>