Files
docutranslate/docutranslate/static/index.html
2025-08-05 14:42:41 +08:00

1 line
100 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN" data-bs-theme="auto">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>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);
}
.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: 100%;
}
.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;
}
.theme-switch {
position: fixed;
bottom: 1rem;
left: 1rem;
z-index: 1050;
}
@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>教程
</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>项目协作
</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-fill me-2"></i>1. 选择工作流</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">转Markdown再翻译 (.pdf/.md/.png等)</option>
<option value="txt">纯文本翻译 (.txt)</option>
<option value="json">JSON翻译 (.json)</option>
<option value="docx">DOCX翻译 (.docx)</option>
<option value="xlsx">XLSX翻译 (.xlsx)</option>
</select>
</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翻译选项</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">插入模式</label>
<select class="form-select" id="docx_insert_mode" name="insert_mode">
<option value="replace">替换原文 (Replace)</option>
<option value="append">附加到原文后 (Append)</option>
<option value="prepend">附加到原文前 (Prepend)</option>
</select>
<div class="form-text">选择如何将翻译后的文本插入。</div>
</div>
<div class="mb-3" id="docxSeparatorGroup" style="display: none;">
<label for="docx_separator" class="form-label">分隔符</label>
<input type="text" class="form-control" id="docx_separator" name="separator"
placeholder="例如: \n---翻译---\n">
<div class="form-text">
当插入模式为附加或前置时,用于分隔原文和译文的字符。<code>\n</code> 代表换行。
</div>
</div>
</div>
</div>
</div>
<!-- XLSX Settings Container -->
<div class="accordion-item" id="xlsxSettingsContainer" style="display: none;">
<h2 class="accordion-header" id="headingXlsx">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseXlsx" aria-expanded="false"
aria-controls="collapseXlsx">
<strong id="xlsxSettingsTitle"><i
class="bi bi-file-earmark-spreadsheet-fill me-2"></i>2.
XLSX翻译选项</strong>
</button>
</h2>
<div id="collapseXlsx" class="accordion-collapse collapse" aria-labelledby="headingXlsx">
<div class="accordion-body">
<div class="mb-3">
<label for="xlsx_insert_mode" class="form-label">插入模式</label>
<select class="form-select" id="xlsx_insert_mode" name="insert_mode">
<option value="replace">替换原文 (Replace)</option>
<option value="append">附加到原文后 (Append)</option>
<option value="prepend">附加到原文前 (Prepend)</option>
</select>
<div class="form-text">选择如何将翻译后的文本插入到单元格中。</div>
</div>
<div class="mb-3" id="xlsxSeparatorGroup" style="display: none;">
<label for="xlsx_separator" class="form-label">分隔符</label>
<input type="text" class="form-control" id="xlsx_separator" name="separator"
placeholder="例如: \n---\n">
<div class="form-text">
当插入模式为附加或前置时,用于分隔原文和译文的字符。<code>\n</code> 代表换行。
</div>
</div>
</div>
</div>
</div>
<!-- JSON Paths Settings Container -->
<div class="accordion-item" id="jsonSettingsContainer" style="display: none;">
<h2 class="accordion-header" id="headingJson">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseJson" aria-expanded="false"
aria-controls="collapseJson">
<strong id="jsonSettingsTitle"><i class="bi bi-signpost-split me-2"></i>2. JSON路径配置</strong>
</button>
</h2>
<div id="collapseJson" class="accordion-collapse collapse" aria-labelledby="headingJson">
<div class="accordion-body">
<div class="mb-3">
<label for="json_paths_textarea" class="form-label">需要翻译的JSON路径</label>
<textarea class="form-control" id="json_paths_textarea" name="json_paths"
rows="4" required
placeholder="每行一个路径, 例如:
$.name
$.*"></textarea>
<div class="form-text">
采用<code>jsonpath-ng</code>的路径选择语法每一行表示一个json路径
</div>
</div>
</div>
</div>
</div>
<!-- Parsing Engine Settings Container -->
<div class="accordion-item" id="parsingSettingsContainer">
<h2 class="accordion-header" id="headingOne">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#collapseOne" aria-expanded="false" aria-controls="collapseOne">
<strong id="parsingSettingsTitle"><i class="bi bi-file-earmark-binary me-2"></i>2.
解析配置</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">解析引擎</label>
<select class="form-select" id="convert_engine" name="convert_engine">
<!-- Options will be populated by JS -->
</select>
<div class="form-text">如果上传的文件本身是.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"
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" 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>
<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">公式识别</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">代码识别</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. 翻译模型</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">选择平台</label>
<select class="form-select" id="platform_select">
<option value="custom">自定义接口</option>
<option value="https://api.openai.com/v1">OpenAI</option>
<option value="https://open.bigmodel.cn/api/paas/v4">智谱AI</option>
<option value="https://api.deepseek.com/v1">DeepSeek</option>
<option value="https://dashscope.aliyuncs.com/compatible-mode/v1">
阿里云百炼
</option>
<option value="https://www.dmxapi.cn/v1">DMXAPI</option>
<option value="https://openrouter.ai/api/v1">OpenRouter</option>
<option value="https://ark.cn-beijing.volces.com/api/v3">火山引擎</option>
<option value="https://api.siliconflow.cn/v1">硅基流动</option>
</select>
</div>
<div class="mb-3" id="baseUrlGroup">
<label for="base_url" class="form-label">API 地址 (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="获取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="请输入您的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">模型 ID</label>
<input type="text" class="form-control" id="model_id" name="model_id" required
placeholder="例如: 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. 翻译配置</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">目标语言</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>
</select>
</div>
<div class="mb-3">
<label class="form-label">思考模式<i
class="bi bi-question-circle ms-2 tooltip-icon"
data-bs-toggle="tooltip"
data-bs-placement="top"
data-bs-title="设置混合推理模型的思考模式目前支持智谱平台的glm-4.5系列、阿里云的qwen3系列、火山引擎的Doubao-Seed-1.6系列等">
</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">启用</label>
<input type="radio" class="btn-check" name="thinking" id="thinkingDisable"
value="disable" autocomplete="off">
<label class="btn btn-outline-primary" for="thinkingDisable">禁用</label>
<input type="radio" class="btn-check" name="thinking" id="thinkingDefault"
value="default" autocomplete="off">
<label class="btn btn-outline-primary" for="thinkingDefault">默认</label>
</div>
</div>
<div class="mb-3">
<label for="custom_prompt" class="form-label">自定义Prompt</label>
<textarea class="form-control" id="custom_prompt"
name="custom_prompt" rows="3"
placeholder="可选,如“人名保持原文不翻译”"></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.
高级参数</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>分块大小: <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">重置
</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 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">重置
</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">重置
</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>
<!-- 项目信息 -->
<div class="mt-4 text-left text-muted small">
<p class="bi bi-github mb-1">
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-0">
交流QQ群: 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>任务列表</h4>
<button class="btn btn-primary" id="addNewTaskBtn"><i class="bi bi-plus-circle-fill me-2"></i>新建任务
</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">当前没有任务,点击“新建任务”开始吧!</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">任务 ID: <code class="task-id-display"><span
class="task-id-placeholder">等待提交...</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">点击或拖拽文件到此处</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">文件已选择</p>
</div>
</div>
<div class="file-name-display-wrapper mt-2" style="display: none;">
<span class="fw-bold">文件名: </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>日志</h6>
<div class="log-area"></div>
<div class="mt-2">
<div class="status-message-container">
<span class="status-message small text-muted">等待上传文件...</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>预览
</button>
<button class="btn btn-sm btn-info download-pdf-btn" style="display: none;"><i
class="bi bi-file-earmark-pdf-fill me-1"></i>下载 PDF
</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>下载其他格式
</button>
<ul class="dropdown-menu">
<li style="display: none;"><a class="dropdown-item download-docx-link" href="#"><i
class="bi bi-filetype-docx me-2"></i>DOCX</a></li>
<li style="display: none;"><a class="dropdown-item download-xlsx-link" href="#"><i
class="bi bi-filetype-xlsx me-2"></i>XLSX</a></li>
<li style="display: none;"><a class="dropdown-item download-html-link" href="#"><i
class="bi bi-filetype-html me-2"></i>HTML</a></li>
<li style="display: none;"><a class="dropdown-item download-markdown-link" href="#"><i
class="bi bi-markdown-fill me-2"></i>Markdown(嵌图)</a></li>
<li style="display: none;"><a class="dropdown-item download-markdown-zip-link" href="#"><i
class="bi bi-file-zip-fill me-2"></i>Markdown压缩包</a></li>
<li style="display: none;"><a class="dropdown-item download-txt-link" href="#"><i
class="bi bi-filetype-txt me-2"></i>TXT</a></li>
<li style="display: none;"><a class="dropdown-item download-json-link" href="#"><i
class="bi bi-filetype-json me-2"></i>JSON</a></li>
</ul>
</div>
</div>
<button class="btn btn-primary start-translate-btn ms-auto"><i class="bi bi-play-fill me-1"></i>开始翻译
</button>
</div>
</div>
</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">预览</h5>
<div class="btn-group me-auto ms-4" role="group">
<button type="button" class="btn btn-sm btn-primary" id="setBilingualViewBtn">双语</button>
<button type="button" class="btn btn-sm btn-outline-primary" id="setTranslatedOnlyViewBtn">仅译文</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">原文</h6>
<div class="preview-pane" id="originalPreviewPane"></div>
</div>
<div id="translatedPreviewContainer" class="preview-pane-wrapper">
<h6 class="text-center text-muted small">译文</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">关闭</button>
<button type="button" class="btn btn-primary ms-2" id="printFromPreview">
<i class="bi bi-printer-fill me-2"></i>打印/保存为PDF
</button>
</div>
</div>
</div>
<!-- Hidden iframe for direct PDF printing -->
<iframe id="printFrame" style="display: none;"></iframe>
<!-- Theme Switcher -->
<div class="dropdown theme-switch">
<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>
<!-- 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>使用教程</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>视频教程可以在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-fill me-2"></i>选择工作流</strong>
<p class="mt-2">
首先,在配置面板顶部选择您需要的翻译流程。不同的工作流适用于不同类型的文件:
<ul>
<li><b>转Markdown再翻译</b>: 适用于翻译PDF、markdown、图片等文件。</li>
<li><b>纯文本翻译</b>: 用于翻译 <code>.txt</code> 等纯文本文件。</li>
<li><b>JSON翻译</b>: 用于翻译 <code>.json</code> 文件中的特定字段。</li>
<li><b>DOCX翻译</b>: 用于翻译 <code>.docx</code> 文件。</li>
<li><b>XLSX翻译</b>: 用于翻译 <code>.xlsx</code> 电子表格文件。</li>
</ul>
</p>
</li>
<li>
<strong><i class="bi bi-gear-fill me-2"></i>配置参数</strong>
<p class="mt-2">根据您选择的工作流,完成相应的配置。所有配置项都会自动保存在您的浏览器中。</p>
<ul>
<li class="mb-2"><strong>解析配置</strong> (仅在“转Markdown再翻译”工作流下显示):
<ul class="mt-1">
<li><strong>解析引擎</strong>:
选择一个引擎将您的文件如PDF转换为适合翻译的Markdown格式。如果您的文件已经是Markdown格式则无需选择。
</li>
<li><strong>Mineru Token</strong>: 如果您选择 <code>minerU</code> 引擎需要在此处填入您的Token。
</li>
</ul>
</li>
<li class="mb-2"><strong>DOCX/XLSX翻译选项</strong> (在对应工作流下显示):
<ul class="mt-1">
<li><strong>插入模式</strong>: 定义翻译结果如何放入文档。您可以选择直接“替换”原文,或是在原文之后“附加”,或是在原文之前“前置”。
</li>
<li><strong>分隔符</strong>: 当选择“附加”或“前置”模式时,此项用于在原文和译文之间插入分隔符。
</li>
</ul>
</li>
<li class="mb-2"><strong>JSON路径配置</strong> (仅在“JSON翻译”工作流下显示):
<ul class="mt-1">
<li><strong>需要翻译的JSON路径</strong>: 每行输入一个 <a
href="https://goessner.net/articles/JsonPath/" target="_blank">JSONPath</a>
表达式,指定需要翻译的字段。
</li>
<li>例如:<code>$..description</code>翻译所有键为description的值。<code>$.items[0].name</code>翻译第一个item的name
</li>
</ul>
</li>
<li class="mb-2"><strong>翻译模型</strong>:
<ul class="mt-1">
<li><strong>选择平台/API 地址/API Key/模型 ID</strong>:
配置您希望使用的AI翻译服务。模型ID参考平台文档建议使用非推理模型。
</li>
</ul>
</li>
<li class="mb-2"><strong>翻译配置</strong>:
<ul class="mt-1">
<li><strong>目标语言/自定义Prompt</strong>: 指定翻译的目标语言和附加指令。</li>
<li><strong>思考模式</strong>:设置混合推理模型是否进行思考目前支持智谱的glm4.5系列、阿里云的qwen3系列、火山引擎的seed1.6系列,建议选择禁用
</li>
</ul>
</li>
<li class="mb-2"><strong>高级参数</strong>:
<ul class="mt-1">
<li><strong>分块大小/并发数/Temperature</strong>: 发给ai的分块大小、并发请求数和温度通常保持默认即可。
</li>
</ul>
</li>
</ul>
</li>
<li>
<strong><i class="bi bi-file-earmark-arrow-up-fill me-2"></i>上传文件</strong>
<p class="mt-2">在右侧的任务列表中,点击或拖拽您的文档到文件上传区域。</p>
</li>
<li>
<strong><i class="bi bi-play-circle-fill me-2"></i>开始翻译</strong>
<p class="mt-2">文件选择成功后,点击任务卡片右下角的 <span
class="badge bg-primary">开始翻译</span> 按钮。系统将开始处理任务,您可以在日志区域查看实时进度。
</p>
</li>
<li>
<strong><i class="bi bi-check-circle-fill me-2"></i>查看与下载</strong>
<p class="mt-2">翻译完成后,任务卡片下方会出现操作按钮(根据文件类型动态显示):</p>
<ul>
<li><span class="badge bg-success"><i class="bi bi-eye-fill me-1"></i>预览</span>:
在右侧滑出的面板中进行原文和译文的对照预览 (非DOCX/XLSX格式)。
</li>
<li><span class="badge bg-info"><i
class="bi bi-file-earmark-pdf-fill me-1"></i>下载 PDF</span>: 将译文直接保存为PDF格式。
</li>
<li><span class="badge bg-secondary"><i class="bi bi-download me-1"></i>下载其他格式</span>:
下载DOCX, XLSX, HTML, 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>提示</strong>: 所有配置都会自动保存在您的浏览器本地,方便下次使用。
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">我明白了</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>感谢贡献
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>DocuTranslate是一个开源项目大家的需求与使用是项目进步的动力。</p>
<p>感谢所有资助项目、提交代码与宝贵建议及给项目star的朋友们</p>
<div class="alert alert-success mt-4" role="alert">
<p>欢迎通过以下方式参与贡献:</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 主页
</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>提交 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>报告 Issue
</a>
</p>
<hr>
<p>或者通过QQ群联系作者<span>1047781902</span></p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</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 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 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 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 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 printFromPreview = document.getElementById('printFromPreview');
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"
};
// --- 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';
// 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()}. 解析配置`;
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路径配置`;
break;
case 'xlsx':
xlsxSettingsContainer.style.display = 'block';
xlsxSettingsTitle.innerHTML = `<i class="bi bi-file-earmark-spreadsheet-fill me-2"></i>${getStep()}. XLSX翻译选项`;
updateSeparatorVisibility(xlsxInsertModeSelect, xlsxSeparatorGroup);
break;
case 'docx':
docxSettingsContainer.style.display = 'block';
docxSettingsTitle.innerHTML = `<i class="bi bi-file-earmark-word-fill me-2"></i>${getStep()}. DOCX翻译选项`;
updateSeparatorVisibility(docxInsertModeSelect, docxSeparatorGroup);
break;
}
// Renumber common sections
aiSettingsTitle.innerHTML = `<i class="bi bi-robot me-2"></i>${getStep()}. 翻译模型`;
translationSettingsTitle.innerHTML = `<i class="bi bi-translate me-2"></i>${getStep()}. 翻译配置`;
advancedSettingsTitle.innerHTML = `<i class="bi bi-sliders me-2"></i>${getStep()}. 高级参数`;
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';
}
}
// --- 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'),
previewBtn: cardElement.querySelector('.preview-html-btn'),
pdfBtn: cardElement.querySelector('.download-pdf-btn'),
htmlLink: cardElement.querySelector('.download-html-link'),
mdLink: cardElement.querySelector('.download-markdown-link'),
mdZipLink: cardElement.querySelector('.download-markdown-zip-link'),
txtLink: cardElement.querySelector('.download-txt-link'),
jsonLink: cardElement.querySelector('.download-json-link'),
xlsxLink: cardElement.querySelector('.download-xlsx-link'),
docxLink: cardElement.querySelector('.download-docx-link'),
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
},
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.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');
}
}
// --- Core Translation Logic ---
async function startTranslation(cardId) {
const {elements, state} = tasks[cardId];
// --- File and common settings validation ---
if (!state.file) {
elements.statusMessage.textContent = '请先选择一个文件。';
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;
}
});
// --- Workflow-specific validation and payload building ---
const workflowType = workflowTypeSelect.value;
let workflowPayload = {};
const basePayload = {
base_url: baseUrlInput.value,
api_key: apikeyInput.value,
model_id: modelInput.value,
to_lang: toLangSelect.value,
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
};
break;
case 'docx':
workflowPayload = {
...basePayload,
workflow_type: 'docx',
insert_mode: docxInsertModeSelect.value,
separator: docxSeparatorInput.value
};
break;
default:
elements.statusMessage.textContent = '无效的工作流类型。';
elements.statusMessage.className = 'status-message small text-danger';
return;
}
if (!isValid) {
elements.statusMessage.textContent = '请填写所有必填的设置项。';
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> 初始化...`;
elements.logArea.innerHTML = '';
elements.statusMessage.textContent = '正在编码文件并提交任务...';
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 || '任务已开始,正在处理...';
elements.statusMessage.className = 'status-message small text-info';
elements.startBtn.innerHTML = `<i class="bi bi-stop-circle-fill me-1"></i>取消翻译`;
elements.startBtn.classList.replace('btn-primary', 'btn-danger');
elements.startBtn.disabled = false;
startPolling(backendTaskId);
} else {
let errorMessage = result.detail || result.message || `请求失败 (${response.status})`;
if (typeof errorMessage === 'object') {
errorMessage = JSON.stringify(errorMessage);
}
throw new Error(errorMessage);
}
} catch (error) {
state.isSubmitted = false;
console.error('请求失败:', error);
elements.statusMessage.textContent = `启动失败: ${error.message}`;
elements.statusMessage.className = 'status-message small text-danger';
elements.startBtn.innerHTML = `<i class="bi bi-play-fill me-1"></i>开始翻译`;
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> 正在取消...`;
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 || '取消请求已发送。';
elements.statusMessage.className = 'status-message small text-warning';
} else {
throw new Error(result.message || '取消失败');
}
} catch (error) {
elements.statusMessage.textContent = `取消请求失败: ${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>取消翻译`;
}
}
// --- 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 || '正在获取状态...';
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>重新翻译`;
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>取消翻译`;
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 = '状态更新出错。';
elements.statusMessage.className = 'status-message small text-danger';
}
}
// --- Download and Preview ---
function updateDownloadButtons(cardId, status) {
const {elements, state} = tasks[cardId];
const {downloads} = status;
const {
previewBtn,
pdfBtn,
htmlLink,
mdLink,
mdZipLink,
txtLink,
jsonLink,
xlsxLink,
docxLink,
downloadButtons
} = elements;
// Reset visibility first
[previewBtn, pdfBtn].forEach(btn => btn.style.display = 'none');
[htmlLink, mdLink, mdZipLink, txtLink, jsonLink, xlsxLink, docxLink].forEach(link => link.parentElement.style.display = 'none');
downloadButtons.style.display = 'none';
let anyDownloadAvailable = false;
if (downloads.html) {
state.htmlUrl = downloads.html;
state.fileNameStem = status.original_filename_stem;
previewBtn.style.display = 'inline-block';
pdfBtn.style.display = 'inline-block';
htmlLink.href = downloads.html;
htmlLink.parentElement.style.display = 'block';
previewBtn.onclick = () => setupPreview(cardId);
pdfBtn.onclick = () => downloadPdf(cardId);
anyDownloadAvailable = true;
}
if (downloads.markdown) {
mdLink.href = downloads.markdown;
mdLink.parentElement.style.display = 'block';
anyDownloadAvailable = true;
}
if (downloads.markdown_zip) {
mdZipLink.href = downloads.markdown_zip;
mdZipLink.parentElement.style.display = 'block';
anyDownloadAvailable = true;
}
if (downloads.txt) {
txtLink.href = downloads.txt;
txtLink.parentElement.style.display = 'block';
anyDownloadAvailable = true;
}
if (downloads.json) {
jsonLink.href = downloads.json;
jsonLink.parentElement.style.display = 'block';
anyDownloadAvailable = true;
}
if (downloads.xlsx) {
xlsxLink.href = downloads.xlsx;
xlsxLink.parentElement.style.display = 'block';
anyDownloadAvailable = true;
}
if (downloads.docx) {
docxLink.href = downloads.docx;
docxLink.parentElement.style.display = 'block';
anyDownloadAvailable = true;
}
if (anyDownloadAvailable) {
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> 正在加载译文...</h3></div>';
setPreviewDisplayMode('bilingual');
previewOffcanvas.show();
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'];
if (fileType.startsWith('text/') || textLikeExtensions.includes(fileExtension)) {
const pre = document.createElement('pre');
state.file.text()
.then(text => pre.textContent = text)
.catch(() => pre.textContent = '无法读取原文内容。');
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 = `无法直接预览此文件类型 (${fileType || '未知: ' + fileExtension})。`;
originalPreviewPane.appendChild(p);
}
} else {
const p = document.createElement('p');
p.className = 'p-3 text-muted';
p.textContent = '未找到原文文件缓存。';
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>加载译文失败</h3><p>${err.message}</p>`;
});
printFromPreview.onclick = () => {
try {
translatedPreviewFrame.contentWindow.focus();
translatedPreviewFrame.contentWindow.print();
} catch (e) {
alert('打印失败,请使用浏览器打印功能。');
}
};
}
function downloadPdf(cardId) {
const {elements, state} = tasks[cardId];
if (!state.htmlUrl) return;
elements.pdfBtn.disabled = true;
elements.pdfBtn.innerHTML = `<span class="spinner-border spinner-border-sm"></span> 准备中...`;
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('自动打印失败,请在预览中手动打印。');
} finally {
elements.pdfBtn.disabled = false;
elements.pdfBtn.innerHTML = `<i class="bi bi-file-earmark-pdf-fill me-1"></i>下载 PDF`;
printFrameEl.onload = null;
printFrameEl.srcdoc = ''; // Clear content
}
}, 500);
};
printFrameEl.srcdoc = html;
})
.catch(err => {
alert('获取HTML内容失败无法生成PDF。');
elements.pdfBtn.disabled = false;
elements.pdfBtn.innerHTML = `<i class="bi bi-file-earmark-pdf-fill me-1"></i>下载 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 = '双语预览';
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 = '译文预览';
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="">不使用引擎 (源文件为MD)</option>'; // Add a null option
enginList.forEach(engin => {
const option = document.createElement('option');
option.value = engin;
let text = engin;
if (engin === 'mineru') text = 'minerU (云端, 推荐)';
if (engin === 'docling') text = 'Docling (本地)';
option.textContent = text;
convertEnginSelect.appendChild(option);
});
defaultParams = await paramsRes.json();
} catch (error) {
console.error("Initialization failed:", error);
alert("页面初始化失败,请检查后端服务是否正常并刷新页面。");
return;
}
// Restore saved settings
workflowTypeSelect.value = getFromStorage('translator_last_workflow', 'markdown_based');
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');
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', '中文');
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();
// 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));
// 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("无法从服务器加载任务列表,请检查后台连接。");
}
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);
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));
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));
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>