1 line
114 KiB
HTML
1 line
114 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en" data-bs-theme="auto">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
<title>DocuTranslate - Interactive Document Translation</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);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
@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"><i class="bi"></i>DocuTranslate</h4>
|
|
<span id="versionDisplay" class="badge bg-success me-4"></span>
|
|
<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>Tutorial
|
|
</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>Contributors
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<form id="translateForm">
|
|
<div class="accordion" id="settingsAccordion">
|
|
|
|
<!-- Workflow Selection -->
|
|
<div class="accordion-item" id="workflowSettingsContainer">
|
|
<h2 class="accordion-header" id="headingZero">
|
|
<button class="accordion-button" type="button" data-bs-toggle="collapse"
|
|
data-bs-target="#collapseZero" aria-expanded="true"
|
|
aria-controls="collapseZero">
|
|
<strong><i class="bi bi-diagram-3 me-2"></i>1. Select Workflow</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">Convert to Markdown then Translate (.pdf/.md/.png etc.)</option>
|
|
<option value="txt">Plain Text Translation (.txt)</option>
|
|
<option value="json">JSON Translation (.json)</option>
|
|
<option value="docx">DOCX Translation (.docx)</option>
|
|
<option value="xlsx">XLSX Translation (.xlsx)</option>
|
|
<option value="srt">SRT Subtitle Translation (.srt)</option>
|
|
<option value="epub">EPUB Translation (.epub)</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">Auto-select workflow</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- [NEW] DOCX Settings Container -->
|
|
<div class="accordion-item" id="docxSettingsContainer" style="display: none;">
|
|
<h2 class="accordion-header" id="headingDocx">
|
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
|
data-bs-target="#collapseDocx" aria-expanded="false"
|
|
aria-controls="collapseDocx">
|
|
<strong id="docxSettingsTitle"><i class="bi bi-file-earmark-word-fill me-2"></i>2.
|
|
DOCX Translation Options</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">Insert Mode</label>
|
|
<select class="form-select" id="docx_insert_mode" name="insert_mode">
|
|
<option value="replace">Replace Original</option>
|
|
<option value="append">Append to Original</option>
|
|
<option value="prepend">Prepend to Original</option>
|
|
</select>
|
|
<div class="form-text">Choose how to insert the translated text.</div>
|
|
</div>
|
|
<div class="mb-3" id="docxSeparatorGroup" style="display: none;">
|
|
<label for="docx_separator" class="form-label">Separator</label>
|
|
<input type="text" class="form-control" id="docx_separator" name="separator"
|
|
placeholder="e.g.: \n---Translation---\n">
|
|
<div class="form-text">
|
|
Characters to separate original and translated text when using append or prepend modes. <code>\n</code> represents a newline.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- XLSX Settings Container -->
|
|
<div class="accordion-item" id="xlsxSettingsContainer" style="display: none;">
|
|
<h2 class="accordion-header" id="headingXlsx">
|
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
|
data-bs-target="#collapseXlsx" aria-expanded="false"
|
|
aria-controls="collapseXlsx">
|
|
<strong id="xlsxSettingsTitle"><i
|
|
class="bi bi-file-earmark-spreadsheet-fill me-2"></i>2.
|
|
XLSX Translation Options</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">Insert Mode</label>
|
|
<select class="form-select" id="xlsx_insert_mode" name="insert_mode">
|
|
<option value="replace">Replace Original</option>
|
|
<option value="append">Append to Original</option>
|
|
<option value="prepend">Prepend to Original</option>
|
|
</select>
|
|
<div class="form-text">Choose how to insert the translated text into cells.</div>
|
|
</div>
|
|
<div class="mb-3" id="xlsxSeparatorGroup" style="display: none;">
|
|
<label for="xlsx_separator" class="form-label">Separator</label>
|
|
<input type="text" class="form-control" id="xlsx_separator" name="separator"
|
|
placeholder="e.g.: \n---\n">
|
|
<div class="form-text">
|
|
Characters to separate original and translated text when using append or prepend modes. <code>\n</code> represents a newline.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- [NEW] SRT Settings Container -->
|
|
<div class="accordion-item" id="srtSettingsContainer" style="display: none;">
|
|
<h2 class="accordion-header" id="headingSrt">
|
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
|
data-bs-target="#collapseSrt" aria-expanded="false"
|
|
aria-controls="collapseSrt">
|
|
<strong id="srtSettingsTitle"><i class="bi bi-filetype-srt me-2"></i>2.
|
|
SRT Translation Options</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">Insert Mode</label>
|
|
<select class="form-select" id="srt_insert_mode" name="insert_mode">
|
|
<option value="replace">Replace Original</option>
|
|
<option value="append">Append to Original</option>
|
|
<option value="prepend">Prepend to Original</option>
|
|
</select>
|
|
<div class="form-text">Choose how to insert the translated text.</div>
|
|
</div>
|
|
<div class="mb-3" id="srtSeparatorGroup" style="display: none;">
|
|
<label for="srt_separator" class="form-label">Separator</label>
|
|
<input type="text" class="form-control" id="srt_separator" name="separator"
|
|
placeholder="e.g.: \n---\n">
|
|
<div class="form-text">
|
|
Characters to separate original and translated text when using append or prepend modes. <code>\n</code> represents a newline.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- [NEW] EPUB Settings Container -->
|
|
<div class="accordion-item" id="epubSettingsContainer" style="display: none;">
|
|
<h2 class="accordion-header" id="headingEpub">
|
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
|
data-bs-target="#collapseEpub" aria-expanded="false"
|
|
aria-controls="collapseEpub">
|
|
<strong id="epubSettingsTitle"><i class="bi bi-book me-2"></i>2.
|
|
EPUB Translation Options</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">Insert Mode</label>
|
|
<select class="form-select" id="epub_insert_mode" name="insert_mode">
|
|
<option value="replace">Replace Original</option>
|
|
<option value="append">Append to Original</option>
|
|
<option value="prepend">Prepend to Original</option>
|
|
</select>
|
|
<div class="form-text">Choose how to insert the translated text.</div>
|
|
</div>
|
|
<div class="mb-3" id="epubSeparatorGroup" style="display: none;">
|
|
<label for="epub_separator" class="form-label">Separator</label>
|
|
<input type="text" class="form-control" id="epub_separator" name="separator"
|
|
placeholder="e.g.: \n---\n">
|
|
<div class="form-text">
|
|
Characters to separate original and translated text when using append or prepend modes. <code>\n</code> represents a newline.
|
|
</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>2. JSON Path Configuration</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">JSON Paths to Translate</label>
|
|
<textarea class="form-control" id="json_paths_textarea" name="json_paths"
|
|
rows="4" required
|
|
placeholder="One path per line, e.g.:
|
|
$.name
|
|
$.*"></textarea>
|
|
<div class="form-text">
|
|
Uses <code>jsonpath-ng</code> syntax. Each line represents one JSON path.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Parsing Engine Settings Container -->
|
|
<div class="accordion-item" id="parsingSettingsContainer">
|
|
<h2 class="accordion-header" id="headingOne">
|
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
|
data-bs-target="#collapseOne" aria-expanded="false" aria-controls="collapseOne">
|
|
<strong id="parsingSettingsTitle"><i class="bi bi-file-earmark-binary me-2"></i>2.
|
|
Parsing Configuration</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">Parsing Engine</label>
|
|
<select class="form-select" id="convert_engine" name="convert_engine">
|
|
<!-- Options will be populated by JS -->
|
|
</select>
|
|
<div class="form-text">This can be skipped if the uploaded file is already in .md format.</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"
|
|
title="Get 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" placeholder="Required when using Mineru engine">
|
|
<button class="btn btn-outline-secondary toggle-password" type="button"
|
|
data-target="mineru_token">
|
|
<i class="bi bi-eye-slash"></i>
|
|
</button>
|
|
</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">Formula Recognition</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">Code Recognition</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- AI Settings -->
|
|
<div class="accordion-item">
|
|
<h2 class="accordion-header" id="headingTwo">
|
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
|
data-bs-target="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo">
|
|
<strong id="aiSettingsTitle"><i class="bi bi-robot me-2"></i>3. Translation Model</strong>
|
|
</button>
|
|
</h2>
|
|
<div id="collapseTwo" class="accordion-collapse collapse" aria-labelledby="headingTwo">
|
|
<div class="accordion-body">
|
|
<div class="mb-3">
|
|
<label for="platform_select" class="form-label">Select Platform</label>
|
|
<select class="form-select" id="platform_select">
|
|
<option value="custom">Custom Endpoint</option>
|
|
<option value="https://api.openai.com/v1">OpenAI</option>
|
|
<option value="https://generativelanguage.googleapis.com/v1beta/openai/">
|
|
Gemini
|
|
</option>
|
|
<option value="https://open.bigmodel.cn/api/paas/v4">Zhipu AI</option>
|
|
<option value="https://api.deepseek.com/v1">DeepSeek</option>
|
|
<option value="https://dashscope.aliyuncs.com/compatible-mode/v1">
|
|
Alibaba Cloud Bailian
|
|
</option>
|
|
<option value="https://www.dmxapi.cn/v1">DMXAPI</option>
|
|
<option value="https://openrouter.ai/api/v1">OpenRouter</option>
|
|
<option value="https://ark.cn-beijing.volces.com/api/v3">Volcengine</option>
|
|
<option value="https://api.siliconflow.cn/v1">SiliconFlow</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3" id="baseUrlGroup">
|
|
<label for="base_url" class="form-label">API Address (Base URL)</label>
|
|
<input type="url" class="form-control" id="base_url" name="base_url" required>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="api_key" class="form-label">
|
|
API Key
|
|
<a href="#" target="_blank" class="ms-1" id="api_href"
|
|
title="Get API Key"><i class="bi bi-box-arrow-up-right"></i></a>
|
|
</label>
|
|
<div class="input-group">
|
|
<input type="password" class="form-control" id="api_key" name="api_key"
|
|
required
|
|
placeholder="Enter your 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">Model ID</label>
|
|
<input type="text" class="form-control" id="model_id" name="model_id" required
|
|
placeholder="e.g., gpt-4o, glm-4">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Translation Settings -->
|
|
<div class="accordion-item">
|
|
<h2 class="accordion-header" id="headingThree">
|
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
|
data-bs-target="#collapseThree" aria-expanded="false"
|
|
aria-controls="collapseThree">
|
|
<strong id="translationSettingsTitle"><i class="bi bi-translate me-2"></i>4. Translation Configuration</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">Target Language</label>
|
|
<select class="form-select" id="to_lang" name="to_lang">
|
|
<option value="中文">Chinese (Simplified)</option>
|
|
<option value="英文">English</option>
|
|
<option value="西班牙文">Spanish (Español)</option>
|
|
<option value="法文">French (Français)</option>
|
|
<option value="德文">German (Deutsch)</option>
|
|
<option value="日文">Japanese (日本語)</option>
|
|
<option value="韩文">Korean (한국어)</option>
|
|
<option value="俄文">Russian (Русский)</option>
|
|
<option value="葡萄牙文">Portuguese (Português)</option>
|
|
<option value="阿拉伯文">Arabic (العَرَبِيَّة)</option>
|
|
<option value="越南文">Vietnamese (tiếng Việt)</option>
|
|
<option value="custom">Other (Custom)</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" placeholder="Enter target language, e.g., Italian">
|
|
</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Thinking Mode<i
|
|
class="bi bi-question-circle ms-2 tooltip-icon"
|
|
data-bs-toggle="tooltip"
|
|
data-bs-placement="top"
|
|
data-bs-title="Set the thinking mode for mixed-inference models. Currently supports Zhipu glm-4.5 series, Alibaba Cloud qwen3 series, Volcengine Doubao-Seed-1.6 series, etc.">
|
|
</i></label>
|
|
<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">Enable</label>
|
|
|
|
<input type="radio" class="btn-check" name="thinking" id="thinkingDisable"
|
|
value="disable" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="thinkingDisable">Disable</label>
|
|
|
|
<input type="radio" class="btn-check" name="thinking" id="thinkingDefault"
|
|
value="default" autocomplete="off">
|
|
<label class="btn btn-outline-primary" for="thinkingDefault">Default</label>
|
|
</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="custom_prompt" class="form-label">Custom Prompt</label>
|
|
<textarea class="form-control" id="custom_prompt"
|
|
name="custom_prompt" rows="3"
|
|
placeholder="Optional, e.g., 'Do not translate proper names'"></textarea>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Other Settings -->
|
|
<div class="accordion-item">
|
|
<h2 class="accordion-header" id="headingFour">
|
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
|
|
data-bs-target="#collapseFour" aria-expanded="false"
|
|
aria-controls="collapseFour">
|
|
<strong id="advancedSettingsTitle"><i class="bi bi-sliders me-2"></i>5.
|
|
Advanced Parameters</strong>
|
|
</button>
|
|
</h2>
|
|
<div id="collapseFour" class="accordion-collapse collapse" aria-labelledby="headingFour">
|
|
<div class="accordion-body">
|
|
<div class="mb-3">
|
|
<label for="chunk-size-slider"
|
|
class="form-label d-flex justify-content-between">
|
|
<span>Chunk Size: <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">Reset
|
|
</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>Concurrency: <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">Reset
|
|
</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">Reset
|
|
</button>
|
|
</label>
|
|
<input type="range" class="form-range" min="0" max="2" step="0.1"
|
|
id="temperature-slider" name="temperature">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
<!-- Project Info -->
|
|
<div class="mt-4 text-left text-muted small">
|
|
<p class="bi bi-github mb-1">
|
|
GitHub Page (stars are welcome❤): <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-0">
|
|
QQ Group: 1047781902
|
|
</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>Task List</h4>
|
|
<button class="btn btn-primary" id="addNewTaskBtn"><i class="bi bi-plus-circle-fill me-2"></i>New Task
|
|
</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">
|
|
<i class="bi bi-journal-plus" style="font-size: 4rem;"></i>
|
|
<p class="mt-3">No tasks currently. Click "New Task" to get started!</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">Task ID: <code class="task-id-display"><span
|
|
class="task-id-placeholder">Awaiting submission...</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">Click or drag file here</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">File selected</p>
|
|
</div>
|
|
</div>
|
|
<div class="file-name-display-wrapper mt-2" style="display: none;">
|
|
<span class="fw-bold">File Name: </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>Log</h6>
|
|
<div class="log-area"></div>
|
|
<div class="mt-2">
|
|
<div class="status-message-container">
|
|
<span class="status-message small text-muted">Waiting for file upload...</span>
|
|
</div>
|
|
<div class="progress mt-1" role="progressbar" style="height: 5px; display: none;">
|
|
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
|
style="width: 100%"></div>
|
|
</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>Preview
|
|
</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>Download
|
|
</button>
|
|
<ul class="dropdown-menu download-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>Start Translation
|
|
</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-fill me-2"></i>Markdown (embedded images)</a></li>
|
|
<li class="download-item-md-zip"><a class="dropdown-item" href="#"><i
|
|
class="bi bi-file-zip-fill me-2"></i>Markdown (zip)</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-srt"><a class="dropdown-item" href="#"><i
|
|
class="bi bi-filetype-srt me-2"></i>SRT</a></li>
|
|
<li class="download-item-epub"><a class="dropdown-item" href="#"><i
|
|
class="bi bi-book me-2"></i>EPUB</a></li>
|
|
<li class="download-item-html"><a class="dropdown-item" href="#"><i
|
|
class="bi bi-filetype-html me-2"></i>HTML</a></li>
|
|
<li class="download-item-pdf"><a class="dropdown-item" href="#"><i
|
|
class="bi bi-file-earmark-pdf-fill 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">Preview</h5>
|
|
<div class="btn-group me-auto ms-4" role="group">
|
|
<button type="button" class="btn btn-sm btn-primary" id="setBilingualViewBtn">Bilingual</button>
|
|
<button type="button" class="btn btn-sm btn-outline-primary" id="setTranslatedOnlyViewBtn">Translation Only</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">Original</h6>
|
|
<div class="preview-pane" id="originalPreviewPane"></div>
|
|
</div>
|
|
<div id="translatedPreviewContainer" class="preview-pane-wrapper">
|
|
<h6 class="text-center text-muted small">Translation</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">Close</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>Download
|
|
</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="/">中文</a></li>
|
|
<li><a class="dropdown-item" href="/EN">English</a></li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Theme Switcher -->
|
|
<div class="dropdown">
|
|
<button class="btn btn-secondary dropdown-toggle" type="button" id="theme-switcher" data-bs-toggle="dropdown"
|
|
aria-expanded="false">
|
|
<i class="bi bi-circle-half"></i>
|
|
</button>
|
|
<ul class="dropdown-menu" aria-labelledby="theme-switcher">
|
|
<li>
|
|
<button class="dropdown-item" type="button" data-bs-theme-value="light"><i class="bi bi-sun-fill me-2"></i>
|
|
Light
|
|
</button>
|
|
</li>
|
|
<li>
|
|
<button class="dropdown-item" type="button" data-bs-theme-value="dark"><i
|
|
class="bi bi-moon-stars-fill me-2"></i> Dark
|
|
</button>
|
|
</li>
|
|
<li>
|
|
<button class="dropdown-item active" type="button" data-bs-theme-value="auto"><i
|
|
class="bi bi-circle-half me-2"></i> Auto
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<!-- Tutorial Modal -->
|
|
<div class="modal fade" id="tutorialModal" tabindex="-1" aria-labelledby="tutorialModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="tutorialModalLabel"><i class="bi bi-book-half me-2"></i>User Guide</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p><i class="bi bi-camera-video me-2"></i>Video tutorials can be found by searching for <a
|
|
href="https://search.bilibili.com/all?keyword=docutranslate" target="_blank">docutranslate</a>
|
|
on Bilibili.</p>
|
|
<p>Welcome to DocuTranslate! Please follow the steps below to translate your documents:</p>
|
|
<ol>
|
|
<li>
|
|
<strong><i class="bi bi-diagram-3 me-2"></i>Select Workflow</strong>
|
|
<p class="mt-2">
|
|
First, select the desired translation process at the top of the settings panel. Different workflows are suitable for different file types:
|
|
<ul>
|
|
<li><b>Convert to Markdown then Translate</b>: Suitable for translating PDF, Markdown, image, and other files.</li>
|
|
<li><b>Plain Text Translation</b>: For translating plain text files like <code>.txt</code>.</li>
|
|
<li><b>JSON Translation</b>: For translating specific fields in <code>.json</code> files.</li>
|
|
<li><b>DOCX Translation</b>: For translating <code>.docx</code> files.</li>
|
|
<li><b>XLSX Translation</b>: For translating <code>.xlsx</code> spreadsheet files.</li>
|
|
<li><b>SRT Subtitle Translation</b>: For translating <code>.srt</code> subtitle files.</li>
|
|
<li><b>EPUB Translation</b>: For translating <code>.epub</code> e-book files.</li>
|
|
</ul>
|
|
<div class="alert alert-info mt-2" role="alert">
|
|
<i class="bi bi-lightbulb-fill me-2"></i>New Feature: The "Auto-select workflow" switch is now enabled by default. Simply upload your file, and the system will automatically match the appropriate workflow for you, simplifying the process.
|
|
</div>
|
|
</p>
|
|
</li>
|
|
<li>
|
|
<strong><i class="bi bi-gear-fill me-2"></i>Configure Parameters</strong>
|
|
<p class="mt-2">Based on your chosen workflow, complete the corresponding configuration. All settings are automatically saved in your browser.</p>
|
|
<ul>
|
|
<li class="mb-2"><strong>Parsing Configuration</strong> (Only shown for "Convert to Markdown then Translate" workflow):
|
|
<ul class="mt-1">
|
|
<li><strong>Parsing Engine</strong>:
|
|
Select an engine to convert your file (e.g., a PDF) into a translation-friendly Markdown format. If your file is already in Markdown format, no selection is needed.
|
|
</li>
|
|
<li><strong>Mineru Token</strong>: If you choose the <code>minerU</code> engine, you need to enter your Token here.
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
<li class="mb-2"><strong>DOCX/XLSX/SRT/EPUB Translation Options</strong> (Shown for corresponding workflows):
|
|
<ul class="mt-1">
|
|
<li><strong>Insert Mode</strong>: Defines how the translation result is placed into the document or subtitle. You can choose to "Replace" the original text, "Append" after it, or "Prepend" before it.
|
|
</li>
|
|
<li><strong>Separator</strong>: When "Append" or "Prepend" mode is selected, this is used to insert a separator between the original and translated text.
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
<li class="mb-2"><strong>JSON Path Configuration</strong> (Only shown for "JSON Translation" workflow):
|
|
<ul class="mt-1">
|
|
<li><strong>JSON Paths to Translate</strong>: Enter one <a
|
|
href="https://goessner.net/articles/JsonPath/" target="_blank">JSONPath</a>
|
|
expression per line to specify the fields to be translated.
|
|
</li>
|
|
<li>For example:<code>$..description</code> translates all values for the key "description". <code>$.items[0].name</code> translates the name of the first item.
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
<li class="mb-2"><strong>Translation Model</strong>:
|
|
<ul class="mt-1">
|
|
<li><strong>Select Platform/API Address/API Key/Model ID</strong>:
|
|
Configure the AI translation service you wish to use.
|
|
</li>
|
|
<li>For Model ID, refer to the platform's documentation. It is recommended to use non-inference models or mixed-inference models (with thinking disabled).</li>
|
|
</ul>
|
|
</li>
|
|
<li class="mb-2"><strong>Translation Configuration</strong>:
|
|
<ul class="mt-1">
|
|
<li><strong>Target Language/Custom Prompt</strong>: Specify the target language and additional instructions.</li>
|
|
<li><strong>Thinking Mode</strong>: Sets whether a mixed-inference model should "think". This is supported by Zhipu's glm4.5 series, Alibaba Cloud's qwen3 series, and Volcengine's seed1.6 series. Disabling is recommended.
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
<li class="mb-2"><strong>Advanced Parameters</strong>:
|
|
<ul class="mt-1">
|
|
<li><strong>Chunk Size/Concurrency/Temperature</strong>: The chunk size, number of concurrent requests, and temperature sent to the AI. Default values are usually fine.
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
<li>
|
|
<strong><i class="bi bi-file-earmark-arrow-up-fill me-2"></i>Upload File</strong>
|
|
<p class="mt-2">In the task list on the right, click or drag your document into the file upload area.</p>
|
|
</li>
|
|
<li>
|
|
<strong><i class="bi bi-play-circle-fill me-2"></i>Start Translation</strong>
|
|
<p class="mt-2">After successfully selecting a file, click the <span
|
|
class="badge bg-primary">Start Translation</span> button in the bottom-right corner of the task card. The system will begin processing the task, and you can view real-time progress in the log area.
|
|
</p>
|
|
</li>
|
|
<li>
|
|
<strong><i class="bi bi-check-circle-fill me-2"></i>View & Download</strong>
|
|
<p class="mt-2">Once the translation is complete, action buttons will appear at the bottom of the task card:</p>
|
|
<ul>
|
|
<li><span class="badge bg-success"><i class="bi bi-eye-fill me-1"></i>Preview</span>:
|
|
Compare the original and translated text in a side panel that slides out from the right (for reference only).
|
|
</li>
|
|
<li><span class="badge bg-secondary"><i class="bi bi-download me-1"></i>Download</span>:
|
|
Download the translation in various formats, including PDF, DOCX, XLSX, HTML, and Markdown.
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
</ol>
|
|
<div class="alert alert-info mt-3" role="alert">
|
|
<i class="bi bi-info-circle-fill me-2"></i><strong>Tip</strong>: All settings are automatically saved locally in your browser for your convenience.
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">I Understand</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>Thanks for Contributing
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p>DocuTranslate is an open-source project! The community's needs and usage are the driving force behind its progress.</p>
|
|
<p>Thank you to all the friends who have sponsored the project, submitted code, provided valuable suggestions, and starred the project!</p>
|
|
<div class="alert alert-success mt-4" role="alert">
|
|
<p>You are welcome to contribute in the following ways:</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>GitHub Home
|
|
</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>Submit a Pull Request
|
|
</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>Report an Issue
|
|
</a>
|
|
</p>
|
|
<hr>
|
|
<p>Or contact the author via QQ group: <span>1047781902</span></p>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</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>
|
|
|
|
<script type="module">
|
|
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
|
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
|
|
|
|
// --- DOM Elements ---
|
|
const settingsForm = document.getElementById('translateForm');
|
|
|
|
// Workflow elements
|
|
const workflowTypeSelect = document.getElementById('workflowTypeSelect');
|
|
const autoWorkflowSwitch = document.getElementById('autoWorkflowSwitch');
|
|
const parsingSettingsContainer = document.getElementById('parsingSettingsContainer');
|
|
const parsingSettingsTitle = document.getElementById('parsingSettingsTitle');
|
|
const jsonSettingsContainer = document.getElementById('jsonSettingsContainer');
|
|
const jsonSettingsTitle = document.getElementById('jsonSettingsTitle');
|
|
const jsonPathsTextarea = document.getElementById('json_paths_textarea');
|
|
const xlsxSettingsContainer = document.getElementById('xlsxSettingsContainer');
|
|
const xlsxSettingsTitle = document.getElementById('xlsxSettingsTitle');
|
|
const xlsxInsertModeSelect = document.getElementById('xlsx_insert_mode');
|
|
const xlsxSeparatorGroup = document.getElementById('xlsxSeparatorGroup');
|
|
const xlsxSeparatorInput = document.getElementById('xlsx_separator');
|
|
const 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 aiSettingsTitle = document.getElementById('aiSettingsTitle');
|
|
const translationSettingsTitle = document.getElementById('translationSettingsTitle');
|
|
const advancedSettingsTitle = document.getElementById('advancedSettingsTitle');
|
|
|
|
// Parsing elements
|
|
const convertEnginSelect = document.getElementById('convert_engine');
|
|
const mineruTokenGroup = document.getElementById('mineruTokenGroup');
|
|
const mineruTokenInput = document.getElementById('mineru_token');
|
|
const formulaCheckbox = document.getElementById('formula_ocr');
|
|
const codeCheckbox = document.getElementById('code_ocr');
|
|
const codeOcrSwitch = document.getElementById('codeOcrSwitch');
|
|
|
|
// AI elements
|
|
const platformSelect = document.getElementById('platform_select');
|
|
const apiHref = document.getElementById('api_href');
|
|
const baseUrlGroup = document.getElementById('baseUrlGroup');
|
|
const baseUrlInput = document.getElementById('base_url');
|
|
const apikeyInput = document.getElementById('api_key');
|
|
const modelInput = document.getElementById('model_id');
|
|
|
|
// Translation elements
|
|
const toLangSelect = document.getElementById('to_lang');
|
|
const customLangGroup = document.getElementById('customLangGroup');
|
|
const customLangInput = document.getElementById('custom_to_lang');
|
|
const customPromptTranslateArea = document.getElementById("custom_prompt");
|
|
|
|
// Advanced elements
|
|
const chunkSizeSlider = document.getElementById('chunk-size-slider');
|
|
const chunkSizeDisplay = document.getElementById('chunk-size-display');
|
|
const chunkSizeReset = document.getElementById('chunk-size-reset');
|
|
const concurrentSlider = document.getElementById('concurrent-slider');
|
|
const concurrentDisplay = document.getElementById('concurrent-display');
|
|
const concurrentReset = document.getElementById("concurrent-reset");
|
|
const temperatureSlider = document.getElementById('temperature-slider');
|
|
const temperatureDisplay = document.getElementById('temperature-display');
|
|
const temperatureReset = document.getElementById("temperature-reset");
|
|
|
|
// General UI elements
|
|
const versionDisplay = document.getElementById("versionDisplay");
|
|
const addNewTaskBtn = document.getElementById('addNewTaskBtn');
|
|
const taskContainer = document.getElementById('task-container');
|
|
const noTaskPlaceholder = document.getElementById('no-task-placeholder');
|
|
const taskCardTemplate = document.getElementById('taskCardTemplate');
|
|
const downloadMenuTemplate = document.getElementById('downloadMenuTemplate');
|
|
const previewOffcanvasEl = document.getElementById('previewOffcanvas');
|
|
const previewOffcanvas = new bootstrap.Offcanvas(previewOffcanvasEl);
|
|
const previewOffcanvasLabel = document.getElementById('previewOffcanvasLabel');
|
|
const originalPreviewPane = document.getElementById('originalPreviewPane');
|
|
const translatedPreviewFrame = document.getElementById('translatedPreviewFrame');
|
|
const originalPreviewContainer = document.getElementById('originalPreviewContainer');
|
|
const translatedPreviewContainer = document.getElementById('translatedPreviewContainer');
|
|
const setBilingualViewBtn = document.getElementById('setBilingualViewBtn');
|
|
const setTranslatedOnlyViewBtn = document.getElementById('setTranslatedOnlyViewBtn');
|
|
const printFrameEl = document.getElementById('printFrame');
|
|
|
|
// --- Global State ---
|
|
let defaultParams = {};
|
|
const tasks = {}; // { cardId: { elements: {...}, state: {...}, intervals: {...} } }
|
|
let isAdminMode = false;
|
|
let previewSplitInstance = null;
|
|
|
|
const apiHrefMap = {
|
|
"https://openrouter.ai/api/v1": "https://openrouter.ai/settings/keys",
|
|
"https://api.openai.com/v1": "https://platform.openai.com/api-keys",
|
|
"https://api.deepseek.com/v1": "https://platform.deepseek.com/api_keys",
|
|
"https://open.bigmodel.cn/api/paas/v4": "https://open.bigmodel.cn/usercenter/apikeys",
|
|
"https://dashscope.aliyuncs.com/compatible-mode/v1": "https://bailian.console.aliyun.com/?tab=model#/api-key",
|
|
"https://ark.cn-beijing.volces.com/api/v3": "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D",
|
|
"https://api.siliconflow.cn/v1": "https://cloud.siliconflow.cn/account/ak",
|
|
"https://www.dmxapi.cn/v1": "https://www.dmxapi.cn/token",
|
|
"https://generativelanguage.googleapis.com/v1beta/openai/": "https://aistudio.google.com/u/0/apikey"
|
|
};
|
|
|
|
const workflowExtensionMap = {
|
|
'txt': 'txt',
|
|
'xlsx': 'xlsx',
|
|
'docx': 'docx',
|
|
'json': 'json',
|
|
'srt': 'srt',
|
|
'epub': 'epub',
|
|
};
|
|
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);
|
|
});
|
|
}
|
|
|
|
// --- UI Update Functions based on Workflow ---
|
|
function updateWorkflowUI() {
|
|
const selectedWorkflow = workflowTypeSelect.value;
|
|
|
|
// Hide all workflow-specific containers first
|
|
parsingSettingsContainer.style.display = 'none';
|
|
jsonSettingsContainer.style.display = 'none';
|
|
xlsxSettingsContainer.style.display = 'none';
|
|
docxSettingsContainer.style.display = 'none';
|
|
srtSettingsContainer.style.display = 'none';
|
|
epubSettingsContainer.style.display = 'none';
|
|
|
|
// Default numbering
|
|
let currentStep = 2;
|
|
const getStep = () => currentStep++;
|
|
|
|
switch (selectedWorkflow) {
|
|
case 'markdown_based':
|
|
parsingSettingsContainer.style.display = 'block';
|
|
parsingSettingsTitle.innerHTML = `<i class="bi bi-file-earmark-binary me-2"></i>${getStep()}. Parsing Configuration`;
|
|
break;
|
|
case 'txt':
|
|
// No specific panel, just re-number
|
|
break;
|
|
case 'json':
|
|
jsonSettingsContainer.style.display = 'block';
|
|
jsonSettingsTitle.innerHTML = `<i class="bi bi-signpost-split me-2"></i>${getStep()}. JSON Path Configuration`;
|
|
break;
|
|
case 'xlsx':
|
|
xlsxSettingsContainer.style.display = 'block';
|
|
xlsxSettingsTitle.innerHTML = `<i class="bi bi-file-earmark-spreadsheet me-2"></i>${getStep()}. XLSX Translation Options`;
|
|
updateSeparatorVisibility(xlsxInsertModeSelect, xlsxSeparatorGroup);
|
|
break;
|
|
case 'docx':
|
|
docxSettingsContainer.style.display = 'block';
|
|
docxSettingsTitle.innerHTML = `<i class="bi bi-file-earmark-word me-2"></i>${getStep()}. DOCX Translation Options`;
|
|
updateSeparatorVisibility(docxInsertModeSelect, docxSeparatorGroup);
|
|
break;
|
|
case 'srt':
|
|
srtSettingsContainer.style.display = 'block';
|
|
srtSettingsTitle.innerHTML = `<i class="bi bi-file-text me-2"></i>${getStep()}. SRT Translation Options`;
|
|
updateSeparatorVisibility(srtInsertModeSelect, srtSeparatorGroup);
|
|
break;
|
|
case 'epub':
|
|
epubSettingsContainer.style.display = 'block';
|
|
epubSettingsTitle.innerHTML = `<i class="bi bi-book me-2"></i>${getStep()}. EPUB Translation Options`;
|
|
updateSeparatorVisibility(epubInsertModeSelect, epubSeparatorGroup);
|
|
break;
|
|
}
|
|
|
|
// Renumber common sections
|
|
aiSettingsTitle.innerHTML = `<i class="bi bi-robot me-2"></i>${getStep()}. Translation Model`;
|
|
translationSettingsTitle.innerHTML = `<i class="bi bi-translate me-2"></i>${getStep()}. Translation Configuration`;
|
|
advancedSettingsTitle.innerHTML = `<i class="bi bi-sliders me-2"></i>${getStep()}. Advanced Parameters`;
|
|
|
|
saveToStorage('translator_last_workflow', selectedWorkflow);
|
|
}
|
|
|
|
function updateSeparatorVisibility(modeSelect, separatorGroup) {
|
|
const selectedMode = modeSelect.value;
|
|
if (selectedMode === 'append' || selectedMode === 'prepend') {
|
|
separatorGroup.style.display = 'block';
|
|
} else {
|
|
separatorGroup.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function updateCustomLangUI() {
|
|
const isCustom = toLangSelect.value === 'custom';
|
|
customLangGroup.style.display = isCustom ? 'block' : 'none';
|
|
customLangInput.required = isCustom;
|
|
}
|
|
|
|
// --- Other UI Update Functions ---
|
|
function updatePlatformUI() {
|
|
const selectedPlatformValue = platformSelect.value;
|
|
apikeyInput.value = getFromStorage(`translator_platform_${selectedPlatformValue}_apikey`);
|
|
modelInput.value = getFromStorage(`translator_platform_${selectedPlatformValue}_model_id`);
|
|
if (selectedPlatformValue === 'custom') {
|
|
baseUrlGroup.classList.remove('d-none');
|
|
baseUrlInput.required = true;
|
|
baseUrlInput.value = getFromStorage('translator_platform_custom_base_url');
|
|
apiHref.classList.add('d-none');
|
|
} else {
|
|
baseUrlGroup.classList.add('d-none');
|
|
baseUrlInput.required = false;
|
|
baseUrlInput.value = selectedPlatformValue;
|
|
if (apiHrefMap[baseUrlInput.value]) {
|
|
apiHref.href = apiHrefMap[baseUrlInput.value];
|
|
apiHref.classList.remove('d-none');
|
|
} else {
|
|
apiHref.classList.add('d-none');
|
|
}
|
|
}
|
|
saveToStorage('translator_last_platform', selectedPlatformValue);
|
|
}
|
|
|
|
function updateConvertEnginUI() {
|
|
const selectedEngin = convertEnginSelect.value;
|
|
mineruTokenGroup.style.display = selectedEngin === 'mineru' ? 'block' : 'none';
|
|
mineruTokenInput.required = selectedEngin === 'mineru';
|
|
codeOcrSwitch.style.display = selectedEngin === 'docling' ? 'block' : 'none';
|
|
|
|
if (selectedEngin === 'mineru') {
|
|
mineruTokenInput.value = getFromStorage('translator_mineru_token');
|
|
}
|
|
saveToStorage('translator_convert_engin', selectedEngin);
|
|
}
|
|
|
|
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;
|
|
|
|
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'),
|
|
progress: cardElement.querySelector('.progress'),
|
|
downloadButtons: cardElement.querySelector('.download-buttons'),
|
|
downloadMenuContainer: cardElement.querySelector('.download-menu-container'),
|
|
previewBtn: cardElement.querySelector('.preview-html-btn'),
|
|
startBtn: cardElement.querySelector('.start-translate-btn'),
|
|
};
|
|
|
|
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: {},
|
|
workflow: null
|
|
},
|
|
intervals: {
|
|
log: null,
|
|
status: null
|
|
}
|
|
};
|
|
|
|
addEventListenersToCard(cardId);
|
|
|
|
taskContainer.prepend(cardElement);
|
|
updateTaskPlaceholderVisibility();
|
|
|
|
if (restoreState && backendTaskId) {
|
|
pollStatus(backendTaskId, true);
|
|
}
|
|
}
|
|
|
|
async function releaseTask(backendTaskId) {
|
|
try {
|
|
await fetch(`/service/release/${backendTaskId}`, {method: 'POST'});
|
|
console.log(`[${backendTaskId}] Release request sent to backend.`);
|
|
} catch (error) {
|
|
console.error(`[${backendTaskId}] Failed to send release request:`, error);
|
|
}
|
|
}
|
|
|
|
async function removeTask(cardId) {
|
|
const task = tasks[cardId];
|
|
if (!task) return;
|
|
|
|
const backendTaskId = task.state.backendTaskId;
|
|
if (backendTaskId) {
|
|
stopPolling(backendTaskId);
|
|
await releaseTask(backendTaskId);
|
|
}
|
|
|
|
task.elements.card.remove();
|
|
delete tasks[cardId];
|
|
|
|
saveTaskIds();
|
|
updateTaskPlaceholderVisibility();
|
|
}
|
|
|
|
function addEventListenersToCard(cardId) {
|
|
const {elements} = tasks[cardId];
|
|
|
|
elements.card.addEventListener('click', () => {
|
|
const task = tasks[cardId];
|
|
if (task && task.state.workflow) {
|
|
workflowTypeSelect.value = task.state.workflow;
|
|
updateWorkflowUI();
|
|
}
|
|
});
|
|
|
|
elements.removeBtn.addEventListener('click', () => removeTask(cardId));
|
|
|
|
elements.fileDropArea.addEventListener('click', () => elements.fileInput.click());
|
|
elements.fileInput.addEventListener('change', () => handleFileSelect(cardId));
|
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
|
elements.fileDropArea.addEventListener(eventName, e => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}, false);
|
|
});
|
|
['dragenter', 'dragover'].forEach(eventName => {
|
|
elements.fileDropArea.addEventListener(eventName, () => elements.fileDropArea.classList.add('drag-over'), false);
|
|
});
|
|
['dragleave', 'drop'].forEach(eventName => {
|
|
elements.fileDropArea.addEventListener(eventName, () => elements.fileDropArea.classList.remove('drag-over'), false);
|
|
});
|
|
elements.fileDropArea.addEventListener('drop', e => {
|
|
if (e.dataTransfer.files.length > 0) {
|
|
elements.fileInput.files = e.dataTransfer.files;
|
|
handleFileSelect(cardId);
|
|
}
|
|
}, false);
|
|
|
|
elements.startBtn.addEventListener('click', () => {
|
|
if (tasks[cardId].state.isTranslating) {
|
|
cancelTranslation(cardId);
|
|
} else {
|
|
startTranslation(cardId);
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleFileSelect(cardId) {
|
|
const {elements, state} = tasks[cardId];
|
|
const file = elements.fileInput.files[0];
|
|
if (file) {
|
|
state.file = file;
|
|
elements.fileNameDisplay.textContent = file.name;
|
|
elements.fileNameDisplayWrapper.style.display = 'block';
|
|
elements.fileDropArea.classList.add('file-selected');
|
|
elements.fileDropDefault.style.display = 'none';
|
|
elements.fileDropSelected.style.display = 'block';
|
|
elements.fileDropArea.classList.remove('input-error');
|
|
elements.fileNameDisplay.classList.remove('input-error-text');
|
|
|
|
if (autoWorkflowSwitch.checked) {
|
|
const fileExtension = file.name.split('.').pop().toLowerCase();
|
|
const targetWorkflow = workflowExtensionMap[fileExtension] || defaultAutoWorkflow;
|
|
workflowTypeSelect.value = targetWorkflow;
|
|
updateWorkflowUI();
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Core Translation Logic ---
|
|
async function startTranslation(cardId) {
|
|
const {elements, state} = tasks[cardId];
|
|
|
|
// --- File and common settings validation ---
|
|
if (!state.file) {
|
|
elements.statusMessage.textContent = 'Please select a file first.';
|
|
elements.statusMessage.className = 'status-message small text-danger';
|
|
elements.fileDropArea.classList.add('input-error');
|
|
return;
|
|
}
|
|
const requiredCommonInputs = [apikeyInput, modelInput];
|
|
if (platformSelect.value === 'custom') requiredCommonInputs.push(baseUrlInput);
|
|
|
|
let isValid = true;
|
|
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;
|
|
}
|
|
}
|
|
|
|
// --- Workflow-specific validation and payload building ---
|
|
const workflowType = workflowTypeSelect.value;
|
|
state.workflow = workflowType;
|
|
let workflowPayload = {};
|
|
|
|
let targetLanguage = toLangSelect.value;
|
|
if (targetLanguage === 'custom') {
|
|
targetLanguage = customLangInput.value.trim();
|
|
}
|
|
|
|
const basePayload = {
|
|
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),
|
|
custom_prompt: customPromptTranslateArea.value || null,
|
|
};
|
|
|
|
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');
|
|
}
|
|
workflowPayload = {
|
|
...basePayload,
|
|
workflow_type: 'markdown_based',
|
|
convert_engine: convertEnginSelect.value || null,
|
|
mineru_token: mineruTokenInput.value || null,
|
|
formula_ocr: formulaCheckbox.checked,
|
|
code_ocr: codeCheckbox.checked,
|
|
};
|
|
break;
|
|
case 'txt':
|
|
workflowPayload = {
|
|
...basePayload,
|
|
workflow_type: 'txt'
|
|
};
|
|
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 = {
|
|
...basePayload,
|
|
workflow_type: 'json',
|
|
json_paths: jsonPaths,
|
|
};
|
|
break;
|
|
case 'xlsx':
|
|
workflowPayload = {
|
|
...basePayload,
|
|
workflow_type: 'xlsx',
|
|
insert_mode: xlsxInsertModeSelect.value,
|
|
separator: xlsxSeparatorInput.value.replace(/\\n/g, '\n')
|
|
};
|
|
break;
|
|
case 'docx':
|
|
workflowPayload = {
|
|
...basePayload,
|
|
workflow_type: 'docx',
|
|
insert_mode: docxInsertModeSelect.value,
|
|
separator: docxSeparatorInput.value.replace(/\\n/g, '\n')
|
|
};
|
|
break;
|
|
case 'srt':
|
|
workflowPayload = {
|
|
...basePayload,
|
|
workflow_type: 'srt',
|
|
insert_mode: srtInsertModeSelect.value,
|
|
separator: srtSeparatorInput.value.replace(/\\n/g, '\n')
|
|
};
|
|
break;
|
|
case 'epub':
|
|
workflowPayload = {
|
|
...basePayload,
|
|
workflow_type: 'epub',
|
|
insert_mode: epubInsertModeSelect.value,
|
|
separator: epubSeparatorInput.value.replace(/\\n/g, '\n')
|
|
};
|
|
break;
|
|
default:
|
|
elements.statusMessage.textContent = 'Invalid workflow type.';
|
|
elements.statusMessage.className = 'status-message small text-danger';
|
|
return;
|
|
}
|
|
|
|
if (!isValid) {
|
|
elements.statusMessage.textContent = 'Please fill in all required settings.';
|
|
elements.statusMessage.className = 'status-message small text-danger';
|
|
return;
|
|
}
|
|
|
|
// --- Release old task and start new one ---
|
|
const oldBackendTaskId = state.backendTaskId;
|
|
if (oldBackendTaskId) {
|
|
console.log(`[${oldBackendTaskId}] Re-translating. Releasing resources for the old task.`);
|
|
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> Initializing...`;
|
|
elements.logArea.innerHTML = '';
|
|
elements.statusMessage.textContent = 'Encoding file and submitting task...';
|
|
elements.statusMessage.className = 'status-message small text-muted';
|
|
elements.downloadButtons.style.display = 'none';
|
|
elements.progress.style.display = 'block';
|
|
|
|
try {
|
|
const fileContentBase64 = await fileToBase64(state.file);
|
|
const finalRequest = {
|
|
file_name: state.file.name,
|
|
file_content: fileContentBase64,
|
|
payload: workflowPayload
|
|
};
|
|
|
|
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 || 'Task started, now processing...';
|
|
elements.statusMessage.className = 'status-message small text-info';
|
|
elements.startBtn.innerHTML = `<i class="bi bi-stop-circle-fill me-1"></i>Cancel Translation`;
|
|
elements.startBtn.classList.replace('btn-primary', 'btn-danger');
|
|
elements.startBtn.disabled = false;
|
|
|
|
startPolling(backendTaskId);
|
|
} else {
|
|
let errorMessage = result.detail || result.message || `Request failed (${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 = `Startup failed: ${error.message}`;
|
|
elements.statusMessage.className = 'status-message small text-danger';
|
|
elements.startBtn.innerHTML = `<i class="bi bi-play-fill me-1"></i>Start Translation`;
|
|
elements.startBtn.classList.replace('btn-danger', 'btn-primary');
|
|
elements.startBtn.disabled = false;
|
|
elements.progress.style.display = 'none';
|
|
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> Cancelling...`;
|
|
|
|
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 || 'Cancellation request sent.';
|
|
elements.statusMessage.className = 'status-message small text-warning';
|
|
} else {
|
|
throw new Error(result.message || 'Cancellation failed');
|
|
}
|
|
} catch (error) {
|
|
elements.statusMessage.textContent = `Cancellation request failed: ${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>Cancel Translation`;
|
|
}
|
|
}
|
|
|
|
// --- 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 || 'Fetching status...';
|
|
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>Re-translate`;
|
|
elements.startBtn.classList.replace('btn-danger', 'btn-primary');
|
|
elements.progress.style.display = 'none';
|
|
|
|
if (status.download_ready && !status.error_flag) {
|
|
elements.statusMessage.className = 'status-message small text-success';
|
|
updateDownloadButtons(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>Cancel Translation`;
|
|
elements.startBtn.classList.replace('btn-primary', 'btn-danger');
|
|
elements.startBtn.disabled = false;
|
|
elements.progress.style.display = 'block';
|
|
elements.downloadButtons.style.display = 'none';
|
|
|
|
if (isRestore && !card.intervals.status) {
|
|
startPolling(backendTaskId);
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error(`[${backendTaskId}] Error polling status:`, error);
|
|
elements.statusMessage.textContent = 'Error updating status.';
|
|
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);
|
|
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-srt', 'srt');
|
|
setupLink('.download-item-epub', 'epub');
|
|
setupLink('.download-item-html', 'html');
|
|
|
|
// Special handler for PDF, which is generated on the fly
|
|
const pdfLi = content.querySelector('.download-item-pdf');
|
|
if (pdfLi && downloads.html) {
|
|
const pdfLink = pdfLi.querySelector('a');
|
|
pdfLink.href = '#';
|
|
pdfLink.onclick = (e) => {
|
|
e.preventDefault();
|
|
downloadPdf(cardId);
|
|
};
|
|
menuElement.appendChild(pdfLi);
|
|
anyLinkAdded = true;
|
|
}
|
|
|
|
return anyLinkAdded;
|
|
}
|
|
|
|
|
|
function updateDownloadButtons(cardId, status) {
|
|
const {elements, state} = tasks[cardId];
|
|
const {downloads} = status;
|
|
state.downloads = downloads; // Store for preview
|
|
|
|
const {previewBtn, downloadButtons, downloadMenuContainer} = elements;
|
|
|
|
// Reset visibility
|
|
previewBtn.style.display = 'none';
|
|
downloadButtons.style.display = 'none';
|
|
|
|
if (downloads.html) {
|
|
state.htmlUrl = downloads.html;
|
|
state.fileNameStem = status.original_filename_stem;
|
|
previewBtn.style.display = 'inline-block';
|
|
previewBtn.onclick = () => setupPreview(cardId);
|
|
}
|
|
|
|
const anyDownloadAvailable = populateDownloadMenu(downloadMenuContainer, downloads, cardId);
|
|
|
|
if (anyDownloadAvailable || downloads.html) {
|
|
downloadButtons.style.display = 'flex';
|
|
}
|
|
}
|
|
|
|
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 = '<div class="d-flex justify-content-center align-items-center h-100"><h3><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Loading translation...</h3></div>';
|
|
|
|
setPreviewDisplayMode('bilingual');
|
|
previewOffcanvas.show();
|
|
|
|
const menu = document.getElementById('previewDownloadMenu');
|
|
populateDownloadMenu(menu, state.downloads, cardId);
|
|
|
|
|
|
if (state.file) {
|
|
const fileType = state.file.type;
|
|
const fileExtension = state.file.name.split('.').pop().toLowerCase();
|
|
const textLikeExtensions = ['md', 'json', 'xml', 'log', 'py', 'js', 'css', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'rb', 'php', 'swift', 'kt', 'go', 'rs', 'ts', 'txt', 'srt'];
|
|
|
|
if (fileType.startsWith('text/') || textLikeExtensions.includes(fileExtension)) {
|
|
const pre = document.createElement('pre');
|
|
state.file.text()
|
|
.then(text => pre.textContent = text)
|
|
.catch(() => pre.textContent = 'Could not read original content.');
|
|
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 = `Direct preview is not available for this file type (${fileType || 'Unknown: ' + fileExtension}).`;
|
|
originalPreviewPane.appendChild(p);
|
|
}
|
|
} else {
|
|
const p = document.createElement('p');
|
|
p.className = 'p-3 text-muted';
|
|
p.textContent = 'Original file cache not found.';
|
|
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 = `<h3>Failed to load translation</h3><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">Preparing PDF, please wait...</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('Automatic printing failed. Please print manually from the preview.');
|
|
} finally {
|
|
printFrameEl.onload = null;
|
|
printFrameEl.srcdoc = ''; // Clear content
|
|
}
|
|
}, 500);
|
|
};
|
|
printFrameEl.srcdoc = html;
|
|
})
|
|
.catch(err => {
|
|
alert('Failed to get HTML content, cannot generate PDF.');
|
|
});
|
|
}
|
|
|
|
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 = 'Bilingual Preview';
|
|
|
|
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 = 'Translation Preview';
|
|
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 ---
|
|
async function init() {
|
|
isAdminMode = window.location.pathname === '/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 from server.");
|
|
}
|
|
const meta = await metaRes.json();
|
|
versionDisplay.textContent = `v${meta.version}`;
|
|
|
|
const enginList = await enginRes.json();
|
|
|
|
// Populate convert engin select
|
|
convertEnginSelect.innerHTML = '<option value="identity">Do not use engine (source is MD)</option>';
|
|
enginList.forEach(engin => {
|
|
const option = document.createElement('option');
|
|
option.value = engin;
|
|
let text = engin;
|
|
if (engin === 'mineru') text = 'minerU (Cloud, Recommended)';
|
|
if (engin === 'docling') text = 'Docling (Local)';
|
|
option.textContent = text;
|
|
convertEnginSelect.appendChild(option);
|
|
});
|
|
|
|
defaultParams = await paramsRes.json();
|
|
} catch (error) {
|
|
console.error("Initialization failed:", error);
|
|
alert("Page initialization failed. Please check if the backend service is running correctly and refresh the page.");
|
|
return;
|
|
}
|
|
|
|
// Restore saved settings
|
|
workflowTypeSelect.value = getFromStorage('translator_last_workflow', 'markdown_based');
|
|
autoWorkflowSwitch.checked = getFromStorage('translator_auto_workflow_enabled', 'true') === 'true';
|
|
xlsxInsertModeSelect.value = getFromStorage('translator_xlsx_insert_mode', 'replace');
|
|
xlsxSeparatorInput.value = getFromStorage('translator_xlsx_separator', '\\n');
|
|
docxInsertModeSelect.value = getFromStorage('translator_docx_insert_mode', 'replace');
|
|
docxSeparatorInput.value = getFromStorage('translator_docx_separator', '\\n');
|
|
srtInsertModeSelect.value = getFromStorage('translator_srt_insert_mode', 'replace');
|
|
srtSeparatorInput.value = getFromStorage('translator_srt_separator', '\\n');
|
|
epubInsertModeSelect.value = getFromStorage('translator_epub_insert_mode', 'replace');
|
|
epubSeparatorInput.value = getFromStorage('translator_epub_separator', '\\n');
|
|
jsonPathsTextarea.value = getFromStorage('translator_json_paths');
|
|
platformSelect.value = getFromStorage('translator_last_platform', 'https://api.openai.com/v1');
|
|
convertEnginSelect.value = getFromStorage('translator_convert_engin', 'mineru');
|
|
toLangSelect.value = getFromStorage('translator_to_lang', '中文');
|
|
customLangInput.value = getFromStorage('translator_custom_to_lang', '');
|
|
formulaCheckbox.checked = getFromStorage('translator_formula_ocr', 'true') === 'true';
|
|
codeCheckbox.checked = getFromStorage('translator_code_ocr', 'true') === 'true';
|
|
customPromptTranslateArea.value = getFromStorage("custom_prompt");
|
|
|
|
// Set thinking mode from storage or default
|
|
const savedThinkingMode = getFromStorage('translator_thinking_mode', 'default');
|
|
const thinkingRadioToSelect = document.querySelector(`#thinkingModeBtnGroup input[value="${savedThinkingMode}"]`);
|
|
if (thinkingRadioToSelect) thinkingRadioToSelect.checked = true;
|
|
|
|
// Initial UI updates
|
|
updateWorkflowUI();
|
|
updatePlatformUI();
|
|
updateConvertEnginUI();
|
|
updateCustomLangUI();
|
|
|
|
// Setup sliders
|
|
setupSlider(chunkSizeSlider, chunkSizeDisplay, chunkSizeReset, 'chunk_size', defaultParams);
|
|
setupSlider(concurrentSlider, concurrentDisplay, concurrentReset, 'concurrent', defaultParams);
|
|
setupSlider(temperatureSlider, temperatureDisplay, temperatureReset, 'temperature', defaultParams);
|
|
|
|
// Setup other UI elements
|
|
document.querySelectorAll('.toggle-password').forEach(button => setupPasswordToggle(button));
|
|
|
|
// Setup language switcher active state
|
|
const path = window.location.pathname;
|
|
const langMenu = document.getElementById('languageMenu');
|
|
if (langMenu) {
|
|
const chineseLink = langMenu.querySelector('a[href="/"]');
|
|
const englishLink = langMenu.querySelector('a[href="/EN"]');
|
|
if (path.startsWith('/EN')) {
|
|
if (englishLink) englishLink.classList.add('active');
|
|
} else {
|
|
if (chineseLink) chineseLink.classList.add('active');
|
|
}
|
|
}
|
|
|
|
// Restore tasks
|
|
if (isAdminMode) {
|
|
document.title = "DocuTranslate - Admin Panel";
|
|
try {
|
|
const response = await fetch('/service/task-list');
|
|
if (!response.ok) throw new Error(`Failed to fetch task list: ${response.statusText}`);
|
|
const allTaskIds = await response.json();
|
|
if (allTaskIds && Array.isArray(allTaskIds) && allTaskIds.length > 0) {
|
|
allTaskIds.reverse().forEach(taskId => createTaskCard(taskId, true));
|
|
}
|
|
} catch (error) {
|
|
console.error("Admin mode: Failed to load task list from server.", error);
|
|
alert("Could not load task list from server. Please check the backend connection.");
|
|
}
|
|
updateTaskPlaceholderVisibility();
|
|
} else {
|
|
const savedTaskIds = JSON.parse(getFromStorage('active_task_ids', '[]'));
|
|
if (savedTaskIds.length > 0) {
|
|
savedTaskIds.forEach(taskId => createTaskCard(taskId, true));
|
|
} else {
|
|
createTaskCard();
|
|
}
|
|
}
|
|
|
|
// Add event listeners for saving settings
|
|
workflowTypeSelect.addEventListener('change', updateWorkflowUI);
|
|
autoWorkflowSwitch.addEventListener('change', e => saveToStorage('translator_auto_workflow_enabled', e.target.checked));
|
|
xlsxInsertModeSelect.addEventListener('change', e => {
|
|
updateSeparatorVisibility(xlsxInsertModeSelect, xlsxSeparatorGroup);
|
|
saveToStorage('translator_xlsx_insert_mode', e.target.value);
|
|
});
|
|
xlsxSeparatorInput.addEventListener('input', e => saveToStorage('translator_xlsx_separator', e.target.value));
|
|
docxInsertModeSelect.addEventListener('change', e => {
|
|
updateSeparatorVisibility(docxInsertModeSelect, docxSeparatorGroup);
|
|
saveToStorage('translator_docx_insert_mode', e.target.value);
|
|
});
|
|
docxSeparatorInput.addEventListener('input', e => saveToStorage('translator_docx_separator', e.target.value));
|
|
srtInsertModeSelect.addEventListener('change', e => {
|
|
updateSeparatorVisibility(srtInsertModeSelect, srtSeparatorGroup);
|
|
saveToStorage('translator_srt_insert_mode', e.target.value);
|
|
});
|
|
srtSeparatorInput.addEventListener('input', e => saveToStorage('translator_srt_separator', e.target.value));
|
|
epubInsertModeSelect.addEventListener('change', e => {
|
|
updateSeparatorVisibility(epubInsertModeSelect, epubSeparatorGroup);
|
|
saveToStorage('translator_epub_insert_mode', e.target.value);
|
|
});
|
|
epubSeparatorInput.addEventListener('input', e => saveToStorage('translator_epub_separator', e.target.value));
|
|
jsonPathsTextarea.addEventListener('input', e => saveToStorage('translator_json_paths', e.target.value));
|
|
platformSelect.addEventListener('change', updatePlatformUI);
|
|
apikeyInput.addEventListener('input', e => saveToStorage(`translator_platform_${platformSelect.value}_apikey`, e.target.value));
|
|
modelInput.addEventListener('input', e => saveToStorage(`translator_platform_${platformSelect.value}_model_id`, e.target.value));
|
|
baseUrlInput.addEventListener('input', e => {
|
|
if (platformSelect.value === 'custom') saveToStorage('translator_platform_custom_base_url', e.target.value);
|
|
});
|
|
convertEnginSelect.addEventListener('change', updateConvertEnginUI);
|
|
mineruTokenInput.addEventListener('input', e => saveToStorage('translator_mineru_token', e.target.value));
|
|
toLangSelect.addEventListener('change', e => {
|
|
saveToStorage('translator_to_lang', e.target.value);
|
|
updateCustomLangUI();
|
|
});
|
|
customLangInput.addEventListener('input', e => saveToStorage('translator_custom_to_lang', e.target.value));
|
|
document.querySelectorAll('#thinkingModeBtnGroup input[name="thinking"]').forEach(radio => {
|
|
radio.addEventListener('change', (e) => {
|
|
saveToStorage('translator_thinking_mode', e.target.value);
|
|
});
|
|
});
|
|
formulaCheckbox.addEventListener('change', e => saveToStorage('translator_formula_ocr', e.target.checked));
|
|
codeCheckbox.addEventListener('change', e => saveToStorage('translator_code_ocr', e.target.checked));
|
|
customPromptTranslateArea.addEventListener('input', () => saveToStorage("custom_prompt", customPromptTranslateArea.value));
|
|
|
|
// Add task button listener
|
|
addNewTaskBtn.addEventListener('click', () => createTaskCard());
|
|
|
|
// Add preview listeners
|
|
setBilingualViewBtn.addEventListener('click', () => setPreviewDisplayMode('bilingual'));
|
|
setTranslatedOnlyViewBtn.addEventListener('click', () => setPreviewDisplayMode('translatedOnly'));
|
|
window.addEventListener('resize', () => {
|
|
// Re-initialize split view on resize if canvas is open
|
|
if (previewOffcanvasEl.classList.contains('show')) {
|
|
setPreviewDisplayMode(originalPreviewContainer.style.display !== 'none' ? 'bilingual' : 'translatedOnly');
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- 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> |