Files
docutranslate/docutranslate/static/index.html
2025-07-15 21:04:49 +08:00

1368 lines
69 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">
<h4 class="mb-0"><i class="bi me-2"></i>Docutranslate</h4>
<span id="versionDisplay" class="badge bg-success"></span>
</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>
</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">English</option>
<option value="Japanese">Japanese</option>
<option value="Korean">Korean</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="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 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="mb-1">
项目主页(欢迎star❤): <br/>
<a href="https://github.com/xunbu/docutranslate" target="_blank" rel="noopener noreferrer">https://github.com/xunbu/docutranslate</a>
</p>
<p class="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>
<!-- 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) {
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;
const existingOriginalContent = originalPreviewPane.querySelector('iframe, pre, p');
if (existingOriginalContent) existingOriginalContent.remove();
translatedPreviewFrame.src = 'about:blank';
if (state.file) {
const fileType = state.file.type;
const fileExtension = state.file.name.split('.').pop().toLowerCase();
const textLikeExtensions = ['md', 'json', 'xml', 'log', 'py', 'js', 'css', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'rb', 'php', 'swift', 'kt', 'go', 'rs', 'ts', 'txt'];
if (fileType.startsWith('text/') || textLikeExtensions.includes(fileExtension)) {
const pre = document.createElement('pre');
state.file.text().then(text => pre.textContent = text).catch(() => pre.textContent = '无法读取原文内容。');
originalPreviewPane.appendChild(pre);
} else if (['application/pdf', 'text/html'].includes(fileType) || ['html', 'htm'].includes(fileExtension)) {
const iframe = document.createElement('iframe');
iframe.src = URL.createObjectURL(state.file);
originalPreviewPane.appendChild(iframe);
} else {
const p = document.createElement('p');
p.className = 'p-3 text-muted';
p.textContent = `无法直接预览此文件类型 (${fileType || '未知: ' + fileExtension})。`;
originalPreviewPane.appendChild(p);
}
} else {
const p = document.createElement('p');
p.className = 'p-3 text-muted';
p.textContent = '未找到原文文件缓存。';
originalPreviewPane.appendChild(p);
}
fetch(state.htmlUrl)
.then(resp => resp.ok ? resp.text() : Promise.reject(`HTTP error ${resp.status}`))
.then(html => {
translatedPreviewFrame.srcdoc = html;
setPreviewDisplayMode('bilingual');
previewOffcanvas.show();
})
.catch(err => {
console.error('Preview fetch failed:', err);
translatedPreviewFrame.srcdoc = `<h3>加载译文失败</h3><p>${err.message}</p>`;
setPreviewDisplayMode('bilingual');
previewOffcanvas.show();
});
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';
codeCheckbox.checked = getFromStorage('translator_code_ocr') === '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>