1368 lines
69 KiB
HTML
1368 lines
69 KiB
HTML
<!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> |