1084 lines
45 KiB
HTML
1084 lines
45 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>DocuTranslate</title>
|
||
<link rel="icon" href="/static/DocuTranslate.ico" type="image/x-icon">
|
||
<link rel="stylesheet" href="/static/pico.css">
|
||
<style>
|
||
body {
|
||
padding: 20px;
|
||
}
|
||
|
||
.container {
|
||
max-width: 800px;
|
||
margin: auto;
|
||
padding: 1rem;
|
||
}
|
||
|
||
summary {
|
||
border-bottom: 1px dashed #ccc;
|
||
padding-bottom: 0.5rem;
|
||
}
|
||
|
||
.prompt-area {
|
||
resize: vertical;
|
||
width: 100%;
|
||
min-height: 200px;
|
||
font-size: 0.9em;
|
||
box-sizing: border-box;
|
||
display: block;
|
||
}
|
||
|
||
.log-area {
|
||
background-color: #f5f5f5;
|
||
border: 1px solid #e0e0e0;
|
||
padding: 10px;
|
||
height: 300px;
|
||
overflow-y: scroll;
|
||
white-space: pre-wrap;
|
||
font-family: monospace;
|
||
margin-top: 1rem;
|
||
}
|
||
|
||
.meta {
|
||
margin-top: 2rem;
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.meta * {
|
||
font-size: inherit;
|
||
}
|
||
|
||
.error-message {
|
||
color: #d32f2f;
|
||
}
|
||
|
||
.success-message {
|
||
color: #2e7d32;
|
||
}
|
||
|
||
a.no-style {
|
||
text-decoration: none;
|
||
color: inherit;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 0.5rem;
|
||
margin-left: 1rem;
|
||
margin-right: 2rem;
|
||
}
|
||
|
||
.form-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.button-group {
|
||
margin-top: 1rem;
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
flex-wrap: wrap;
|
||
justify-content: right;
|
||
align-items: baseline;
|
||
}
|
||
|
||
.spacer {
|
||
flex-grow: 1;
|
||
}
|
||
|
||
details {
|
||
margin-bottom: 2rem;
|
||
}
|
||
|
||
.checkbox-group {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 1rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.checkbox-group label {
|
||
margin-right: 10px;
|
||
}
|
||
|
||
#resultArea {
|
||
margin-top: 1.5rem;
|
||
padding-top: 1rem;
|
||
border-top: 1px solid #eee;
|
||
}
|
||
|
||
#downloadButtons {
|
||
display: none;
|
||
margin-top: 1rem;
|
||
}
|
||
|
||
.hidden {
|
||
display: none !important;
|
||
}
|
||
|
||
.modal {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background-color: rgba(0, 0, 0, 0.6);
|
||
z-index: 1000;
|
||
overflow: auto;
|
||
}
|
||
|
||
.modal-content {
|
||
background-color: #fff;
|
||
margin: 2% auto;
|
||
padding: 20px;
|
||
width: 95%;
|
||
max-width: 1400px;
|
||
height: 90vh;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
#previewTitleBar {
|
||
display: flex;
|
||
align-items: center; /* Vertically align items */
|
||
margin-bottom: 10px;
|
||
flex-shrink: 0; /* Prevent shrinking when content is large */
|
||
}
|
||
|
||
#previewModalTitle {
|
||
margin: 0; /* Remove default h3 margin */
|
||
font-size: 1.2rem; /* Adjust title size */
|
||
}
|
||
|
||
.preview-view-mode-buttons { /* This is a Pico .button-group */
|
||
margin-left: 1.5rem; /* Space from title */
|
||
/* Pico styles will apply display:flex, gap */
|
||
}
|
||
|
||
.preview-view-mode-buttons button {
|
||
font-size: 0.85rem; /* Smaller buttons for toggle */
|
||
padding: 0.4rem 0.8rem;
|
||
}
|
||
|
||
|
||
#closeModalBtnInTitle { /* Renamed to avoid conflict if another element has closeModalBtn */
|
||
cursor: pointer;
|
||
margin-left: auto; /* Pushes close button to the very right */
|
||
font-size: 1.5rem;
|
||
line-height: 1;
|
||
padding: 0 0.5rem; /* Add some clickable area */
|
||
}
|
||
|
||
|
||
#previewContainer {
|
||
display: flex;
|
||
flex-grow: 1;
|
||
gap: 15px;
|
||
overflow: hidden;
|
||
min-height: 0;
|
||
}
|
||
|
||
.preview-pane {
|
||
flex: 1 1 0; /* Grow, Shrink, Basis. Basis 0 for even distribution with gap */
|
||
min-width: 0; /* Important for flex items that might contain oversized content */
|
||
border: 1px solid #ddd;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
background-color: #f9f9f9;
|
||
}
|
||
|
||
.preview-pane h4 {
|
||
margin: 0;
|
||
padding: 8px 12px;
|
||
background-color: #efefef;
|
||
border-bottom: 1px solid #ddd;
|
||
font-size: 0.9rem;
|
||
text-align: center;
|
||
flex-shrink: 0; /* Prevent title from shrinking */
|
||
}
|
||
|
||
.preview-pane iframe, .preview-pane pre {
|
||
width: 100%;
|
||
flex-grow: 1;
|
||
border: none;
|
||
overflow: auto;
|
||
background-color: #fff;
|
||
}
|
||
|
||
.preview-pane pre {
|
||
padding: 10px;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
font-family: monospace;
|
||
}
|
||
|
||
#printFrame {
|
||
display: none;
|
||
}
|
||
|
||
#fileDropArea {
|
||
border: 2px dashed #ccc;
|
||
padding: 20px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out;
|
||
}
|
||
|
||
#fileDropArea.drag-over {
|
||
border-color: #1095c1;
|
||
background-color: #e7f5fa;
|
||
}
|
||
|
||
#fileDropArea.file-selected {
|
||
border-color: #2e7d32;
|
||
background-color: #e8f5e9;
|
||
}
|
||
|
||
#fileDropArea p {
|
||
margin: 0.5rem 0;
|
||
color: #555;
|
||
}
|
||
|
||
#fileNameDisplay {
|
||
margin-top: 0.5rem;
|
||
font-style: italic;
|
||
color: #333;
|
||
}
|
||
|
||
#fileNameDisplay.has-file {
|
||
font-style: normal;
|
||
font-weight: bold;
|
||
color: #1a531d;
|
||
}
|
||
|
||
#fileDropArea.input-error, input.input-error, select.input-error {
|
||
border-color: #d32f2f !important;
|
||
}
|
||
|
||
#fileNameDisplay.input-error-text {
|
||
color: #d32f2f !important;
|
||
font-weight: bold;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.form-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
#previewContainer {
|
||
flex-direction: column;
|
||
height: auto;
|
||
}
|
||
|
||
.modal-content {
|
||
height: 95vh;
|
||
}
|
||
|
||
.preview-pane {
|
||
min-height: 300px;
|
||
}
|
||
|
||
#previewTitleBar {
|
||
flex-wrap: wrap; /* Allow wrapping for smaller screens */
|
||
}
|
||
|
||
.preview-view-mode-buttons {
|
||
margin-left: 0;
|
||
margin-top: 0.5rem; /* Space when wrapped */
|
||
width: 100%; /* Take full width when wrapped */
|
||
justify-content: center;
|
||
}
|
||
|
||
#closeModalBtnInTitle {
|
||
order: -1; /* Move close button to top left on wrap if needed, or adjust layout */
|
||
margin-left: auto; /* Keep it to the right */
|
||
}
|
||
|
||
#previewModalTitle {
|
||
width: 100%; /* Allow title to take width if buttons wrap below */
|
||
text-align: center;
|
||
margin-bottom: 0.5rem; /* Space if buttons wrap */
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main class="container">
|
||
<h1>
|
||
<a href="https://github.com/xunbu/docutranslate" target="_blank">DocuTranslate🔗</a>
|
||
</h1>
|
||
<form id="translateForm">
|
||
|
||
<div class="form-group">
|
||
<label for="file">文档选择</label>
|
||
<div id="fileDropArea">
|
||
<input type="file" id="file" name="file" required style="display: none;">
|
||
<p id="fileDropPrompt">点击此处选择文件,或将文件拖拽到这里</p>
|
||
<div id="fileNameDisplay">未选择文件</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label for="to_lang">目标语言</label>
|
||
<select id="to_lang" name="to_lang">
|
||
<option value="中文">中文 (Chinese)</option>
|
||
<option value="English">英文 (English)</option>
|
||
<option value="日本語">日语 (Japanese)</option>
|
||
<option value="한국어">韩语 (Korean)</option>
|
||
<option value="Français">法语 (French)</option>
|
||
<option value="Deutsch">德语 (German)</option>
|
||
<option value="Español">西班牙语 (Spanish)</option>
|
||
<option value="Italiano">意大利语 (Italian)</option>
|
||
<option value="Português">葡萄牙语 (Portuguese)</option>
|
||
<option value="Русский">俄语 (Russian)</option>
|
||
<option value="العربية">阿拉伯语 (Arabic)</option>
|
||
<option value="हिन्दी">印地语 (Hindi)</option>
|
||
<option value="Nederlands">荷兰语 (Dutch)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>选项</label>
|
||
<div class="checkbox-group">
|
||
<label for="formula_ocr"><input type="checkbox" id="formula_ocr" name="formula_ocr" role="switch">公式识别</label>
|
||
<label for="code_ocr"><input type="checkbox" id="code_ocr" name="code_ocr"
|
||
role="switch">代码识别</label>
|
||
<label for="refine_markdown"><input type="checkbox" id="refine_markdown" name="refine_markdown"
|
||
role="switch">修正文本(耗时)</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<details>
|
||
<summary>文档解析引擎配置</summary>
|
||
<div class="form-group">
|
||
<label for="convert_engin">解析引擎</label>
|
||
<select id="convert_engin" name="convert_engin">
|
||
<option value="mineru" selected>minerU</option>
|
||
<option value="docling" id="docling">Docling</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group hidden" id="mineruTokenGroup">
|
||
<label for="mineru_token">minerU Token<a class="no-style" href="https://mineru.net/apiManage/token"
|
||
target="_blank"
|
||
title="获取令牌">🔗</a></label>
|
||
<input type="password" id="mineru_token" name="mineru_token" placeholder="使用 Mineru 引擎时必须填写">
|
||
</div>
|
||
</details>
|
||
|
||
|
||
<details>
|
||
<summary>翻译API配置</summary>
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label for="platform_select">AI 平台 </label>
|
||
<select id="platform_select" name="platform_select_ui">
|
||
<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="form-group hidden" id="baseUrlGroup">
|
||
<label for="base_url">API 地址 (Base URL)</label>
|
||
<input type="text" id="base_url" name="base_url" placeholder="https://api.openai.com/v1">
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="apikey">API 密钥<a id="api_href" class="no-style" href="/"
|
||
target="_blank"
|
||
title="获取API-KEY">🔗</a></label>
|
||
<input type="password" id="apikey" name="apikey" placeholder="平台对应的API Key" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="model_id">模型 ID</label>
|
||
<input type="text" id="model_id" name="model_id" placeholder="模型id" required>
|
||
</div>
|
||
</details>
|
||
<details>
|
||
<summary>系统提示(可选)</summary>
|
||
<div class="form-group">
|
||
<label for="custom_prompt_translate"></label>
|
||
<textarea class="prompt-area" type="text" id="custom_prompt_translate"
|
||
name="custom_prompt_translate" placeholder="翻译提示"></textarea>
|
||
</div>
|
||
</details>
|
||
<button type="submit" id="submitButton" class="primary">开始翻译</button>
|
||
</form>
|
||
<div id="resultArea">
|
||
<p id="statusMessage"></p>
|
||
<div id="downloadButtons" class="button-group">
|
||
<h4>翻译结果</h4>
|
||
<div class="spacer"></div>
|
||
<a id="downloadMarkdown" href="#" role="button" class="outline">下载 Markdown</a>
|
||
<a id="downloadHtml" href="#" role="button" class="outline">下载 HTML</a>
|
||
<button id="downloadPdf" class="outline">下载 PDF</button>
|
||
<button id="previewHtml" class="outline">预览</button>
|
||
</div>
|
||
</div>
|
||
<h4 style="margin-top: 1.5rem;">运行日志</h4>
|
||
<div class="log-area" id="logArea"></div>
|
||
<ul class="meta">
|
||
<li id="versionDisplay"></li>
|
||
<li>QQ交流群:1047781902</li>
|
||
<li><a class="no-style"
|
||
target="_blank"
|
||
href="https://github.com/xunbu/docutranslate">项目主页:github.com/xunbu/docutranslate</a></li>
|
||
</ul>
|
||
</main>
|
||
|
||
<!-- MODIFIED MODAL STRUCTURE -->
|
||
<div id="previewModal" class="modal">
|
||
<div class="modal-content">
|
||
<div id="previewTitleBar">
|
||
<h3 id="previewModalTitle">双语预览</h3>
|
||
<div class="preview-view-mode-buttons button-group" style="margin-top:0;">
|
||
<!-- Pico .button-group applied -->
|
||
<button id="setBilingualViewBtn" role="button" class="primary">双语</button>
|
||
<button id="setTranslatedOnlyViewBtn" role="button" class="outline">译文</button>
|
||
</div>
|
||
<span id="closeModalBtnInTitle">×</span>
|
||
</div>
|
||
|
||
<div id="previewContainer">
|
||
<div class="preview-pane" id="originalPreviewPane">
|
||
<h4>原文</h4>
|
||
<!-- Content will be an iframe or pre, added by JS -->
|
||
</div>
|
||
<div class="preview-pane" id="translatedPreviewPane">
|
||
<h4>译文</h4>
|
||
<iframe id="translatedPreviewFrame"></iframe>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="button-group" style="margin-top: 15px; flex-shrink:0;"> <!-- flex-shrink:0 to prevent shrinking -->
|
||
<button id="printFromPreview" class="primary">打印/保存为PDF (译文)</button>
|
||
<button id="closePreviewModalBtn" class="outline">关闭</button> <!-- Renamed to avoid conflict -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<iframe id="printFrame" style="display:none;"></iframe>
|
||
<script>
|
||
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 form = document.getElementById('translateForm');
|
||
const submitButton = document.getElementById('submitButton');
|
||
const logArea = document.getElementById('logArea');
|
||
const statusMsg = document.getElementById('statusMessage');
|
||
const downloadBtns = document.getElementById('downloadButtons');
|
||
const markdownLink = document.getElementById('downloadMarkdown');
|
||
const htmlLink = document.getElementById('downloadHtml');
|
||
const previewHtmlBtn = document.getElementById('previewHtml');
|
||
const downloadPdfBtn = document.getElementById('downloadPdf');
|
||
const printFrameEl = document.getElementById('printFrame');
|
||
|
||
// Modal and preview elements
|
||
const modal = document.getElementById('previewModal');
|
||
const previewModalTitle = document.getElementById('previewModalTitle');
|
||
const closeModalBtnInTitle = document.getElementById('closeModalBtnInTitle');
|
||
const closePreviewModalBtn = document.getElementById('closePreviewModalBtn'); // Bottom close button
|
||
const printFromPreview = document.getElementById('printFromPreview');
|
||
|
||
const originalPreviewPane = document.getElementById('originalPreviewPane');
|
||
const translatedPreviewFrame = document.getElementById('translatedPreviewFrame');
|
||
|
||
const setBilingualViewBtn = document.getElementById('setBilingualViewBtn');
|
||
const setTranslatedOnlyViewBtn = document.getElementById('setTranslatedOnlyViewBtn');
|
||
|
||
const fileInput = document.getElementById('file');
|
||
const fileDropArea = document.getElementById('fileDropArea');
|
||
const fileNameDisplay = document.getElementById('fileNameDisplay');
|
||
const fileDropPrompt = document.getElementById('fileDropPrompt');
|
||
|
||
const versionDisplay = document.getElementById("versionDisplay")
|
||
|
||
let logPollIntervalId = null;
|
||
let statusPollIntervalId = null;
|
||
let isTranslating = false;
|
||
|
||
function saveToStorage(key, value) {
|
||
try {
|
||
localStorage.setItem(key, value);
|
||
} catch (e) {
|
||
console.warn("保存到本地存储失败:", e);
|
||
}
|
||
}
|
||
|
||
function getFromStorage(key, defaultValue = '') {
|
||
try {
|
||
return localStorage.getItem(key) || defaultValue;
|
||
} catch (e) {
|
||
console.warn("从本地存储读取失败:", e);
|
||
return defaultValue;
|
||
}
|
||
}
|
||
|
||
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"
|
||
}
|
||
|
||
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('hidden');
|
||
baseUrlInput.required = true;
|
||
baseUrlInput.value = getFromStorage('translator_platform_custom_base_url');
|
||
apiHref.classList.add('hidden')
|
||
} else {
|
||
baseUrlGroup.classList.add('hidden');
|
||
baseUrlInput.required = false;
|
||
baseUrlInput.value = selectedPlatformValue;
|
||
apiHref.classList.remove('hidden')
|
||
if (apiHrefMap[baseUrlInput.value]) { // Check if key exists
|
||
apiHref.href = apiHrefMap[baseUrlInput.value];
|
||
} else {
|
||
apiHref.classList.add('hidden'); // Hide if no link defined
|
||
}
|
||
}
|
||
saveToStorage('translator_last_platform', selectedPlatformValue);
|
||
}
|
||
|
||
function updateConvertEnginUI() {
|
||
const selectedEngin = convertEnginSelect.value;
|
||
if (selectedEngin === 'mineru') {
|
||
mineruTokenGroup.classList.remove('hidden');
|
||
mineruTokenInput.required = true;
|
||
mineruTokenInput.value = getFromStorage('translator_mineru_token');
|
||
} else {
|
||
mineruTokenGroup.classList.add('hidden');
|
||
mineruTokenInput.required = false;
|
||
}
|
||
saveToStorage('translator_convert_engin', selectedEngin);
|
||
}
|
||
|
||
function loadSettings() {
|
||
platformSelect.value = getFromStorage('translator_last_platform', 'custom');
|
||
updatePlatformUI();
|
||
convertEnginSelect.value = getFromStorage('translator_convert_engin', 'mineru');
|
||
updateConvertEnginUI();
|
||
toLangSelect.value = getFromStorage('translator_to_lang', '中文');
|
||
formulaCheckbox.checked = getFromStorage('translator_formula_ocr') === 'true';
|
||
codeCheckbox.checked = getFromStorage('translator_code_ocr') === 'true';
|
||
refineCheckbox.checked = getFromStorage('translator_refine_markdown') === 'true';
|
||
}
|
||
|
||
loadSettings();
|
||
startPolling();
|
||
|
||
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.toString()));
|
||
codeCheckbox.addEventListener('change', e => saveToStorage('translator_code_ocr', e.target.checked.toString()));
|
||
refineCheckbox.addEventListener('change', e => saveToStorage('translator_refine_markdown', e.target.checked.toString()));
|
||
|
||
function closePreviewModal() {
|
||
modal.style.display = 'none';
|
||
const existingOriginalFrame = originalPreviewPane.querySelector('iframe, pre, p');
|
||
if (existingOriginalFrame) existingOriginalFrame.remove();
|
||
translatedPreviewFrame.src = 'about:blank';
|
||
}
|
||
|
||
[closeModalBtnInTitle, closePreviewModalBtn].forEach(elem => elem.addEventListener('click', closePreviewModal));
|
||
|
||
window.addEventListener('click', (event) => {
|
||
if (event.target === modal) {
|
||
closePreviewModal();
|
||
}
|
||
});
|
||
|
||
printFromPreview.addEventListener('click', () => {
|
||
try {
|
||
translatedPreviewFrame.contentWindow.focus();
|
||
translatedPreviewFrame.contentWindow.print();
|
||
} catch (err) {
|
||
console.error('打印预览内容失败:', err);
|
||
alert('打印失败,请尝试使用浏览器的打印功能 (Ctrl+P 或 ⌘+P)。');
|
||
}
|
||
});
|
||
|
||
// Function to set the preview display mode
|
||
function setPreviewDisplayMode(mode) {
|
||
if (mode === 'bilingual') {
|
||
originalPreviewPane.style.display = 'flex'; // 'flex' because it's a flex container
|
||
previewModalTitle.textContent = '双语预览';
|
||
setBilingualViewBtn.classList.add('primary');
|
||
setBilingualViewBtn.classList.remove('outline');
|
||
setTranslatedOnlyViewBtn.classList.add('outline');
|
||
setTranslatedOnlyViewBtn.classList.remove('primary');
|
||
} else if (mode === 'translationOnly') {
|
||
originalPreviewPane.style.display = 'none';
|
||
previewModalTitle.textContent = '译文预览';
|
||
setTranslatedOnlyViewBtn.classList.add('primary');
|
||
setTranslatedOnlyViewBtn.classList.remove('outline');
|
||
setBilingualViewBtn.classList.add('outline');
|
||
setBilingualViewBtn.classList.remove('primary');
|
||
}
|
||
}
|
||
|
||
setBilingualViewBtn.addEventListener('click', () => setPreviewDisplayMode('bilingual'));
|
||
setTranslatedOnlyViewBtn.addEventListener('click', () => setPreviewDisplayMode('translationOnly'));
|
||
|
||
|
||
fileDropArea.addEventListener('click', () => fileInput.click());
|
||
fileInput.addEventListener('change', () => {
|
||
if (fileInput.files.length > 0) {
|
||
fileNameDisplay.textContent = `已选择: ${fileInput.files[0].name}`;
|
||
fileDropArea.classList.add('file-selected');
|
||
fileNameDisplay.classList.add('has-file');
|
||
fileDropPrompt.classList.add('hidden');
|
||
fileDropArea.classList.remove('input-error');
|
||
fileNameDisplay.classList.remove('input-error-text');
|
||
statusMsg.textContent = '';
|
||
statusMsg.className = '';
|
||
} else {
|
||
fileNameDisplay.textContent = '未选择文件';
|
||
fileDropArea.classList.remove('file-selected');
|
||
fileNameDisplay.classList.remove('has-file');
|
||
fileDropPrompt.classList.remove('hidden');
|
||
}
|
||
});
|
||
|
||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||
fileDropArea.addEventListener(eventName, preventDefaults, false);
|
||
});
|
||
|
||
function preventDefaults(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}
|
||
|
||
['dragenter', 'dragover'].forEach(eventName => {
|
||
fileDropArea.addEventListener(eventName, () => {
|
||
if (!fileDropArea.classList.contains('file-selected')) {
|
||
fileDropArea.classList.add('drag-over');
|
||
}
|
||
}, false);
|
||
});
|
||
['dragleave', 'drop'].forEach(eventName => {
|
||
fileDropArea.addEventListener(eventName, () => {
|
||
fileDropArea.classList.remove('drag-over');
|
||
}, false);
|
||
});
|
||
fileDropArea.addEventListener('drop', (e) => {
|
||
const dt = e.dataTransfer;
|
||
const files = dt.files;
|
||
if (files.length > 0) {
|
||
fileInput.files = files;
|
||
const event = new Event('change', {bubbles: true});
|
||
fileInput.dispatchEvent(event);
|
||
}
|
||
}, false);
|
||
|
||
// 初始化调用
|
||
(async () => {
|
||
try {
|
||
const response = await fetch("/meta")
|
||
let meta = await response.json();
|
||
versionDisplay.textContent = `版本号:${meta.version}`;
|
||
} catch (error) {
|
||
console.warn("获取版本号失败", error);
|
||
}
|
||
try {
|
||
const response = await fetch('/get-engin-list')
|
||
if (!response.ok) {
|
||
console.warn(`get engine list failed: ${response.status}`);
|
||
return;
|
||
}
|
||
const enginList = await response.json();
|
||
statusMsg.textContent = '正在初始化';
|
||
let options = convertEnginSelect.querySelectorAll(`option`);
|
||
let currentEngineDisabled = false;
|
||
options.forEach((option) => {
|
||
if (!enginList.includes(option.value)) {
|
||
option.disabled = true;
|
||
option.textContent += " (不可用)";
|
||
if (option.value === convertEnginSelect.value) {
|
||
currentEngineDisabled = true;
|
||
}
|
||
}
|
||
});
|
||
if (currentEngineDisabled) {
|
||
const mineruOption = convertEnginSelect.querySelector('option[value="mineru"]');
|
||
if (mineruOption && !mineruOption.disabled) {
|
||
convertEnginSelect.value = "mineru";
|
||
} else {
|
||
const firstAvailable = convertEnginSelect.querySelector('option:not([disabled])');
|
||
if (firstAvailable) convertEnginSelect.value = firstAvailable.value;
|
||
}
|
||
updateConvertEnginUI();
|
||
}
|
||
statusMsg.textContent = '初始化完成';
|
||
} catch (error) {
|
||
console.warn("Error get engin-list", error);
|
||
statusMsg.textContent = '引擎列表初始化失败';
|
||
statusMsg.className = 'error-message';
|
||
}
|
||
})();
|
||
|
||
|
||
async function pollLogs() {
|
||
try {
|
||
const response = await fetch('/get-logs');
|
||
if (!response.ok) {
|
||
console.warn(`Log polling failed: ${response.status}`);
|
||
return;
|
||
}
|
||
const data = await response.json();
|
||
if (data.logs && data.logs.length > 0) {
|
||
data.logs.forEach(log => {
|
||
const escapedLog = log.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||
logArea.innerHTML += escapedLog + "<br>";
|
||
});
|
||
logArea.scrollTop = logArea.scrollHeight;
|
||
}
|
||
} catch (error) {
|
||
console.warn("Error polling logs:", error);
|
||
}
|
||
}
|
||
|
||
async function pollStatus() {
|
||
try {
|
||
const response = await fetch('/get-status');
|
||
if (!response.ok) {
|
||
console.warn(`Status polling failed: ${response.status}`);
|
||
statusMsg.textContent = `状态更新失败 (${response.status})`;
|
||
statusMsg.className = 'error-message';
|
||
return;
|
||
}
|
||
const status = await response.json();
|
||
statusMsg.textContent = status.status_message || '正在获取状态...';
|
||
statusMsg.className = status.error_flag ? 'error-message' : 'success-message';
|
||
if (!status.is_processing) {
|
||
stopPolling();
|
||
submitButton.disabled = false;
|
||
submitButton.removeAttribute('aria-busy');
|
||
submitButton.textContent = '开始翻译';
|
||
submitButton.classList.remove('secondary', 'contrast');
|
||
submitButton.classList.add('primary');
|
||
isTranslating = false;
|
||
|
||
if (status.download_ready && !status.error_flag) {
|
||
markdownLink.href = status.markdown_url;
|
||
markdownLink.setAttribute('download', status.original_filename_stem + '_translated.md');
|
||
htmlLink.href = status.html_url;
|
||
htmlLink.setAttribute('download', status.original_filename_stem + '_translated.html');
|
||
|
||
let htmlUrl = status.html_url;
|
||
let fileName = status.original_filename_stem;
|
||
|
||
previewHtmlBtn.onclick = function () {
|
||
const currentHtmlUrl = htmlUrl;
|
||
const currentFileName = fileName;
|
||
const originalFile = fileInput.files[0];
|
||
|
||
const existingOriginalContent = originalPreviewPane.querySelector('iframe, pre, p');
|
||
if (existingOriginalContent) existingOriginalContent.remove();
|
||
|
||
if (originalFile) {
|
||
const fileType = originalFile.type;
|
||
const reader = new FileReader();
|
||
const fileExtension = originalFile.name.split('.').pop().toLowerCase();
|
||
|
||
if (fileType.startsWith('text/') || ['md', 'json', 'xml', 'log', 'py', 'js', 'css', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'rb', 'php', 'swift', 'kt', 'go', 'rs', 'ts'].includes(fileExtension)) {
|
||
const pre = document.createElement('pre');
|
||
reader.onload = (e) => {
|
||
pre.textContent = e.target.result;
|
||
originalPreviewPane.appendChild(pre);
|
||
};
|
||
reader.onerror = () => {
|
||
pre.textContent = '无法读取原文文件内容。';
|
||
originalPreviewPane.appendChild(pre);
|
||
};
|
||
reader.readAsText(originalFile);
|
||
} else if (fileType === 'application/pdf' || fileType === 'text/html' || fileExtension === 'html' || fileExtension === 'htm') {
|
||
const iframe = document.createElement('iframe');
|
||
const objectUrl = URL.createObjectURL(originalFile);
|
||
iframe.src = objectUrl;
|
||
iframe.onload = () => URL.revokeObjectURL(objectUrl);
|
||
originalPreviewPane.appendChild(iframe);
|
||
} else {
|
||
const p = document.createElement('p');
|
||
p.style.padding = '10px';
|
||
p.textContent = `无法直接预览此文件类型 (${fileType || '未知类型: ' + fileExtension}). 文件名: ${originalFile.name}.`;
|
||
originalPreviewPane.appendChild(p);
|
||
}
|
||
} else {
|
||
const p = document.createElement('p');
|
||
p.style.padding = '10px';
|
||
p.textContent = '未选择原文文件。';
|
||
originalPreviewPane.appendChild(p);
|
||
}
|
||
|
||
fetch(currentHtmlUrl)
|
||
.then(resp => {
|
||
if (!resp.ok) throw new Error(`HTTP error ${resp.status}`);
|
||
return resp.text();
|
||
})
|
||
.then(html => {
|
||
const blob = new Blob([html], {type: 'text/html'});
|
||
const blobUrl = URL.createObjectURL(blob);
|
||
translatedPreviewFrame.src = blobUrl;
|
||
translatedPreviewFrame.onload = function () {
|
||
try {
|
||
translatedPreviewFrame.contentWindow.document.title = currentFileName + '_translated';
|
||
URL.revokeObjectURL(blobUrl);
|
||
} catch (e) {
|
||
console.warn('无法设置译文iframe标题或释放Blob URL', e);
|
||
}
|
||
};
|
||
setPreviewDisplayMode('bilingual'); // Default to bilingual view
|
||
modal.style.display = 'block';
|
||
})
|
||
.catch(err => {
|
||
console.error('预览: 获取译文HTML内容失败:', err);
|
||
statusMsg.textContent = '获取译文HTML内容失败,无法预览。';
|
||
statusMsg.className = 'error-message';
|
||
translatedPreviewFrame.srcdoc = `<h3>加载译文失败</h3><p>${err.message}</p>`;
|
||
setPreviewDisplayMode('bilingual'); // Default to bilingual view even on error
|
||
modal.style.display = 'block';
|
||
});
|
||
};
|
||
|
||
downloadPdfBtn.onclick = function () {
|
||
downloadPdfBtn.disabled = true;
|
||
downloadPdfBtn.textContent = '准备PDF...';
|
||
const currentHtmlUrl = htmlUrl;
|
||
const currentFileName = fileName;
|
||
const iframe = printFrameEl;
|
||
|
||
fetch(currentHtmlUrl)
|
||
.then(resp => {
|
||
if (!resp.ok) throw new Error(`HTTP error ${resp.status} for PDF HTML`);
|
||
return resp.text();
|
||
})
|
||
.then(htmlContent => {
|
||
let finalHtml = htmlContent;
|
||
iframe.onload = () => {
|
||
iframe.onload = null;
|
||
setTimeout(() => {
|
||
try {
|
||
const iframeWindow = iframe.contentWindow;
|
||
if (!iframeWindow) throw new Error("无法访问打印框架。");
|
||
iframeWindow.document.title = currentFileName + '_translated.pdf';
|
||
iframeWindow.focus();
|
||
iframeWindow.print();
|
||
} catch (err) {
|
||
console.error('打印PDF出错:', err);
|
||
statusMsg.textContent = '无法直接生成PDF。请预览HTML后,使用浏览器的打印功能 (Ctrl+P) 保存。';
|
||
statusMsg.className = 'error-message';
|
||
} finally {
|
||
setTimeout(() => {
|
||
downloadPdfBtn.disabled = false;
|
||
downloadPdfBtn.textContent = '下载 PDF';
|
||
}, 2000);
|
||
}
|
||
}, 500)
|
||
};
|
||
iframe.srcdoc = finalHtml;
|
||
})
|
||
.catch(err => {
|
||
console.error('PDF生成: 获取HTML内容失败:', err);
|
||
statusMsg.textContent = '获取HTML内容失败,无法生成PDF。请尝试预览。';
|
||
statusMsg.className = 'error-message';
|
||
downloadPdfBtn.disabled = false;
|
||
downloadPdfBtn.textContent = '下载 PDF';
|
||
});
|
||
};
|
||
downloadBtns.style.display = 'flex';
|
||
} else {
|
||
downloadBtns.style.display = 'none';
|
||
}
|
||
} else {
|
||
submitButton.textContent = '取消翻译';
|
||
submitButton.classList.remove('primary');
|
||
submitButton.classList.add('secondary', 'contrast');
|
||
isTranslating = true;
|
||
submitButton.disabled = false;
|
||
submitButton.removeAttribute('aria-busy');
|
||
downloadBtns.style.display = 'none';
|
||
}
|
||
} catch (error) {
|
||
console.error("Error polling status:", error);
|
||
statusMsg.textContent = '状态更新出错。';
|
||
statusMsg.className = 'error-message';
|
||
}
|
||
}
|
||
|
||
function startPolling() {
|
||
stopPolling();
|
||
logArea.innerHTML = '';
|
||
pollLogs();
|
||
pollStatus();
|
||
logPollIntervalId = setInterval(pollLogs, 2000);
|
||
statusPollIntervalId = setInterval(pollStatus, 1500);
|
||
}
|
||
|
||
function stopPolling() {
|
||
if (logPollIntervalId) clearInterval(logPollIntervalId);
|
||
if (statusPollIntervalId) clearInterval(statusPollIntervalId);
|
||
logPollIntervalId = null;
|
||
statusPollIntervalId = null;
|
||
setTimeout(pollLogs, 500);
|
||
}
|
||
|
||
async function cancelTranslation() {
|
||
submitButton.disabled = true;
|
||
submitButton.textContent = '正在取消...';
|
||
submitButton.setAttribute('aria-busy', 'true');
|
||
|
||
try {
|
||
const response = await fetch('/cancel-translate', {method: 'POST'});
|
||
const result = await response.json();
|
||
if (response.ok && result.cancelled) {
|
||
statusMsg.textContent = result.message || '取消请求已发送。';
|
||
statusMsg.className = '';
|
||
} else {
|
||
statusMsg.textContent = result.message || '取消失败。';
|
||
statusMsg.className = 'error-message';
|
||
submitButton.disabled = false;
|
||
submitButton.textContent = '取消翻译';
|
||
submitButton.removeAttribute('aria-busy');
|
||
}
|
||
} catch (error) {
|
||
console.error('取消请求失败:', error);
|
||
statusMsg.textContent = '取消请求发送失败。';
|
||
statusMsg.className = 'error-message';
|
||
submitButton.disabled = false;
|
||
submitButton.textContent = '取消翻译';
|
||
submitButton.removeAttribute('aria-busy');
|
||
}
|
||
}
|
||
|
||
submitButton.addEventListener('click', async function (event) {
|
||
event.preventDefault();
|
||
console.log(fileInput)
|
||
if (isTranslating) {
|
||
await cancelTranslation();
|
||
return;
|
||
}
|
||
|
||
[fileDropArea, mineruTokenInput, apikeyInput, modelInput, baseUrlInput].forEach(el => el.classList.remove('input-error'));
|
||
fileNameDisplay.classList.remove('input-error-text');
|
||
|
||
if (fileInput.files.length !== 1) {
|
||
statusMsg.textContent = '请选择一个文件进行翻译。';
|
||
statusMsg.className = 'error-message';
|
||
fileNameDisplay.textContent = '请选择文件!';
|
||
fileNameDisplay.classList.add('input-error-text');
|
||
fileDropArea.classList.add('input-error');
|
||
fileDropPrompt.classList.remove('hidden');
|
||
return
|
||
}
|
||
console.log(convertEnginSelect.value === 'mineru' && (!mineruTokenInput.value.trim()) && (!["md", "txt"].includes(fileInput.files[0].name.split('.').pop())))
|
||
if (convertEnginSelect.value === 'mineru' && !mineruTokenInput.value.trim() && (!["md", "txt"].includes(fileInput.files[0].name.split('.').pop()))) {
|
||
statusMsg.textContent = '使用 Mineru 引擎时,必须填写 Mineru Token。';
|
||
statusMsg.className = 'error-message';
|
||
mineruTokenInput.classList.add('input-error');
|
||
return
|
||
}
|
||
|
||
if (!apikeyInput.value.trim()) {
|
||
statusMsg.textContent = 'API 密钥不能为空。';
|
||
statusMsg.className = 'error-message';
|
||
apikeyInput.classList.add('input-error');
|
||
return
|
||
}
|
||
if (!modelInput.value.trim()) {
|
||
statusMsg.textContent = '模型 ID 不能为空。';
|
||
statusMsg.className = 'error-message';
|
||
modelInput.classList.add('input-error');
|
||
return
|
||
}
|
||
if (platformSelect.value === 'custom' && !baseUrlInput.value.trim()) {
|
||
statusMsg.textContent = '自定义接口时,API 地址不能为空。';
|
||
statusMsg.className = 'error-message';
|
||
baseUrlInput.classList.add('input-error');
|
||
return
|
||
}
|
||
|
||
stopPolling();
|
||
submitButton.disabled = true;
|
||
submitButton.setAttribute('aria-busy', 'true');
|
||
submitButton.textContent = '初始化...';
|
||
logArea.innerHTML = '';
|
||
statusMsg.textContent = '正在提交任务...';
|
||
statusMsg.className = '';
|
||
downloadBtns.style.display = 'none';
|
||
|
||
const formData = new FormData(form);
|
||
|
||
try {
|
||
const response = await fetch('/translate', {method: 'POST', body: formData});
|
||
const result = await response.json();
|
||
if (response.ok && result.task_started) {
|
||
statusMsg.textContent = result.message || '任务已开始,正在处理...';
|
||
statusMsg.className = '';
|
||
submitButton.textContent = '取消翻译';
|
||
submitButton.classList.remove('primary');
|
||
submitButton.classList.add('secondary', 'contrast');
|
||
isTranslating = true;
|
||
submitButton.disabled = false;
|
||
submitButton.removeAttribute('aria-busy');
|
||
startPolling();
|
||
} else {
|
||
statusMsg.textContent = result.message || `请求失败 (${response.status})`;
|
||
statusMsg.className = 'error-message';
|
||
submitButton.disabled = false;
|
||
submitButton.removeAttribute('aria-busy');
|
||
submitButton.textContent = '开始翻译';
|
||
isTranslating = false;
|
||
}
|
||
} catch (error) {
|
||
console.error('请求失败:', error);
|
||
statusMsg.textContent = '请求翻译失败,请检查网络或服务状态。';
|
||
statusMsg.className = 'error-message';
|
||
submitButton.disabled = false;
|
||
submitButton.removeAttribute('aria-busy');
|
||
submitButton.textContent = '开始翻译';
|
||
isTranslating = false;
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html> |