Files
docutranslate/docutranslate/static/index.html
2025-05-21 20:49:00 +08:00

1052 lines
45 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">
<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;
}
.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;
}
.error-message {
color: #d32f2f;
}
.success-message {
color: #2e7d32;
}
a.no-style {
text-decoration: none;
color: inherit;
}
.form-group {
margin-bottom: 1rem;
}
.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: 1rem;
}
.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>
<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>
</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');
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();
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('/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 => {
let finalHtml = html;
if (!html.toLowerCase().includes('/static/pico.css')) {
finalHtml = `<link rel="stylesheet" href="/static/pico.css">\n<style>body{padding:1em;}</style>\n${html}`;
}
const blob = new Blob([finalHtml], {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;
if (!htmlContent.toLowerCase().includes('/static/pico.css')) {
finalHtml = `<link rel="stylesheet" href="/static/pico.css">\n<style>body{padding:1em; break-inside: avoid;}</style>\n${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');
}
}
form.addEventListener('submit', async function (event) {
event.preventDefault();
if (isTranslating) {
await cancelTranslation();
return;
}
[fileDropArea, mineruTokenInput, apikeyInput, modelInput, baseUrlInput].forEach(el => el.classList.remove('input-error'));
fileNameDisplay.classList.remove('input-error-text');
let firstErrorElement = null;
let currentStatusMsg = '';
if (fileInput.files.length === 0) {
currentStatusMsg += '请选择一个文件进行翻译。';
fileNameDisplay.textContent = '请选择文件!';
fileNameDisplay.classList.add('input-error-text');
fileDropArea.classList.add('input-error');
fileDropPrompt.classList.remove('hidden');
if (!firstErrorElement) firstErrorElement = fileDropArea;
}
if (convertEnginSelect.value === 'mineru' && !mineruTokenInput.value.trim()) {
currentStatusMsg += (currentStatusMsg ? ' ' : '') + '使用 Mineru 引擎时,必须填写 Mineru Token。';
mineruTokenInput.classList.add('input-error');
if (!firstErrorElement) firstErrorElement = mineruTokenInput;
}
if (!apikeyInput.value.trim()) {
currentStatusMsg += (currentStatusMsg ? ' ' : '') + 'API 密钥不能为空。';
apikeyInput.classList.add('input-error');
if (!firstErrorElement) firstErrorElement = apikeyInput;
}
if (!modelInput.value.trim()) {
currentStatusMsg += (currentStatusMsg ? ' ' : '') + '模型 ID 不能为空。';
modelInput.classList.add('input-error');
if (!firstErrorElement) firstErrorElement = modelInput;
}
if (platformSelect.value === 'custom' && !baseUrlInput.value.trim()) {
currentStatusMsg += (currentStatusMsg ? ' ' : '') + '自定义接口时API 地址不能为空。';
baseUrlInput.classList.add('input-error');
if (!firstErrorElement) firstErrorElement = baseUrlInput;
}
if (firstErrorElement) {
statusMsg.textContent = currentStatusMsg;
statusMsg.className = 'error-message';
firstErrorElement.focus();
setTimeout(() => {
[fileDropArea, mineruTokenInput, apikeyInput, modelInput, baseUrlInput].forEach(el => el.classList.remove('input-error'));
fileNameDisplay.classList.remove('input-error-text');
if (fileNameDisplay.textContent === '请选择文件!' && fileInput.files.length === 0) {
fileNameDisplay.textContent = '未选择文件';
fileDropPrompt.classList.remove('hidden');
}
}, 3000);
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>