Files
docutranslate/docutranslate/static/index.html
2025-07-24 17:32:21 +08:00

1 line
77 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">
<!-- Parsing Engine Settings -->
<div class="accordion-item">
<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><i class="bi bi-file-earmark-binary me-2"></i>解析配置</strong>
</button>
</h2>
<div id="collapseOne" class="accordion-collapse collapse" aria-labelledby="headingOne">
<div class="accordion-body">
<div class="mb-3">
<label for="convert_engin" class="form-label">解析引擎</label>
<select class="form-select" id="convert_engin" name="convert_engin">
<option value="mineru">minerU (推荐)</option>
<option value="docling">Docling (本地解析)</option>
</select>
</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">
<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><i class="bi bi-robot me-2"></i>翻译模型</strong>
</button>
</h2>
<div id="collapseTwo" class="accordion-collapse collapse" aria-labelledby="headingTwo">
<div class="accordion-body">
<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="apikey" 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="apikey" name="apikey"
required
placeholder="请输入您的API Key">
<button class="btn btn-outline-secondary toggle-password" type="button"
data-target="apikey">
<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><i class="bi bi-translate me-2"></i>翻译配置</strong>
</button>
</h2>
<div id="collapseThree" class="accordion-collapse collapse" aria-labelledby="headingThree">
<div class="accordion-body">
<div class="mb-3">
<label for="to_lang" class="form-label">目标语言</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 for="custom_prompt_translate" class="form-label">自定义Prompt</label>
<textarea class="form-control" id="custom_prompt_translate"
name="custom_prompt_translate" rows="3"
placeholder="可选,如“人名保持原文不翻译”"></textarea>
</div>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" role="switch"
id="refine_markdown" name="refine_markdown">
<label class="form-check-label" for="refine_markdown">Markdown修复<span
class="d-inline-block" tabindex="0" data-bs-toggle="tooltip"
title="使用ai对解析后的文本先修复再翻译现不推荐开启">
<i class="bi bi-question-circle"></i>
</span></label>
</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><i class="bi bi-sliders me-2"></i>高级参数</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="6000" 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"><i class="bi bi-eye-fill me-1"></i>预览</button>
<button class="btn btn-sm btn-info download-pdf-btn"><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><a class="dropdown-item download-html-link" href="#"><i
class="bi bi-filetype-html me-2"></i>HTML</a></li>
<li><a class="dropdown-item download-markdown-link" href="#"><i
class="bi bi-markdown-fill me-2"></i>Markdown(嵌图)</a></li>
<li><a class="dropdown-item download-markdown-zip-link" href="#"><i
class="bi bi-file-zip-fill me-2"></i>Markdown压缩包</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-gear-fill me-2"></i>配置参数</strong>
<p class="mt-2">在左侧的配置面板中,完成以下设置:</p>
<ul>
<li><strong>解析配置</strong>:
<ul>
<li>选择一个解析引擎。推荐使用 <code>minerU</code>
进行云端解析支持PDF/Word/PPT/jpg等如果需要本地解析可以选择
<code>Docling</code>
</li>
<li>如果使用 <code>minerU</code>,请填入您的 <a
href="https://mineru.net/apiManage/token">Mineru Token</a>
</li>
<li>如果输入文件是txt或markdown文件该部分可以不进行设置</li>
</ul>
</li>
<li><strong>翻译模型</strong>:
<ul>
<li>选择您的AI服务提供商平台或选择 <code>自定义接口</code></li>
<li>填入您的 <code>API Key</code><code>模型 ID</code>(例如
glm-4-flash、deepseek-chat
</li>
<li>API-KEY和模型ID参见对应平台文档。</li>
<li>建议使用非推理模型进行翻译。</li>
</ul>
</li>
<li><strong>翻译配置</strong>:
<ul>
<li>选择您希望翻译的 <strong>目标语言</strong></li>
<li>可以填写自定义Prompt指导AI进行特定翻译例如“人名保持原文不翻译”。</li>
</ul>
</li>
<li><strong>高级参数</strong>:
<ul>
<li>通常保持默认即可。</li>
<li>并发数指的是每个任务允许多少个并发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">在右侧的任务列表中点击或拖拽您的文档如PDF, Markdown等到文件上传区域。</p>
</li>
<li>
<strong><i class="bi bi-play-circle-fill me-2"></i>开始翻译</strong>
<p class="mt-2">文件选择成功后,点击任务卡片右下角的 <span
class="badge bg-primary">开始翻译</span> 按钮。系统将开始处理任务,您可以在日志区域查看实时进度。
</p>
</li>
<li>
<strong><i class="bi bi-check-circle-fill me-2"></i>查看与下载</strong>
<p class="mt-2">翻译完成后,任务卡片下方会出现操作按钮:</p>
<ul>
<li><span class="badge bg-success"><i class="bi bi-eye-fill me-1"></i>预览</span>:
在右侧滑出的面板中进行原文和译文的对照预览。
</li>
<li><span class="badge bg-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>:
下载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');
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('apikey');
const modelInput = document.getElementById('model_id');
const toLangSelect = document.getElementById('to_lang');
const formulaCheckbox = document.getElementById('formula_ocr');
const codeCheckbox = document.getElementById('code_ocr');
const refineCheckbox = document.getElementById('refine_markdown');
const convertEnginSelect = document.getElementById('convert_engin');
const mineruTokenGroup = document.getElementById('mineruTokenGroup');
const mineruTokenInput = document.getElementById('mineru_token');
const customPromptTranslateArea = document.getElementById("custom_prompt_translate");
const chunkSizeSlider = document.getElementById('chunk-size-slider');
const chunkSizeDisplay = document.getElementById('chunk-size-display');
const chunkSizeReset = document.getElementById('chunk-size-reset');
const concurrentSlider = document.getElementById('concurrent-slider');
const concurrentDisplay = document.getElementById('concurrent-display');
const concurrentReset = document.getElementById("concurrent-reset");
const temperatureSlider = document.getElementById('temperature-slider');
const temperatureDisplay = document.getElementById('temperature-display');
const temperatureReset = document.getElementById("temperature-reset");
const 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 ---
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';
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();
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'),
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);
}
}
/**
* Notifies the backend to release a task's resources.
* @param {string} backendTaskId - The ID of the task to release.
*/
async function releaseTask(backendTaskId) {
try {
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);
}
}
/**
* Removes a task card from the UI and releases its backend resources.
* @param {string} cardId - The local ID of the card to remove.
*/
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];
if (!state.file) {
elements.statusMessage.textContent = '请先选择一个文件。';
elements.statusMessage.className = 'status-message small text-danger';
elements.fileDropArea.classList.add('input-error');
return;
}
const requiredInputs = [apikeyInput, modelInput];
if (platformSelect.value === 'custom') requiredInputs.push(baseUrlInput);
// 只有当文件不是markdown或txt时才需要mineru_token
const fileExtension = state.file.name.split('.').pop().toLowerCase();
const isTextFile = ['md', 'txt'].includes(fileExtension);
if (convertEnginSelect.value === 'mineru' && !mineruTokenInput.value.trim() && !isTextFile) {
requiredInputs.push(mineruTokenInput);
}
let isValid = true;
requiredInputs.forEach(input => {
input.classList.remove('is-invalid');
if (!input.value.trim()) {
input.classList.add('is-invalid');
isValid = false;
}
});
if (!isValid) {
elements.statusMessage.textContent = '请填写所有必填的设置项。';
elements.statusMessage.className = 'status-message small text-danger';
return;
}
// ======================== FIX: RELEASE OLD TASK ON RETRY ========================
// If a previous backend task exists for this card, release it before starting a new one.
const oldBackendTaskId = state.backendTaskId;
if (oldBackendTaskId) {
console.log(`[${oldBackendTaskId}] Re-translating. Releasing resources for the old task.`);
await releaseTask(oldBackendTaskId);
// Immediately reset the backend ID in the state.
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 payload = {
base_url: baseUrlInput.value,
apikey: apikeyInput.value,
model_id: modelInput.value,
to_lang: toLangSelect.value,
formula_ocr: formulaCheckbox.checked,
code_ocr: codeCheckbox.checked,
refine_markdown: refineCheckbox.checked,
convert_engin: convertEnginSelect.value,
mineru_token: mineruTokenInput.value || null,
chunk_size: parseInt(chunkSizeSlider.value, 10),
concurrent: parseInt(concurrentSlider.value, 10),
temperature: parseFloat(temperatureSlider.value),
custom_prompt_translate: customPromptTranslateArea.value || null,
file_name: state.file.name,
file_content: fileContentBase64
};
const response = await fetch('/service/translate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const result = await response.json();
if (response.ok && result.task_started) {
const backendTaskId = result.task_id;
state.backendTaskId = backendTaskId; // Store the NEW task ID
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 {
throw new Error(result.message || `请求失败 (${response.status})`);
}
} 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);//获取最后未获取的logs
stopPolling(backendTaskId);
state.isTranslating = false;
elements.startBtn.disabled = false;
elements.startBtn.innerHTML = `<i class="bi bi-play-fill 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';
state.htmlUrl = status.downloads.html;
state.fileNameStem = status.original_filename_stem;
elements.htmlLink.href = status.downloads.html;
elements.mdLink.href = status.downloads.markdown;
elements.mdZipLink.href = status.downloads.markdown_zip;
elements.previewBtn.onclick = () => setupPreview(cardId);
elements.pdfBtn.onclick = () => downloadPdf(cardId);
elements.downloadButtons.style.display = 'flex';
} 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 setupPreview(cardId) {
const {state} = tasks[cardId];
if (!state.htmlUrl) return;
// 1. 清除旧内容并设置译文预览的加载状态
const existingOriginalContent = originalPreviewPane.querySelector('iframe, pre, p');
if (existingOriginalContent) existingOriginalContent.remove();
translatedPreviewFrame.src = 'about:blank'; // 清除iframe的src防止残留
translatedPreviewFrame.srcdoc = '<h3><span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 正在加载译文...</h3>'; // 立即显示加载提示
// 2. 立即显示预览 Offcanvas 并设置初始显示模式
setPreviewDisplayMode('bilingual'); // 先设置显示模式,确保布局正确
previewOffcanvas.show(); // 立即显示预览框
// 3. 加载原文内容(如果文件可用)
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);
}
// 4. 异步加载译文 HTML
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>`; // 加载失败显示错误信息
});
// 5. 打印按钮事件设置
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;
}
}, 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")
]);
const meta = await metaRes.json();
versionDisplay.textContent = `v${meta.version}`;
const enginList = await enginRes.json();
Array.from(convertEnginSelect.options).forEach(option => {
if (!enginList.includes(option.value)) {
option.disabled = true;
option.textContent += " (不可用)";
}
});
defaultParams = await paramsRes.json();
} catch (error) {
console.error("Initialization failed:", error);
alert("页面初始化失败,请检查后端服务是否正常并刷新页面。");
return;
}
platformSelect.value = getFromStorage('translator_last_platform', 'https://api.openai.com/v1');
updatePlatformUI();
convertEnginSelect.value = getFromStorage('translator_convert_engin', 'mineru');
updateConvertEnginUI();
toLangSelect.value = getFromStorage('translator_to_lang', '中文');
formulaCheckbox.checked = getFromStorage('translator_formula_ocr', 'true') === 'true';
codeCheckbox.checked = getFromStorage('translator_code_ocr', 'true') === 'true';
refineCheckbox.checked = getFromStorage('translator_refine_markdown') === 'true';
customPromptTranslateArea.value = getFromStorage("custom_prompt_translate");
setupSlider(chunkSizeSlider, chunkSizeDisplay, chunkSizeReset, 'chunk_size', defaultParams);
setupSlider(concurrentSlider, concurrentDisplay, concurrentReset, 'concurrent', defaultParams);
setupSlider(temperatureSlider, temperatureDisplay, temperatureReset, 'temperature', defaultParams);
// Setup password toggles
document.querySelectorAll('.toggle-password').forEach(button => {
setupPasswordToggle(button);
});
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();
}
}
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));
formulaCheckbox.addEventListener('change', e => saveToStorage('translator_formula_ocr', e.target.checked));
codeCheckbox.addEventListener('change', e => saveToStorage('translator_code_ocr', e.target.checked));
refineCheckbox.addEventListener('change', e => saveToStorage('translator_refine_markdown', e.target.checked));
customPromptTranslateArea.addEventListener('input', () => saveToStorage("custom_prompt_translate", customPromptTranslateArea.value));
addNewTaskBtn.addEventListener('click', () => createTaskCard());
setBilingualViewBtn.addEventListener('click', () => setPreviewDisplayMode('bilingual'));
setTranslatedOnlyViewBtn.addEventListener('click', () => setPreviewDisplayMode('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>