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