Files
docutranslate/docutranslate/static/index.html
2025-05-20 18:16:58 +08:00

752 lines
31 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="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; /* Pico invalid color */
}
.success-message {
color: #2e7d32; /* Pico valid color */
}
.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; /* Added gap for better spacing */
margin-bottom: 1rem;
}
.checkbox-group label { /* Ensure checkboxes are aligned */
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: 90%;
max-width: 900px;
max-height: 90vh;
border-radius: 8px;
overflow: auto;
}
#previewFrame {
width: 100%;
min-height: 500px;
border: 1px solid #ddd;
}
#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 { /* Extended to input/select */
border-color: #d32f2f !important;
}
#fileNameDisplay.input-error-text {
color: #d32f2f !important;
font-weight: bold;
}
@media (max-width: 768px) {
.form-grid {
grid-template-columns: 1fr;
}
}
</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">Docling</option>
</select>
</div>
<div class="form-group hidden" id="mineruTokenGroup">
<label for="mineru_token">Mineru Token</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 密钥</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>
<div id="previewModal" class="modal">
<div class="modal-content">
<span id="closeModalBtn" style="cursor:pointer; float:right; font-size: 1.5rem; line-height: 1;">×</span>
<h3>HTML 预览</h3>
<iframe id="previewFrame"></iframe>
<div class="button-group">
<button id="printFromPreview" class="primary">打印/保存为PDF</button>
<button id="closePreviewBtn" class="outline">关闭</button>
</div>
</div>
</div>
<iframe id="printFrame" style="display:none;"></iframe>
<script>
const platformSelect = document.getElementById('platform_select');
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');
const modal = document.getElementById('previewModal');
const previewFrame = document.getElementById('previewFrame');
const closeModalButton = document.getElementById('closeModalBtn');
const closePreviewBtn = document.getElementById('closePreviewBtn');
const printFromPreview = document.getElementById('printFromPreview');
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;
}
}
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');
} else {
baseUrlGroup.classList.add('hidden');
baseUrlInput.required = false;
baseUrlInput.value = selectedPlatformValue;
}
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;
// Optionally clear if not needed: mineruTokenInput.value = '';
}
saveToStorage('translator_convert_engin', selectedEngin);
}
function loadSettings() {
platformSelect.value = getFromStorage('translator_last_platform', 'custom');
updatePlatformUI();
convertEnginSelect.value = getFromStorage('translator_convert_engin', 'mineru');
updateConvertEnginUI(); // Must be after setting convertEnginSelect.value
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(); // Initial load
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()));
[closeModalButton, closePreviewBtn].forEach(elem => elem.addEventListener('click', () => modal.style.display = 'none'));
window.addEventListener('click', (event) => {
if (event.target === modal) modal.style.display = 'none';
});
printFromPreview.addEventListener('click', () => {
try {
previewFrame.contentWindow.focus();
previewFrame.contentWindow.print();
} catch (err) {
console.error('打印预览内容失败:', err);
alert('打印失败,请尝试使用浏览器的打印功能 (Ctrl+P 或 ⌘+P)。');
}
});
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 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;
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);
previewFrame.src = blobUrl;
previewFrame.onload = function () {
try {
previewFrame.contentWindow.document.title = currentFileName + '_translated';
URL.revokeObjectURL(blobUrl);
} catch (e) {
console.warn('无法设置iframe标题或释放Blob URL', e);
}
};
modal.style.display = 'block';
})
.catch(err => {
console.error('预览: 获取HTML内容失败:', err);
statusMsg.textContent = '获取HTML内容失败无法预览。';
statusMsg.className = 'error-message';
});
};
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 => {
iframe.onload = () => {
iframe.onload = null;
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);
}
};
iframe.srcdoc = htmlContent;
})
.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'); // Using contrast for cancel
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); // One last poll for logs
}
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 = ''; // Clear error class
} else {
statusMsg.textContent = result.message || '取消失败。';
statusMsg.className = 'error-message';
// Re-enable button if cancellation failed to register server-side
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');
}
// Status poller will eventually update the button state correctly
}
form.addEventListener('submit', async function (event) {
event.preventDefault();
if (isTranslating) {
await cancelTranslation();
return;
}
// Clear previous input errors
[fileDropArea, mineruTokenInput].forEach(el => el.classList.remove('input-error'));
fileNameDisplay.classList.remove('input-error-text');
if (fileInput.files.length === 0) {
statusMsg.textContent = '请选择一个文件进行翻译。';
statusMsg.className = 'error-message';
fileNameDisplay.textContent = '请选择文件!';
fileNameDisplay.classList.add('input-error-text');
fileDropArea.classList.add('input-error');
fileDropPrompt.classList.remove('hidden');
setTimeout(() => {
fileDropArea.classList.remove('input-error');
fileNameDisplay.classList.remove('input-error-text');
if (fileNameDisplay.textContent === '请选择文件!') fileNameDisplay.textContent = '未选择文件';
if (fileInput.files.length === 0) fileDropPrompt.classList.remove('hidden');
}, 3000);
return;
}
if (convertEnginSelect.value === 'mineru' && !mineruTokenInput.value.trim()) {
statusMsg.textContent = '使用 Mineru 引擎时,必须填写 Mineru Token。';
statusMsg.className = 'error-message';
mineruTokenInput.classList.add('input-error');
mineruTokenInput.focus();
setTimeout(() => mineruTokenInput.classList.remove('input-error'), 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);
// FormData automatically includes convert_engin and mineru_token due to 'name' attributes
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; // Enable cancel button
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>