Files
docutranslate/docutranslate/static/index.html
2025-08-30 09:10:49 +08:00

1 line
175 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.
<!DOCTYPE html>
<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;
}
@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">
<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="json" data-i18n="workflowOptionJson">JSON翻译 (.json)</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="epub" data-i18n="workflowOptionEpub">EPUB翻译 (.epub)</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] 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">
<strong id="docxSettingsTitle"><i class="bi bi-file-earmark-word me-2"></i>DOCX翻译选项</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">
<strong id="xlsxSettingsTitle"><i
class="bi bi-file-earmark-spreadsheet me-2"></i>XLSX翻译选项</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">
<strong id="srtSettingsTitle"><i
class="bi bi-file-text me-2"></i>SRT翻译选项</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">
<strong id="epubSettingsTitle"><i class="bi bi-book me-2"></i>EPUB翻译选项</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">
<strong id="htmlSettingsTitle"><i class="bi bi-filetype-html me-2"></i>HTML翻译选项</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>
<!-- 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>JSON路径配置</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">
<strong id="parsingSettingsTitle"><i class="bi bi-file-earmark-binary me-2"></i>解析配置</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">
<strong id="aiSettingsTitle"><i class="bi bi-robot me-2"></i>翻译模型</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 class="mb-3">
<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.openai.com/v1">OpenAI</option>
<option value="https://generativelanguage.googleapis.com/v1beta/openai/">
Gemini
</option>
<option value="https://open.bigmodel.cn/api/paas/v4">智谱AI</option>
<option value="https://api.deepseek.com/v1">DeepSeek</option>
<option value="https://dashscope.aliyuncs.com/compatible-mode/v1">
阿里云百炼
</option>
<option value="https://www.dmxapi.cn/v1">DMXAPI</option>
<option value="https://openrouter.ai/api/v1">OpenRouter</option>
<option value="https://ark.cn-beijing.volces.com/api/v3">火山引擎
</option>
<option value="https://api.siliconflow.cn/v1">硅基流动</option>
</select>
</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>
</label>
<div class="input-group">
<input type="password" class="form-control" id="api_key" name="api_key"
required
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>
</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">
<strong id="translationSettingsTitle"><i
class="bi bi-translate me-2"></i>翻译配置</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="设置混合推理模型的思考模式目前支持智谱平台的glm-4.5系列、阿里云的qwen3系列、火山引擎的Doubao-Seed-1.6系列等,建议关闭">
</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>
</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">
<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;">
<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 class="mb-3">
<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="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 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>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
<!-- 项目信息 -->
<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-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 -->
<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">
首先,在配置面板顶部选择您需要的翻译流程。不同的工作流适用于不同类型的文件:
<ul>
<li><b>转Markdown再翻译</b>: 适用于翻译PDF、markdown、图片等文件。</li>
<li><b>纯文本翻译</b>: 用于翻译 <code>.txt</code> 等纯文本文件。</li>
<li><b>JSON翻译</b>: 用于翻译 <code>.json</code> 文件中的特定字段。</li>
<li><b>DOCX翻译</b>: 用于翻译 <code>.docx</code> 文件。</li>
<li><b>XLSX翻译</b>: 用于翻译 <code>.xlsx</code> 电子表格、 <code>.csv</code> 文件。</li>
<li><b>SRT字幕翻译</b>: 用于翻译 <code>.srt</code> 字幕文件。</li>
<li><b>EPUB翻译</b>: 用于翻译 <code>.epub</code> 电子书文件。</li>
<li><b>HTML翻译</b>: 用于翻译 <code>.html</code> 文件。</li>
</ul>
<div class="alert alert-info mt-2" role="alert">
<i class="bi bi-lightbulb-fill me-2"></i>新增功能:
"自动选择工作流"开关已默认开启。您只需上传文件,系统会自动为您匹配合适的工作流,简化操作。
</div>
</p>
</li>
<li>
<strong><i class="bi bi-gear-fill me-2"></i>配置参数</strong>
<p class="mt-2">根据您选择的工作流,完成相应的配置。所有配置项都会自动保存在您的浏览器中。</p>
<ul>
<li class="mb-2"><strong>解析配置</strong> (仅在“转Markdown再翻译”工作流下显示):
<ul class="mt-1">
<li><strong>解析引擎</strong>:
选择一个引擎将您的文件如PDF转换为适合翻译的Markdown格式。如果您的文件已经是Markdown格式则无需选择。
</li>
<li><strong>Mineru Token</strong>: 如果您选择 <code>minerU</code> 引擎需要在此处填入您的Token。
</li>
</ul>
</li>
<li class="mb-2"><strong>DOCX/XLSX/SRT/EPUB/HTML翻译选项</strong> (在对应工作流下显示):
<ul class="mt-1">
<li><strong>插入模式</strong>:
定义翻译结果如何放入文档或字幕。您可以选择直接“替换”原文,或是在原文之后“附加”,或是在原文之前“前置”。
</li>
<li><strong>分隔符</strong>: 当选择“附加”或“前置”模式时,此项用于在原文和译文之间插入分隔符。
</li>
</ul>
</li>
<li class="mb-2"><strong>JSON路径配置</strong> (仅在“JSON翻译”工作流下显示):
<ul class="mt-1">
<li><strong>需要翻译的JSON路径</strong>: 每行输入一个 <a
href="https://goessner.net/articles/JsonPath/" target="_blank">JSONPath</a>
表达式,指定需要翻译的字段。
</li>
<li>例如:<code>$..description</code>翻译所有键为description的值。<code>$.items[0].name</code>翻译第一个item的name值。
<code>$.*</code>翻译所有字符串。
</li>
</ul>
</li>
<li class="mb-2"><strong>翻译模型</strong>:
<ul class="mt-1">
<li><strong>跳过翻译</strong>:
勾选此项后将只执行文档解析和格式转换不调用AI进行翻译。
</li>
<li><strong>选择平台/API 地址/API Key/模型 ID</strong>:
配置您希望使用的AI翻译服务。
</li>
<li>模型ID参考平台文档建议使用非推理模型或混合推理模型关闭思考</li>
</ul>
</li>
<li class="mb-2"><strong>翻译配置</strong>:
<ul class="mt-1">
<li><strong>目标语言/自定义Prompt/术语表</strong>:
指定翻译的目标语言、附加指令以及用于保证特定名词翻译准确性的术语表。
</li>
<li><strong>思考模式</strong>:设置混合推理模型是否进行思考目前支持智谱的glm4.5系列、阿里云的qwen3系列、火山引擎的seed1.6系列,建议选择禁用思考。
</li>
<li><strong>分块大小/并发数/Temperature</strong>: 发给AI的分块大小、并发请求数和温度通常保持默认即可。
</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, XLSX, HTML, 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-info 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 {
el.title = text;
const tooltipInstance = bootstrap.Tooltip.getInstance(el);
if (tooltipInstance) {
tooltipInstance.setContent({'.tooltip-inner': 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
updateWorkflowUI();
updateConvertEnginUI(true);
// 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 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 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 baseUrlGroup = document.getElementById('baseUrlGroup');
const baseUrlInput = document.getElementById('base_url');
const apikeyInput = document.getElementById('api_key');
const modelInput = document.getElementById('model_id');
// 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 glossaryAgentToLangSelect = document.getElementById('glossary_agent_to_lang');
const glossaryAgentCustomLangGroup = document.getElementById('glossaryAgentCustomLangGroup');
const glossaryAgentCustomLangInput = document.getElementById('glossary_agent_custom_to_lang');
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 glossaryAgentThinkingRadios = document.querySelectorAll('input[name="glossary_agent_thinking"]');
// 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");
// 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://openrouter.ai/api/v1": "https://openrouter.ai/settings/keys",
"https://api.openai.com/v1": "https://platform.openai.com/api-keys",
"https://api.deepseek.com/v1": "https://platform.deepseek.com/api_keys",
"https://open.bigmodel.cn/api/paas/v4": "https://open.bigmodel.cn/usercenter/apikeys",
"https://dashscope.aliyuncs.com/compatible-mode/v1": "https://bailian.console.aliyun.com/?tab=model#/api-key",
"https://ark.cn-beijing.volces.com/api/v3": "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D",
"https://api.siliconflow.cn/v1": "https://cloud.siliconflow.cn/account/ak",
"https://www.dmxapi.cn/v1": "https://www.dmxapi.cn/token",
"https://generativelanguage.googleapis.com/v1beta/openai/": "https://aistudio.google.com/u/0/apikey"
};
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',
};
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 ---
// [REFACTORED] Configuration object to drive workflow UI updates
const workflowConfigs = {
'markdown_based': {
container: parsingSettingsContainer,
titleEl: parsingSettingsTitle,
titleKey: 'parsingSettingsTitleText',
icon: 'bi-file-earmark-binary'
},
'json': {
container: jsonSettingsContainer,
titleEl: jsonSettingsTitle,
titleKey: 'jsonSettingsTitleText',
icon: 'bi-signpost-split'
},
'xlsx': {
container: xlsxSettingsContainer,
titleEl: xlsxSettingsTitle,
titleKey: 'xlsxSettingsTitleText',
icon: 'bi-file-earmark-spreadsheet',
modeSelect: xlsxInsertModeSelect,
separatorGroup: xlsxSeparatorGroup
},
'docx': {
container: docxSettingsContainer,
titleEl: docxSettingsTitle,
titleKey: 'docxSettingsTitleText',
icon: 'bi-file-earmark-word',
modeSelect: docxInsertModeSelect,
separatorGroup: docxSeparatorGroup
},
'srt': {
container: srtSettingsContainer,
titleEl: srtSettingsTitle,
titleKey: 'srtSettingsTitleText',
icon: 'bi-file-text',
modeSelect: srtInsertModeSelect,
separatorGroup: srtSeparatorGroup
},
'epub': {
container: epubSettingsContainer,
titleEl: epubSettingsTitle,
titleKey: 'epubSettingsTitleText',
icon: 'bi-book',
modeSelect: epubInsertModeSelect,
separatorGroup: epubSeparatorGroup
},
'html': {
container: htmlSettingsContainer,
titleEl: htmlSettingsTitle,
titleKey: 'htmlSettingsTitleText',
icon: 'bi-filetype-html',
modeSelect: htmlInsertModeSelect,
separatorGroup: htmlSeparatorGroup
}
};
/**
* [REFACTORED] Updates the UI based on the selected workflow, using a configuration object.
*/
function updateWorkflowUI() {
const selectedWorkflow = workflowTypeSelect.value;
const translations = i18nData[currentLang] || i18nData.zh;
// Hide all workflow-specific containers
Object.values(workflowConfigs).forEach(config => {
if (config.container) config.container.style.display = 'none';
});
// --- Dynamic Numbering ---
let currentStep = 1;
const getStep = () => currentStep++;
// 1. Workflow Selection (Always visible)
document.querySelector('#headingZero button strong').innerHTML = `<i class="bi bi-diagram-3 me-2"></i>${getStep()}. ${translations.workflowTitle}`;
// 2. Show and configure the selected workflow's panel
const activeConfig = workflowConfigs[selectedWorkflow];
if (activeConfig) {
activeConfig.container.style.display = 'block';
activeConfig.titleEl.innerHTML = `<i class="bi ${activeConfig.icon} me-2"></i>${getStep()}. ${translations[activeConfig.titleKey]}`;
if (activeConfig.modeSelect) {
updateSeparatorVisibility(activeConfig.modeSelect, activeConfig.separatorGroup);
}
} else if (selectedWorkflow !== 'txt') {
// No specific panel for 'txt', so don't increment step.
}
// 3. Renumber common sections
aiSettingsTitle.innerHTML = `<i class="bi bi-robot me-2"></i>${getStep()}. ${translations.aiSettingsTitleText}`;
if (translationSettingsAccordionItem.style.display !== 'none') {
translationSettingsTitle.innerHTML = `<i class="bi bi-translate me-2"></i>${getStep()}. ${translations.translationSettingsTitleText}`;
}
glossaryGenSettingsTitle.innerHTML = `<i class="bi bi-journal-bookmark-fill me-2"></i>${getStep()}. ${translations.glossaryGenTitle}`;
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 = !skipTranslate;
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
} = 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 (platformApiHref) {
if (!isCustom && apiHrefMap[baseUrlInput.value]) {
platformApiHref.href = apiHrefMap[baseUrlInput.value];
platformApiHref.style.display = 'inline-block';
} else {
platformApiHref.style.display = 'none';
}
}
saveToStorage(`${storagePrefix}_last_platform`, selectedPlatformValue);
};
}
const mainPlatformUpdater = createPlatformUIUpdater({
platformSelect, apikeyInput, modelInput, baseUrlGroup, baseUrlInput, apiHref
}, '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},
docling: {showFormula: true, showCode: true}
};
// 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
}, 'glossary_agent_platform');
function setupGlossaryAgentPlatformUI() {
glossaryAgentPlatformSelect.innerHTML = platformSelect.innerHTML;
glossaryAgentPlatformSelect.value = getFromStorage('glossary_agent_last_platform', 'https://api.openai.com/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();
}
}
}
// --- Core Translation Logic ---
function buildGlossaryAgentConfig() {
if (!glossaryGenerateEnableSwitch.checked) {
return {config: null, isValid: true};
}
const agentChoice = document.querySelector('input[name="glossary_agent_config_choice"]:checked').value;
let isValid = true;
let agentConfig;
if (agentChoice === 'same') {
const targetLanguage = toLangSelect.value === 'custom' ? customLangInput.value.trim() : toLangSelect.value;
agentConfig = {
baseurl: baseUrlInput.value,
key: apikeyInput.value,
model_id: modelInput.value,
to_lang: targetLanguage,
temperature: parseFloat(temperatureSlider.value),
chunk_size: parseInt(chunkSizeSlider.value, 10),
max_concurrent: parseInt(concurrentSlider.value, 10),
timeout: 2000,
thinking: document.querySelector('input[name="thinking"]:checked')?.value || 'default',
};
} else { // 'custom'
const requiredAgentInputs = [glossaryAgentKeyInput, 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;
}
}
agentConfig = {
baseurl: glossaryAgentBaseUrlInput.value,
key: glossaryAgentKeyInput.value,
model_id: glossaryAgentModelIdInput.value,
to_lang: glossaryTargetLanguage,
temperature: parseFloat(glossaryAgentTemperatureSlider.value),
chunk_size: parseInt(glossaryAgentChunkSizeSlider.value, 10),
max_concurrent: parseInt(glossaryAgentConcurrentSlider.value, 10),
timeout: 2000,
thinking: document.querySelector('input[name="glossary_agent_thinking"]:checked')?.value || 'default',
};
}
return {config: agentConfig, isValid};
}
function validateAndBuildPayload() {
const skipTranslate = skipTranslationSwitch.checked;
let isValid = true;
// --- Common settings validation ---
if (!skipTranslate) {
const requiredCommonInputs = [apikeyInput, 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: skipTranslate ? null : baseUrlInput.value,
api_key: skipTranslate ? null : apikeyInput.value,
model_id: skipTranslate ? null : 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),
custom_prompt: customPromptTranslateArea.value || null,
glossary_dict: Object.keys(glossaryData).length > 0 ? glossaryData : null,
};
// --- 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 'docx':
case 'srt':
case 'epub':
case 'html':
const controls = {
docx: {mode: docxInsertModeSelect, sep: docxSeparatorInput},
srt: {mode: srtInsertModeSelect, sep: srtSeparatorInput},
epub: {mode: epubInsertModeSelect, sep: epubSeparatorInput},
html: {mode: htmlInsertModeSelect, sep: htmlSeparatorInput},
}[workflowType];
Object.assign(workflowPayload, {
insert_mode: controls.mode.value,
separator: controls.sep.value.replace(/\\n/g, '\n')
});
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-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'];
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
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;
}
// Step 3: Initialize i18n system
initI18n();
// Step 4: [REFACTORED] Restore user settings and setup event listeners using config
const settingsToRestore = [
{element: workflowTypeSelect, key: 'translator_last_workflow', defaultValue: 'markdown_based'},
{
element: autoWorkflowSwitch,
key: 'translator_auto_workflow_enabled',
defaultValue: 'true',
type: 'boolean'
},
{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: jsonPathsTextarea, key: 'translator_json_paths', defaultValue: ''},
{element: skipTranslationSwitch, key: 'translator_skip_translate', defaultValue: 'false', type: 'boolean'},
{element: platformSelect, key: 'translator_last_platform', defaultValue: 'https://api.openai.com/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: 'default'
},
// 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: glossaryAgentToLangSelect, key: 'glossary_agent_to_lang', defaultValue: '中文'},
{element: glossaryAgentCustomLangInput, key: 'glossary_agent_custom_to_lang', defaultValue: ''},
{
type: 'radio',
selector: 'input[name="glossary_agent_thinking"]',
key: 'glossary_agent_thinking_mode',
defaultValue: 'default'
}
];
restoreSettings(settingsToRestore);
// 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);
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
};
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);
// 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: 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: skipTranslationSwitch,
event: 'change',
saveKey: 'translator_skip_translate',
postHook: updateTranslationModeUI
},
{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: 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: 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 => {
if (platformSelect.value === 'custom') saveToStorage('translator_platform_custom_base_url', e.target.value);
}
},
{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: glossaryAgentPlatformSelect, event: 'change', handler: glossaryAgentPlatformUpdater},
{
element: glossaryAgentBaseUrlInput, event: 'input', handler: e => {
if (glossaryAgentPlatformSelect.value === 'custom') saveToStorage('glossary_agent_platform_custom_baseurl', e.target.value);
}
},
{
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'},
// 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);
// Restore tasks
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);
}
});
}
// --- 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>